Ver código fonte

Merge branch 'refs/heads/master-clq-temp'

caoliqin 2 semanas atrás
pai
commit
ee04e97dbb
100 arquivos alterados com 9091 adições e 0 exclusões
  1. 22 0
      fs-admin/src/main/java/com/fs/app/controller/AppArticleController.java
  2. 23 0
      fs-admin/src/main/java/com/fs/app/controller/AppCivilianGoodsController.java
  3. 87 0
      fs-admin/src/main/java/com/fs/app/controller/AppCompanyUserDeptController.java
  4. 45 0
      fs-admin/src/main/java/com/fs/app/controller/AppCourseController.java
  5. 88 0
      fs-admin/src/main/java/com/fs/app/controller/AppCourseFinishTempController.java
  6. 30 0
      fs-admin/src/main/java/com/fs/app/controller/AppCoursePlaySourceConfigController.java
  7. 146 0
      fs-admin/src/main/java/com/fs/app/controller/AppCustomerRoleController.java
  8. 90 0
      fs-admin/src/main/java/com/fs/app/controller/AppCustomerRoleMemberController.java
  9. 40 0
      fs-admin/src/main/java/com/fs/app/controller/AppGenerateController.java
  10. 12 0
      fs-admin/src/main/java/com/fs/app/controller/AppGroupController.java
  11. 75 0
      fs-admin/src/main/java/com/fs/app/controller/AppInvitationCodeController.java
  12. 33 0
      fs-admin/src/main/java/com/fs/app/controller/AppLiveController.java
  13. 23 0
      fs-admin/src/main/java/com/fs/app/controller/AppMedicinesController.java
  14. 22 0
      fs-admin/src/main/java/com/fs/app/controller/AppOpenClassVideoController.java
  15. 22 0
      fs-admin/src/main/java/com/fs/app/controller/AppShortVideoController.java
  16. 122 0
      fs-admin/src/main/java/com/fs/app/controller/AppSopController.java
  17. 62 0
      fs-admin/src/main/java/com/fs/app/controller/AppSopLogsController.java
  18. 154 0
      fs-admin/src/main/java/com/fs/app/controller/AppSopTempController.java
  19. 62 0
      fs-admin/src/main/java/com/fs/app/controller/AppSopUserLogController.java
  20. 47 0
      fs-admin/src/main/java/com/fs/app/controller/AppSopUserLogInfoController.java
  21. 74 0
      fs-admin/src/main/java/com/fs/app/controller/AppUrgentClassTaskController.java
  22. 103 0
      fs-admin/src/main/java/com/fs/app/controller/AppUserChatLogsController.java
  23. 80 0
      fs-admin/src/main/java/com/fs/app/controller/AppUserController.java
  24. 144 0
      fs-admin/src/main/java/com/fs/app/controller/AppUserPortraitController.java
  25. 93 0
      fs-admin/src/main/java/com/fs/app/controller/AppWelcomeController.java
  26. 186 0
      fs-admin/src/main/java/com/fs/app/controller/CommonV2Controller.java
  27. 36 0
      fs-admin/src/main/java/com/fs/app/controller/FsUserInfoController.java
  28. 34 0
      fs-admin/src/main/java/com/fs/app/controller/VipTagController.java
  29. 98 0
      fs-admin/src/main/java/com/fs/app/controller/VipTagGroupController.java
  30. 79 0
      fs-admin/src/main/java/com/fs/app/controller/VipUserCustomerController.java
  31. 78 0
      fs-admin/src/main/java/com/fs/app/controller/VipUserTagController.java
  32. 116 0
      fs-admin/src/main/java/com/fs/app/job/UserActivityTask.java
  33. 97 0
      fs-admin/src/main/java/com/fs/app/job/UserConsumptionAmountTask.java
  34. 24 0
      fs-admin/src/main/java/com/fs/app/job/UserTagTask.java
  35. 201 0
      fs-admin/src/main/java/com/fs/app/job/VideoTrackTask.java
  36. 6 0
      fs-admin/src/main/java/com/fs/company/controller/CompanyUserController.java
  37. 514 0
      fs-admin/src/main/java/com/fs/course/controller/FsAppCourseWatchLogController.java
  38. 5 0
      fs-admin/src/main/java/com/fs/his/controller/FsPackageController.java
  39. 96 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsPickupGoodsController.java
  40. 103 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderPickController.java
  41. 93 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderPickItemController.java
  42. 106 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserRecordController.java
  43. 103 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserRecordValueController.java
  44. 109 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserTempController.java
  45. 143 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserTempFieldController.java
  46. 103 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreRedeemedRecordController.java
  47. 308 0
      fs-admin/src/main/java/com/fs/utils/AudioUtils.java
  48. 136 0
      fs-admin/src/main/java/com/fs/web/controller/system/AppDeptController.java
  49. 152 0
      fs-app-task-course/pom.xml
  50. 14 0
      fs-app-task-course/src/main/java/com/fs/FSServletInitializer.java
  51. 24 0
      fs-app-task-course/src/main/java/com/fs/FsAppTaskCourseApplication.java
  52. 29 0
      fs-app-task-course/src/main/java/com/fs/app/controller/CommonController.java
  53. 51 0
      fs-app-task-course/src/main/java/com/fs/app/exception/FSException.java
  54. 81 0
      fs-app-task-course/src/main/java/com/fs/app/exception/FSExceptionHandler.java
  55. 90 0
      fs-app-task-course/src/main/java/com/fs/app/task/AppCourseWatchLogScheduler.java
  56. 12 0
      fs-app-task-course/src/main/java/com/fs/app/taskService/SopLogsTaskService.java
  57. 309 0
      fs-app-task-course/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  58. 171 0
      fs-app-task-course/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java
  59. 73 0
      fs-app-task-course/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java
  60. 219 0
      fs-app-task-course/src/main/java/com/fs/framework/aspectj/LogAspect.java
  61. 117 0
      fs-app-task-course/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java
  62. 31 0
      fs-app-task-course/src/main/java/com/fs/framework/config/ApplicationConfig.java
  63. 58 0
      fs-app-task-course/src/main/java/com/fs/framework/config/ArrayStringTypeHandler.java
  64. 85 0
      fs-app-task-course/src/main/java/com/fs/framework/config/CaptchaConfig.java
  65. 119 0
      fs-app-task-course/src/main/java/com/fs/framework/config/DataSourceConfig.java
  66. 123 0
      fs-app-task-course/src/main/java/com/fs/framework/config/DruidConfig.java
  67. 72 0
      fs-app-task-course/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java
  68. 59 0
      fs-app-task-course/src/main/java/com/fs/framework/config/FilterConfig.java
  69. 76 0
      fs-app-task-course/src/main/java/com/fs/framework/config/KaptchaTextCreator.java
  70. 150 0
      fs-app-task-course/src/main/java/com/fs/framework/config/MyBatisConfig.java
  71. 153 0
      fs-app-task-course/src/main/java/com/fs/framework/config/RedisConfig.java
  72. 76 0
      fs-app-task-course/src/main/java/com/fs/framework/config/ResourcesConfig.java
  73. 11 0
      fs-app-task-course/src/main/java/com/fs/framework/config/RetryConfig.java
  74. 158 0
      fs-app-task-course/src/main/java/com/fs/framework/config/SecurityConfig.java
  75. 33 0
      fs-app-task-course/src/main/java/com/fs/framework/config/ServerConfig.java
  76. 121 0
      fs-app-task-course/src/main/java/com/fs/framework/config/SwaggerConfig.java
  77. 102 0
      fs-app-task-course/src/main/java/com/fs/framework/config/ThreadPoolConfig.java
  78. 77 0
      fs-app-task-course/src/main/java/com/fs/framework/config/properties/DruidProperties.java
  79. 27 0
      fs-app-task-course/src/main/java/com/fs/framework/datasource/DynamicDataSource.java
  80. 45 0
      fs-app-task-course/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java
  81. 115 0
      fs-app-task-course/src/main/java/com/fs/framework/exception/GlobalExceptionHandler.java
  82. 56 0
      fs-app-task-course/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java
  83. 126 0
      fs-app-task-course/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java
  84. 56 0
      fs-app-task-course/src/main/java/com/fs/framework/manager/AsyncManager.java
  85. 40 0
      fs-app-task-course/src/main/java/com/fs/framework/manager/ShutdownManager.java
  86. 106 0
      fs-app-task-course/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java
  87. 69 0
      fs-app-task-course/src/main/java/com/fs/framework/security/LoginBody.java
  88. 255 0
      fs-app-task-course/src/main/java/com/fs/framework/security/LoginUser.java
  89. 89 0
      fs-app-task-course/src/main/java/com/fs/framework/security/SecurityUtils.java
  90. 47 0
      fs-app-task-course/src/main/java/com/fs/framework/security/filter/JwtAuthenticationTokenFilter.java
  91. 35 0
      fs-app-task-course/src/main/java/com/fs/framework/security/handle/AuthenticationEntryPointImpl.java
  92. 54 0
      fs-app-task-course/src/main/java/com/fs/framework/security/handle/LogoutSuccessHandlerImpl.java
  93. 92 0
      fs-app-task-course/src/main/java/com/fs/framework/service/CompanyLoginService.java
  94. 66 0
      fs-app-task-course/src/main/java/com/fs/framework/service/CompanyPermissionService.java
  95. 170 0
      fs-app-task-course/src/main/java/com/fs/framework/service/PermissionService.java
  96. 236 0
      fs-app-task-course/src/main/java/com/fs/framework/service/TokenService.java
  97. 75 0
      fs-app-task-course/src/main/java/com/fs/framework/service/UserDetailsServiceImpl.java
  98. 1 0
      fs-app-task-course/src/main/resources/META-INF/spring-devtools.properties
  99. 10 0
      fs-app-task-course/src/main/resources/application.yml
  100. 2 0
      fs-app-task-course/src/main/resources/banner.txt

+ 22 - 0
fs-admin/src/main/java/com/fs/app/controller/AppArticleController.java

@@ -0,0 +1,22 @@
+package com.fs.app.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.his.service.IFsArticleService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/article")
+@RequiredArgsConstructor
+public class AppArticleController {
+
+    private final IFsArticleService fsArticleService;
+
+    @GetMapping("/findOptions")
+    public AjaxResult findOptions(String keyword, Long metaId, Long limit) {
+        return AjaxResult.success(fsArticleService.findOptions(keyword, metaId, limit));
+    }
+
+}

+ 23 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCivilianGoodsController.java

@@ -0,0 +1,23 @@
+package com.fs.app.controller;
+
+import com.fs.app.civilgoods.dto.CivilGoodsDTO;
+import com.fs.app.civilgoods.service.ICivilGoodsService;
+import com.fs.common.core.domain.AjaxResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/civilianGoods")
+@RequiredArgsConstructor
+public class AppCivilianGoodsController {
+
+    private final ICivilGoodsService appFsStoreProductScrmService;
+
+    @GetMapping("/findOptions")
+    public AjaxResult findOptions(CivilGoodsDTO req) {
+        return AjaxResult.success(appFsStoreProductScrmService.findOptions(req));
+    }
+
+}

+ 87 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCompanyUserDeptController.java

@@ -0,0 +1,87 @@
+package com.fs.app.controller;
+
+import com.fs.app.comuserdept.domain.AppCompanyUserDept;
+import com.fs.app.comuserdept.dto.AppCompanyUserDeptDTO;
+import com.fs.app.comuserdept.service.IAppCompanyUserDeptService;
+import com.fs.app.comuserdept.vo.AppCompanyUserDeptVO;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/comuserdept")
+public class AppCompanyUserDeptController extends BaseController {
+
+    @Autowired
+    private IAppCompanyUserDeptService appCompanyUserDeptService;
+
+    /**
+     * 查询app-销售/客服绑定部门(与销售原部门无关联)列表
+     */
+    @PreAuthorize("@ss.hasPermi('app:comuserdept:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AppCompanyUserDeptDTO appCompanyUserDept) {
+        startPage();
+        List<AppCompanyUserDeptVO> list = appCompanyUserDeptService.selectAppCompanyUserDeptList(appCompanyUserDept);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出app-销售/客服绑定部门(与销售原部门无关联)列表
+     */
+    @PreAuthorize("@ss.hasPermi('app:comuserdept:export')")
+    @Log(title = "app-销售/客服绑定部门(与销售原部门无关联)", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppCompanyUserDeptDTO appCompanyUserDept) {
+        List<AppCompanyUserDeptVO> list = appCompanyUserDeptService.selectAppCompanyUserDeptList(appCompanyUserDept);
+        ExcelUtil<AppCompanyUserDeptVO> util = new ExcelUtil<>(AppCompanyUserDeptVO.class);
+        return util.exportExcel(list, "app-销售/客服绑定部门(与销售原部门无关联)数据");
+    }
+
+    /**
+     * 获取app-销售/客服绑定部门(与销售原部门无关联)详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('app:comuserdept:list')")
+    @GetMapping(value = "/getById/{id}")
+    public AjaxResult getById(@PathVariable Long id) {
+        return AjaxResult.success(appCompanyUserDeptService.selectAppCompanyUserDeptById(id));
+    }
+
+    /**
+     * 新增app-销售/客服绑定部门(与销售原部门无关联)
+     */
+    @PreAuthorize("@ss.hasPermi('app:comuserdept:add')")
+    @Log(title = "app-销售/客服绑定部门(与销售原部门无关联)", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody AppCompanyUserDept appCompanyUserDept) {
+        return toAjax(appCompanyUserDeptService.insertAppCompanyUserDept(appCompanyUserDept));
+    }
+
+    /**
+     * 修改app-销售/客服绑定部门(与销售原部门无关联)
+     */
+    @PreAuthorize("@ss.hasPermi('app:comuserdept:edit')")
+    @Log(title = "app-销售/客服绑定部门(与销售原部门无关联)", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    public AjaxResult edit(@RequestBody AppCompanyUserDept appCompanyUserDept) {
+        return toAjax(appCompanyUserDeptService.updateAppCompanyUserDept(appCompanyUserDept));
+    }
+
+    /**
+     * 删除app-销售/客服绑定部门(与销售原部门无关联)
+     */
+    @PreAuthorize("@ss.hasPermi('app:comuserdept:delete')")
+    @Log(title = "app-销售/客服绑定部门(与销售原部门无关联)", businessType = BusinessType.DELETE)
+    @DeleteMapping("/deleteByIds/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(appCompanyUserDeptService.deleteAppCompanyUserDeptByIds(ids));
+    }
+}

+ 45 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCourseController.java

@@ -0,0 +1,45 @@
+package com.fs.app.controller;
+
+import com.fs.app.course.service.IAppFsUserCourseService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.his.vo.OptionsVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/course")
+@RequiredArgsConstructor
+public class AppCourseController extends BaseController {
+
+    private final IAppFsUserCourseService appFsUserCourseService;
+
+    /**
+     * 获取课程选项
+     *
+     * @param keyword
+     * @return
+     */
+    @GetMapping("/courseList")
+    public R courseList(String keyword, String selectedId) {
+        startPage();
+        return R.ok().put("list", appFsUserCourseService.findOptions(keyword, selectedId));
+    }
+
+    /**
+     * 获取指定课程的所有视频列表
+     * @param courseId
+     * @param title
+     * @return
+     */
+    @GetMapping(value = "/videoList")
+    public R videoList(Long courseId, String title) {
+        List<OptionsVO> optionsVOS = appFsUserCourseService.findVideoOptionsByCourse(courseId, title);
+        return R.ok().put("list", optionsVOS);
+    }
+
+}

+ 88 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCourseFinishTempController.java

@@ -0,0 +1,88 @@
+package com.fs.app.controller;
+
+import com.fs.app.course.dto.AppCourseFinishTempDTO;
+import com.fs.app.course.service.IAppCourseFinishTempService;
+import com.fs.app.course.vo.AppCourseFinishTempVO;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/courseFinishTemp")
+public class AppCourseFinishTempController extends BaseController {
+
+    @Autowired
+    private IAppCourseFinishTempService appCourseFinishTempService;
+
+    /**
+     * 查询app-完课模板列表
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:finishTemp:list')")
+    @GetMapping("/findList")
+    public TableDataInfo list(AppCourseFinishTempDTO appCourseFinishTemp) {
+        startPage();
+        List<AppCourseFinishTempVO> list = appCourseFinishTempService.selectAppCourseFinishTempList(appCourseFinishTemp);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出app-完课模板列表
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:finishTemp:export')")
+    @Log(title = "app-完课模板", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppCourseFinishTempDTO appCourseFinishTemp) {
+        List<AppCourseFinishTempVO> list = appCourseFinishTempService.selectAppCourseFinishTempList(appCourseFinishTemp);
+        ExcelUtil<AppCourseFinishTempVO> util = new ExcelUtil<>(AppCourseFinishTempVO.class);
+        return util.exportExcel(list, "app-完课模板数据");
+    }
+
+    /**
+     * 获取app-完课模板详细信息
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:finishTemp:list')")
+    @Log(title = "app-完课模板", businessType = BusinessType.OTHER)
+    @GetMapping(value = "/getById/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(appCourseFinishTempService.selectAppCourseFinishTempById(id));
+    }
+
+    /**
+     * 新增app-完课模板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:finishTemp:add')")
+    @Log(title = "app-完课模板", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody AppCourseFinishTempDTO appCourseFinishTemp) {
+        return toAjax(appCourseFinishTempService.insertAppCourseFinishTemp(appCourseFinishTemp));
+    }
+
+    /**
+     * 修改app-完课模板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:finishTemp:edit')")
+    @Log(title = "app-完课模板", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    public AjaxResult edit(@RequestBody AppCourseFinishTempDTO appCourseFinishTemp) {
+        return toAjax(appCourseFinishTempService.updateAppCourseFinishTemp(appCourseFinishTemp));
+    }
+
+    /**
+     * 删除app-完课模板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:finishTemp:delete')")
+    @Log(title = "app-完课模板", businessType = BusinessType.DELETE)
+    @DeleteMapping("/deleteByIds/{ids}")
+    public AjaxResult remove(@PathVariable List<Long> ids) {
+        return toAjax(appCourseFinishTempService.deleteAppCourseFinishTempByIds(ids));
+    }
+
+}

+ 30 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCoursePlaySourceConfigController.java

@@ -0,0 +1,30 @@
+package com.fs.app.controller;
+
+import com.fs.app.sop.domain.AppFsCoursePlaySourceConfig;
+import com.fs.app.sop.service.IAppCoursePlaySourceConfigService;
+import com.fs.app.sop.vo.AppCoursePlaySourceConfigVO;
+import com.fs.common.BeanCopyUtils;
+import com.fs.common.core.domain.R;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/cpsc")
+@RequiredArgsConstructor
+public class AppCoursePlaySourceConfigController {
+
+    private final IAppCoursePlaySourceConfigService appCoursePlaySourceConfigService;
+
+    @GetMapping(value = "/getList")
+    public R getCompanyInfo() {
+        //查询小程序名称
+        List<AppFsCoursePlaySourceConfig> cpsc = appCoursePlaySourceConfigService.lambdaQuery().list();
+        List<AppCoursePlaySourceConfigVO> vos = BeanCopyUtils.copyList(cpsc, AppCoursePlaySourceConfigVO.class);
+        return R.ok().put("data", vos);
+    }
+
+}

+ 146 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCustomerRoleController.java

@@ -0,0 +1,146 @@
+package com.fs.app.controller;
+
+import cn.hutool.http.HttpRequest;
+import com.fs.app.cusrole.domain.AppCustomerRole;
+import com.fs.app.cusrole.dto.AppCustomerRoleDTO;
+import com.fs.app.cusrole.service.IAppCustomerRoleService;
+import com.fs.app.cusrole.vo.AppCustomerRoleManageVO;
+import com.fs.app.cusrole.vo.AppCustomerRoleVO;
+import com.fs.app.user.vo.AppUserVO;
+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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.im.service.OpenIMService;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/app/cusrole")
+@RequiredArgsConstructor
+public class AppCustomerRoleController extends BaseController {
+
+    private final IAppCustomerRoleService appCustomerRoleService;
+
+    @Autowired
+    private OpenIMService openIMService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(AppCustomerRoleDTO dto) {
+        startPage();
+        List<AppCustomerRoleVO> list = appCustomerRoleService.findList(dto);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分页查询客服组(管理端)
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrole:list')")
+    @GetMapping("/pageList")
+    public TableDataInfo rolePageList(AppCustomerRoleDTO dto) {
+        startPage();
+        List<AppCustomerRoleManageVO> list = appCustomerRoleService.selectAppCustomerRoleList(dto);
+        return getDataTable(list);
+    }
+
+    /**
+     * 新增客服组
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrole:add')")
+    @Log(title = "app-客服组", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult addRole(@RequestBody AppCustomerRole appCustomerRole) {
+        return toAjax(appCustomerRoleService.insertAppCustomerRole(appCustomerRole));
+    }
+
+    /**
+     * 修改客服组
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrole:edit')")
+    @Log(title = "app-客服组", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult editRole(@RequestBody AppCustomerRole appCustomerRole) {
+        return toAjax(appCustomerRoleService.updateAppCustomerRole(appCustomerRole));
+    }
+
+    @ApiOperation("校验客服是否注册新的im")
+    @PostMapping("/accountCheck")
+    public R accountCheck(@RequestBody Map<String, String> userIdMap){
+        //获取管理员token
+        String userId = userIdMap.get("userId");
+        String adminToken = openIMService.getAdminToken();
+        JSONObject requestBody = new JSONObject();
+        // 解析响应
+        if (StringUtils.isNotEmpty(adminToken)) {
+            //查询用户是否注册
+            ArrayList<String> userIds = new ArrayList<>();
+            requestBody = new JSONObject();
+            userIds.add(userId);
+            requestBody.put("checkUserIDs", userIds);
+            String body = HttpRequest.post("https://web.im.cdwjyyh.com/api/user/account_check")
+                    .header("operationID", String.valueOf(System.currentTimeMillis()))
+                    .header("token", adminToken)
+                    .body(requestBody.toString())
+                    .execute()
+                    .body();
+            JSONObject jsonObject = new JSONObject(body);
+            JSONArray results = jsonObject.getJSONObject("data").getJSONArray("results");
+            if (results != null && results.length() > 0) {
+                JSONObject resultObj = results.getJSONObject(0);
+                int accountStatus = resultObj.getInt("accountStatus");
+                //未注册自动注册
+                if (accountStatus==0){
+                    String s = userId.replaceFirst("^C", "");
+                    AppUserVO appUserVO = appCustomerRoleService.selectById(Long.parseLong(s));
+                    if (null==appUserVO){
+                        return R.error("用户不存在");
+                    }
+                    ArrayList<Object> users = new ArrayList<>();
+                    HashMap<String, String> map = new HashMap<>();
+                    map.put("userID",userId);
+                    map.put("nickname",appUserVO.getUsername());
+                    map.put("faceURL",appUserVO.getAvatar());
+                    users.add(map);
+                    requestBody = new JSONObject();
+                    userIds.add(userId);
+                    requestBody.put("users", users);
+                    HttpRequest.post("https://web.im.cdwjyyh.com/api/user/user_register")
+                            .header("operationID", String.valueOf(System.currentTimeMillis()))
+                            .header("token", adminToken).body(requestBody.toString()).execute().body();
+                }
+            } else {
+                return R.error("返回结果为空");
+            }
+           /* HashMap<String, String> tokenMap = new HashMap<>();
+            tokenMap.put("platformID","1");
+            tokenMap.put("userID",userId);*/
+            requestBody = new JSONObject();
+            requestBody.put("platformID",5);
+            requestBody.put("userID",userId);
+            String body1 = HttpRequest.post("https://web.im.cdwjyyh.com/api/auth/get_user_token")
+                    .header("operationID", String.valueOf(System.currentTimeMillis()))
+                    .header("token", adminToken)
+                    .body(requestBody.toString()).execute().body();
+            JSONObject userJson = new JSONObject(body1);
+            JSONObject userData = userJson.getJSONObject("data");
+            String userToken = userData.getString("token");
+            return R.ok().put("token", userToken);
+        } else {
+            return R.error("获取管理员token失败");
+        }
+    }
+
+}

+ 90 - 0
fs-admin/src/main/java/com/fs/app/controller/AppCustomerRoleMemberController.java

@@ -0,0 +1,90 @@
+package com.fs.app.controller;
+
+import com.fs.app.cusrole.domain.AppCustomerRoleMember;
+import com.fs.app.cusrole.dto.AppCustomerRoleMemberDTO;
+import com.fs.app.cusrole.service.IAppCustomerRoleMemberService;
+import com.fs.app.cusrole.vo.AppCustomerRoleMemberVO;
+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.StringUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * app-客服组成员管理
+ */
+@RestController
+@RequestMapping("/app/cusrole/member")
+@RequiredArgsConstructor
+public class AppCustomerRoleMemberController extends BaseController {
+
+    private final IAppCustomerRoleMemberService appCustomerRoleMemberService;
+
+    /**
+     * 分页查询客服
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrolemember:list')")
+    @GetMapping("/pageList")
+    public TableDataInfo memberPageList(
+            @RequestParam(required = false) String keyword,
+            @RequestParam(required = false) Long appCustomerId) {
+        startPage();
+        AppCustomerRoleMember query = new AppCustomerRoleMember();
+        query.setAppCustomerId(appCustomerId);
+        if (StringUtils.isNotEmpty(keyword)) {
+            query.setMemberName(keyword);
+        }
+        query.setIsDelete(0);
+        List<AppCustomerRoleMemberVO> list = appCustomerRoleMemberService.selectAppCustomerRoleMemberList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 新增客服
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrolemember:add')")
+    @Log(title = "app-客服", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AppCustomerRoleMemberDTO dto) {
+        AppCustomerRoleMember member = new AppCustomerRoleMember();
+        member.setId(dto.getCompanyUserId());
+        member.setMemberName(dto.getCompanyUserName());
+        member.setAppCustomerId(dto.getAppCustomerId());
+        member.setAvatar(dto.getAvatar());
+        member.setRemark(dto.getRemark());
+        return toAjax(appCustomerRoleMemberService.insertAppCustomerRoleMember(member));
+    }
+
+    /**
+     * 修改客服
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrolemember:edit')")
+    @Log(title = "app-客服", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AppCustomerRoleMemberDTO dto) {
+        AppCustomerRoleMember member = new AppCustomerRoleMember();
+        member.setRoleMemberId(dto.getRoleMemberId());
+        member.setId(dto.getCompanyUserId());
+        member.setMemberName(dto.getCompanyUserName());
+        member.setAppCustomerId(dto.getAppCustomerId());
+        member.setAvatar(dto.getAvatar());
+        member.setRemark(dto.getRemark());
+        return toAjax(appCustomerRoleMemberService.updateAppCustomerRoleMember(member));
+    }
+
+    /**
+     * 批量删除客服(逻辑删除)
+     */
+    @PreAuthorize("@ss.hasPermi('app:cusrolemember:delete')")
+    @Log(title = "app-客服", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(appCustomerRoleMemberService.deleteAppCustomerRoleMemberByIds(ids));
+    }
+}

+ 40 - 0
fs-admin/src/main/java/com/fs/app/controller/AppGenerateController.java

@@ -0,0 +1,40 @@
+package com.fs.app.controller;
+
+import com.fs.app.cusrole.mapper.AppCustomerRoleMapper;
+import com.fs.app.sop.service.impl.AppSopUserLogsInfoHandle;
+import com.fs.common.core.domain.R;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/generate")
+@RequiredArgsConstructor
+public class AppGenerateController {
+
+    private final AppCustomerRoleMapper appCustomerRoleMapper;
+
+    private final AppSopUserLogsInfoHandle appSopUserLogsInfoHandle;
+
+    /**
+     * 加入或创建营期
+     *
+     * @param userIds
+     * @return
+     */
+    @GetMapping("/joinOrCreateUserLogs/{userIds}")
+    public R joinOrCreateUserLogs(@PathVariable List<Long> userIds) {
+        for (Long userId : userIds) {
+            List<Long> customerIds = appCustomerRoleMapper.getCustomerIdsByUserId(userId);
+            customerIds.forEach(customerId -> {
+                this.appSopUserLogsInfoHandle.joinOrCreateSopUserLogsHandle(customerId, userId);
+            });
+        }
+        return R.ok();
+    }
+
+}

+ 12 - 0
fs-admin/src/main/java/com/fs/app/controller/AppGroupController.java

@@ -0,0 +1,12 @@
+package com.fs.app.controller;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/groupChat")
+@RequiredArgsConstructor
+public class AppGroupController {
+
+}

+ 75 - 0
fs-admin/src/main/java/com/fs/app/controller/AppInvitationCodeController.java

@@ -0,0 +1,75 @@
+package com.fs.app.controller;
+
+import com.fs.app.invitation.dto.AppInvitationCodeDTO;
+import com.fs.app.invitation.service.IAppInvitationCodeService;
+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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * app-邀请码管理
+ */
+@RestController
+@RequestMapping("/app/invCode")
+@RequiredArgsConstructor
+public class AppInvitationCodeController extends BaseController {
+
+    private final IAppInvitationCodeService appInvitationCodeService;
+
+    /**
+     * 列表查询
+     * @param reqData
+     * @return
+     */
+    @GetMapping("/findList")
+    @PreAuthorize("@ss.hasAnyPermi('app:invite:list')")
+    public TableDataInfo findList(AppInvitationCodeDTO reqData) {
+        startPage();
+        return getDataTable(appInvitationCodeService.findList(reqData));
+    }
+
+    /**
+     * 新增编辑保存
+     * @param reqData
+     * @return
+     */
+    @PostMapping("/save")
+    @PreAuthorize("@ss.hasAnyPermi('app:invite:add,app:invite:edit')")
+    @Log(title = "邀请码", businessType = BusinessType.UPDATE)
+    public R saveAddOrEdit(@RequestBody AppInvitationCodeDTO reqData) {
+        return appInvitationCodeService.saveAddOrEdit(reqData);
+    }
+
+    /**
+     * 根据id获取指定记录详情
+     * @param reqData
+     * @return
+     */
+    @GetMapping("/getById")
+    @PreAuthorize("@ss.hasAnyPermi('app:invite:list')")
+    public AjaxResult getById(AppInvitationCodeDTO reqData) {
+        return AjaxResult.success(appInvitationCodeService.getById(reqData));
+    }
+
+    /**
+     * 邀请码删除
+     * @param ids
+     * @return
+     */
+    @DeleteMapping("/deleteByIds/{ids}")
+    @PreAuthorize("@ss.hasAnyPermi('app:invite:delete')")
+    @Log(title = "邀请码", businessType = BusinessType.DELETE)
+    public R deleteByIds(@PathVariable List<Long> ids) {
+        this.appInvitationCodeService.deleteByIds(ids);
+        return R.ok();
+    }
+
+}

+ 33 - 0
fs-admin/src/main/java/com/fs/app/controller/AppLiveController.java

@@ -0,0 +1,33 @@
+package com.fs.app.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.Live;
+import com.fs.live.service.ILiveService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/live")
+@RequiredArgsConstructor
+public class AppLiveController extends BaseController {
+
+    private final ILiveService liveService;
+
+    /**
+     * 查询直播列表
+     */
+//    @PreAuthorize("@ss.hasPermi('live:live:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(Live live)
+    {
+        startPage();
+        List<Live> list = liveService.selectLiveList(live);
+        return getDataTable(list);
+    }
+
+}

+ 23 - 0
fs-admin/src/main/java/com/fs/app/controller/AppMedicinesController.java

@@ -0,0 +1,23 @@
+package com.fs.app.controller;
+
+import com.fs.app.medicines.dto.AppFsStoreProductDTO;
+import com.fs.app.medicines.service.IAppFsStoreProductService;
+import com.fs.common.core.domain.AjaxResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/medicines")
+@RequiredArgsConstructor
+public class AppMedicinesController {
+
+    private final IAppFsStoreProductService appFsStoreProductService;
+
+    @GetMapping("/findOptions")
+    public AjaxResult findOptions(AppFsStoreProductDTO req) {
+        return AjaxResult.success(appFsStoreProductService.findOptions(req));
+    }
+
+}

+ 22 - 0
fs-admin/src/main/java/com/fs/app/controller/AppOpenClassVideoController.java

@@ -0,0 +1,22 @@
+package com.fs.app.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.course.service.IFsUserCourseVideoService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/openClassVideo")
+@RequiredArgsConstructor
+public class AppOpenClassVideoController {
+
+    private final IFsUserCourseVideoService fsUserCourseVideoService;
+
+    @GetMapping("/findOptions")
+    public AjaxResult findOptions(String keyword, Long metaId, Long limit) {
+        return AjaxResult.success(fsUserCourseVideoService.findOptions(keyword, metaId, limit));
+    }
+
+}

+ 22 - 0
fs-admin/src/main/java/com/fs/app/controller/AppShortVideoController.java

@@ -0,0 +1,22 @@
+package com.fs.app.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.course.service.IFsUserVideoService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/app/shortVideo")
+@RequiredArgsConstructor
+public class AppShortVideoController {
+
+    private final IFsUserVideoService fsUserVideoService;
+
+    @GetMapping("/findOptions")
+    public AjaxResult findOptions(String keyword, Long metaId, Long limit) {
+        return AjaxResult.success(fsUserVideoService.findOptions(keyword, metaId, limit));
+    }
+
+}

+ 122 - 0
fs-admin/src/main/java/com/fs/app/controller/AppSopController.java

@@ -0,0 +1,122 @@
+package com.fs.app.controller;
+
+import com.fs.app.sop.dto.AppSopDTO;
+import com.fs.app.sop.service.IAppSopService;
+import com.fs.app.sop.vo.AppSopVO;
+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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/sop")
+@RequiredArgsConstructor
+public class AppSopController extends BaseController {
+
+
+    private final IAppSopService appSopService;
+
+    /**
+     * 查询 sop 列表
+     */
+    @PreAuthorize("@ss.hasPermi('app:sop:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AppSopDTO sopDTO) {
+        startPage();
+        List<AppSopVO> list = appSopService.findList(sopDTO);
+        return getDataTable(list);
+    }
+
+    /**
+     * 新增 sop
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:sop:add')")
+    @Log(title = "appSop", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody AppSopDTO sop) {
+        int count = appSopService.add(sop);
+//        if (count > 0) {
+//            if (qwSop.getQwUserIds() != null) {
+//                updateTempVoiceInfo(qwSop);
+//            }
+//        }
+        return toAjax(count);
+    }
+
+    /**
+     * 修改 sop
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:sop:edit')")
+    @Log(title = "修改appSop", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    public R edit(@RequestBody AppSopDTO sopDTO) {
+        R sop = appSopService.edit(sopDTO);
+//        String code = sop.get("code").toString();
+//        if(code.equals("200")){
+//            if(qwSop != null && qwSop.getQwUserIds() != null){
+//                updateTempVoiceInfo(qwSop);
+//            }
+//        }
+        return sop;
+    }
+
+    /**
+     * 删除 sop
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:sop:delete')")
+    @Log(title = "删除appSop", businessType = BusinessType.DELETE)
+    @DeleteMapping("/delete/{ids}")
+    public AjaxResult remove(@PathVariable List<String> ids) {
+        return toAjax(appSopService.deleteSopByIds(ids));
+    }
+
+    /**
+     * 根据id获取 sop详情
+     */
+    @PreAuthorize("@ss.hasPermi('app:sop:list')")
+    @GetMapping("/getById")
+    public AjaxResult getById(String id) {
+        return AjaxResult.success(appSopService.getById(id));
+    }
+
+    /**
+     * 修改 sop 自动创建时间
+     */
+    @Log(title = "appSop自动创建时间", businessType = BusinessType.UPDATE)
+    @PreAuthorize("@ss.hasAnyPermi('app:sop:edit')")
+    @PostMapping("/updateAutoSopTime")
+    public AjaxResult updateAutoSopTime(@RequestBody AppSopDTO appSopReq) {
+        return toAjax(appSopService.updateAutoSopTime(appSopReq));
+    }
+
+    /**
+     * 更新执行状态
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:sop:exec')")
+    @Log(title = "修改执行状态", businessType = BusinessType.UPDATE)
+    @GetMapping(value = "/updateStatus/{ids}")
+    public R updateStatus(@PathVariable List<String> ids) {
+        return appSopService.updateStatusByIds(ids);
+    }
+
+    /**
+     * 导出sop列表
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:sop:export')")
+    @Log(title = "sop", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppSopDTO sopDTO) {
+        List<AppSopVO> list = appSopService.findList(sopDTO);
+        ExcelUtil<AppSopVO> util = new ExcelUtil<>(AppSopVO.class);
+        return util.exportExcel(list, "自动化数据");
+    }
+
+}

+ 62 - 0
fs-admin/src/main/java/com/fs/app/controller/AppSopLogsController.java

@@ -0,0 +1,62 @@
+package com.fs.app.controller;
+
+import com.fs.app.sop.dto.AppSopLogDTO;
+import com.fs.app.sop.service.IAppSopLogsService;
+import com.fs.app.sop.vo.AppSopLogsVO;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/sopLogs")
+@RequiredArgsConstructor
+public class AppSopLogsController extends BaseController {
+
+    private final IAppSopLogsService appSopLogsService;
+
+    @GetMapping("/findList")
+    public TableDataInfo findList(AppSopLogDTO param) {
+        startPage();
+        return getDataTable(this.appSopLogsService.findList(param));
+    }
+
+    /**
+     * 删除SOP  定时任务
+     */
+//    @PreAuthorize("@ss.hasPermi('qw:sopLogs:remove')")
+    @Log(title = "SOP  定时任务", businessType = BusinessType.DELETE)
+    @DeleteMapping("/deleteByIds/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(appSopLogsService.deleteByIds(ids));
+    }
+
+    /**
+     * 修改SOP  定时任务-只修改完课的补发
+     */
+//    @PreAuthorize("@ss.hasPermi('qw:sopLogs:editCourse')")
+    @Log(title = "修改SOP只修改完课", businessType = BusinessType.UPDATE)
+    @PutMapping("/editCourseSopLogs/{ids}")
+    public AjaxResult editCourseQwSopLogs(@PathVariable Long[] ids) {
+        return toAjax(appSopLogsService.editCourseSopLogs(ids));
+    }
+
+    /**
+     * 导出企业微信SOP  定时任务列表
+     */
+//    @PreAuthorize("@ss.hasPermi('qw:sopLogs:export')")
+    @Log(title = "SOP  定时任务", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppSopLogDTO param) {
+        List<AppSopLogsVO> list = appSopLogsService.findList(param);
+        ExcelUtil<AppSopLogsVO> util = new ExcelUtil<>(AppSopLogsVO.class);
+        return util.exportExcel(list, "SOP 定时任务数据");
+    }
+
+}

+ 154 - 0
fs-admin/src/main/java/com/fs/app/controller/AppSopTempController.java

@@ -0,0 +1,154 @@
+package com.fs.app.controller;
+
+import com.fs.app.sop.dto.AppSopTempDTO;
+import com.fs.app.sop.dto.AppSopTempDayDTO;
+import com.fs.app.sop.dto.AppSopTempDaySortDTO;
+import com.fs.app.sop.service.IAppSopTempService;
+import com.fs.app.sop.vo.AppSopTempVO;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ *
+ * @author hulin
+ */
+@RestController
+@RequestMapping("/app/sopTemp")
+@RequiredArgsConstructor
+public class AppSopTempController extends BaseController {
+
+    private final IAppSopTempService appSopTempService;
+
+    /**
+     * 查询 sop 模板列表
+     */
+    @GetMapping("/list")
+    @PreAuthorize("@ss.hasAnyPermi('app:template:list')")
+    public TableDataInfo list(AppSopTempDTO param) {
+        startPage();
+        List<AppSopTempVO> list = appSopTempService.findList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出sop模板列表
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:template:export')")
+    @Log(title = "sop模板", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppSopTempDTO param) {
+        List<AppSopTempVO> list = appSopTempService.findList(param);
+        ExcelUtil<AppSopTempVO> util = new ExcelUtil(AppSopTempVO.class);
+        return util.exportExcel(list, "sop模板数据");
+    }
+
+    /**
+     * 获取 sop 模板详细信息
+     */
+    @GetMapping(value = "/{id}")
+    @PreAuthorize("@ss.hasAnyPermi('app:template:list')")
+    public AjaxResult getInfo(@PathVariable String id) {
+        return AjaxResult.success(appSopTempService.getOneById(id));
+    }
+
+    /**
+     * 新增 sop 模板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:template:add')")
+    @Log(title = "sop模板", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody AppSopTempDTO param) {
+        return toAjax(appSopTempService.addSopTemp(param));
+    }
+
+    /**
+     * 修改 sop 模板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:template:edit')")
+    @Log(title = "sop模板", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    public AjaxResult edit(@RequestBody AppSopTempDTO param) {
+        return toAjax(appSopTempService.editSopTemp(param));
+    }
+
+    /**
+     * 删除 sop 模板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:template:delete,app:template:stop')")
+    @Log(title = "sop模板", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String[] ids) {
+        return toAjax(appSopTempService.deleteSopTemp(ids));
+    }
+
+
+    /**
+     * 模板复制
+     *
+     * @param param
+     * @return
+     */
+    @Log(title = "sop模板复制", businessType = BusinessType.INSERT)
+    @PreAuthorize("@ss.hasAnyPermi('app:template:copy')")
+    @PostMapping("/copy")
+    public AjaxResult copyTemplate(@RequestBody AppSopTempDTO param) {
+        appSopTempService.copyTemplate(param);
+        return toAjax(1);
+    }
+
+    /**
+     * 新增/编辑 保存模板天数
+     *
+     * @param day
+     * @return
+     */
+    @Log(title = "添加修改sop模板天数", businessType = BusinessType.UPDATE)
+    @PreAuthorize("@ss.hasAnyPermi('app:template:edit')")
+    @PostMapping("/addOrUpdateSetting")
+    public AjaxResult addOrUpdateSetting(@RequestBody AppSopTempDayDTO day) {
+        return AjaxResult.success(appSopTempService.addOrUpdateSetting(day));
+    }
+
+    /**
+     * 获取规则信息
+     *
+     * @param id
+     * @return
+     */
+    @GetMapping("/selectRulesInfo")
+    public AjaxResult selectRulesInfo(Long id) {
+        return AjaxResult.success(appSopTempService.selectRulesInfo(id));
+    }
+
+
+    @GetMapping("/dayList")
+    public AjaxResult dayList(String id) {
+        return AjaxResult.success(appSopTempService.dayList(id));
+    }
+
+    @Log(title = "排序天数", businessType = BusinessType.UPDATE)
+    @PostMapping("/sortDay")
+    public AjaxResult sortDay(@RequestBody List<AppSopTempDaySortDTO> list) {
+        appSopTempService.sortDay(list);
+        return toAjax(1);
+    }
+
+    // 更新模板图片
+    @Log(title = "更新模板图片", businessType = BusinessType.UPDATE)
+    @PreAuthorize("@ss.hasAnyPermi('app:template:updateImage')")
+    @GetMapping("/updateImage")
+    public AjaxResult updateImage(String id){
+        appSopTempService.updateImage(id);
+        return AjaxResult.success();
+    }
+
+}

+ 62 - 0
fs-admin/src/main/java/com/fs/app/controller/AppSopUserLogController.java

@@ -0,0 +1,62 @@
+package com.fs.app.controller;
+
+import com.fs.app.sop.dto.AppGroupSendMessageDTO;
+import com.fs.app.sop.dto.AppSopUserLogDTO;
+import com.fs.app.sop.service.IAppSopUserLogService;
+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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/userLogs")
+@RequiredArgsConstructor
+public class AppSopUserLogController extends BaseController {
+
+    private final IAppSopUserLogService appSopUserLogService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(AppSopUserLogDTO dto) {
+        startPage();
+        return getDataTable(this.appSopUserLogService.findList(dto));
+    }
+
+    /**
+     * 删除sopUserLogs
+     */
+//    @PreAuthorize("@ss.hasPermi('qwSop:sopUserLogs:remove')")
+    @Log(title = "删除sopUserLogs", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable List<String> ids) {
+        return toAjax(appSopUserLogService.deleteByIds(ids));
+    }
+
+
+    /**
+     * 修改营期时间
+     */
+//    @PreAuthorize("@ss.hasPermi('qwSop:sopUserLogs:updateTime')")
+    @Log(title = "批量修改 sopUserLogs", businessType = BusinessType.UPDATE)
+    @PostMapping("/batchUpdateSopUserLogsToTime")
+    public R updateTime(@RequestBody AppSopUserLogDTO dto) {
+        return appSopUserLogService.batchUpdateSopUserLogsToTime(dto);
+    }
+
+    /**
+     * 一键群发
+     * @param appGroupSendMessageDTO
+     * @return
+     */
+    @PostMapping("/groupSendMessage")
+    public R groupSendMessage(@RequestBody AppGroupSendMessageDTO appGroupSendMessageDTO) {
+        this.appSopUserLogService.groupSendMessage(appGroupSendMessageDTO);
+        return R.ok();
+    }
+
+}

+ 47 - 0
fs-admin/src/main/java/com/fs/app/controller/AppSopUserLogInfoController.java

@@ -0,0 +1,47 @@
+package com.fs.app.controller;
+
+import com.fs.app.sop.dto.AppSopUserLogInfoDTO;
+import com.fs.app.sop.service.IAppSopUserLogInfoService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/app/userLogsInfo")
+@RequiredArgsConstructor
+public class AppSopUserLogInfoController {
+
+    private final IAppSopUserLogInfoService appSopUserLogInfoService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(AppSopUserLogInfoDTO dto) {
+        return this.appSopUserLogInfoService.findList(dto);
+    }
+
+    /**
+     * 删除 sopUserLogsInfo
+     */
+//    @PreAuthorize("@ss.hasPermi('qw:sopUserLogsInfo:remove')")
+    @Log(title = "deleteByIds", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable List<Long> ids) {
+        return appSopUserLogInfoService.deleteByIds(ids) > 0 ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    /**
+     * 营期详情内,修改具体用户所属的营期
+     */
+//    @PreAuthorize("@ss.hasPermi('qw:sopUserLogsInfo:edit')")
+    @Log(title = "updateSopUserLogsInfo", businessType = BusinessType.UPDATE)
+    @PostMapping("/batchUpdateSopUserLogsInfoToTime")
+    public R edit(@RequestBody AppSopUserLogInfoDTO dto) {
+        return appSopUserLogInfoService.batchUpdateSopUserLogsInfoToTime(dto);
+    }
+
+}

+ 74 - 0
fs-admin/src/main/java/com/fs/app/controller/AppUrgentClassTaskController.java

@@ -0,0 +1,74 @@
+package com.fs.app.controller;
+
+import com.fs.app.task.dto.AppWorkTaskDTO;
+import com.fs.app.task.service.IAppWorkTaskService;
+import com.fs.app.task.vo.AppWorkTaskVO;
+import com.fs.app.watchlog.service.IAppCourseWatchLogService;
+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.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * app-任务看板Controller
+ *
+ * @author fs
+ * @date 2026-03-03
+ */
+@RestController
+@RequestMapping("/app/urgentClass")
+public class AppUrgentClassTaskController extends BaseController {
+
+    @Autowired
+    private IAppWorkTaskService appWorkTaskService;
+
+    @Autowired
+    private IAppCourseWatchLogService appCourseWatchLogService;
+
+
+    /**
+     * 查询app-任务看板列表
+     */
+    @GetMapping("/list")
+    @PreAuthorize("@ss.hasAnyPermi('app:urgentClass:list')")
+    public TableDataInfo list(AppWorkTaskDTO appWorkTask) {
+        startPage();
+        List<AppWorkTaskVO> list = appWorkTaskService.selectAppWorkTaskList(appWorkTask);
+        for (AppWorkTaskVO appWorkTaskVO : list) {
+            appWorkTaskVO.setLatestCourseName(appCourseWatchLogService.recentWatchVideoName(appWorkTaskVO.getFsUserId(), appWorkTaskVO.getCustomerRoleId()));
+            appWorkTaskVO.setUserWatchLogs(this.appCourseWatchLogService.getAppUserWatchLogByDaysAndUserId(7, appWorkTaskVO.getFsUserId(), appWorkTaskVO.getCustomerRoleId()));
+        }
+        return getDataTable(list);
+    }
+
+    /**
+     * 修改app-任务看板
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:urgentClass:handle')")
+    @Log(title = "催课看板", businessType = BusinessType.UPDATE)
+    @PostMapping("/setStatus")
+    public AjaxResult setStatus(@RequestBody AppWorkTaskDTO appWorkTask) {
+        return toAjax(appWorkTaskService.setStatus(appWorkTask));
+    }
+
+    /**
+     * 查询app-任务看板用户电话
+     */
+    @GetMapping("/getFsUserPhone/{fsUserId}")
+    @PreAuthorize("@ss.hasAnyPermi('app:urgentClass:getFsUserPhone')")
+    public AjaxResult getFsUserPhone(@PathVariable Long fsUserId) {
+        String phone = appWorkTaskService.getFsUserPhone(fsUserId);
+        if(StringUtils.isNotBlank(phone)){
+            return AjaxResult.success("查询成功!",phone);
+        }else{
+            return AjaxResult.error("用户电话不存在");
+        }
+    }
+}

+ 103 - 0
fs-admin/src/main/java/com/fs/app/controller/AppUserChatLogsController.java

@@ -0,0 +1,103 @@
+package com.fs.app.controller;
+
+import com.fs.app.chat.domain.AppUserChatLogs;
+import com.fs.app.chat.dto.AppUserChatLogsDTO;
+import com.fs.app.chat.service.IAppUserChatLogsService;
+import com.fs.app.chat.vo.AppUserChatLogsVO;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * app-客户与客服对话(问答)Controller
+ *
+ * @author fs
+ * @date 2026-04-07
+ */
+@RestController
+@RequestMapping("/app/userChatLogs")
+public class AppUserChatLogsController extends BaseController {
+
+    @Autowired
+    private IAppUserChatLogsService appUserChatLogsService;
+
+    /**
+     * 查询app-客户与客服对话(问答)列表
+     */
+//    @PreAuthorize("@ss.hasPermi('app:userChatLogs:list')")
+    @GetMapping("/findList")
+    public TableDataInfo findList(AppUserChatLogsDTO appUserChatLogs) {
+        startPage();
+        List<AppUserChatLogsVO> list = appUserChatLogsService.selectAppUserChatLogsList(appUserChatLogs);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出app-客户与客服对话(问答)列表
+     */
+    @PreAuthorize("@ss.hasPermi('app:userChatLogs:export')")
+    @Log(title = "app-客户与客服对话(问答)", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppUserChatLogsDTO appUserChatLogs) {
+        List<AppUserChatLogsVO> list = appUserChatLogsService.selectAppUserChatLogsList(appUserChatLogs);
+        ExcelUtil<AppUserChatLogsVO> util = new ExcelUtil<>(AppUserChatLogsVO.class);
+        return util.exportExcel(list, "app-客户与客服对话(问答)数据");
+    }
+
+    /**
+     * 获取app-客户与客服对话(问答)详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('app:userChatLogs:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(appUserChatLogsService.selectAppUserChatLogsById(id));
+    }
+
+    /**
+     * 新增app-客户与客服对话(问答)
+     */
+    @PreAuthorize("@ss.hasPermi('app:userChatLogs:add')")
+    @Log(title = "app-客户与客服对话(问答)", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AppUserChatLogs appUserChatLogs) {
+        return toAjax(appUserChatLogsService.insertAppUserChatLogs(appUserChatLogs));
+    }
+
+    /**
+     * 修改app-客户与客服对话(问答)
+     */
+//    @PreAuthorize("@ss.hasPermi('app:userChatLogs:edit')")
+    @Log(title = "app-客户与客服对话(问答)", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    public AjaxResult edit(@RequestBody AppUserChatLogsDTO req) {
+        return AjaxResult.success("ok", appUserChatLogsService.updateAppUserChatLogs(req));
+    }
+
+    /**
+     * 删除app-客户与客服对话(问答)
+     */
+    @PreAuthorize("@ss.hasPermi('app:userChatLogs:remove')")
+    @Log(title = "app-客户与客服对话(问答)", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(appUserChatLogsService.deleteAppUserChatLogsByIds(ids));
+    }
+
+    /**
+     * 查询当前用户和当前客服的最新对话信息
+     *
+     * @return
+     */
+    @GetMapping("/findChatLogsByUserIdAndCustomerId")
+    public AjaxResult findChatLogsByUserIdAndCustomerId(@RequestParam Long userId, @RequestParam Long customerId) {
+        return AjaxResult.success(appUserChatLogsService.findChatLogsByUserIdAndCustomerId(userId, customerId));
+    }
+}

+ 80 - 0
fs-admin/src/main/java/com/fs/app/controller/AppUserController.java

@@ -0,0 +1,80 @@
+package com.fs.app.controller;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.app.user.dto.AppUserDTO;
+import com.fs.app.user.service.IAppUserService;
+import com.fs.app.user.vo.AppUserVO;
+import com.fs.common.annotation.Log;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+import static com.fs.his.utils.PhoneUtil.encryptPhone;
+import static com.fs.his.utils.PhoneUtil.encryptPhoneOldKey;
+
+@RestController
+@RequestMapping("/app/user")
+@RequiredArgsConstructor
+public class AppUserController {
+
+    private final IAppUserService userService;
+
+
+
+    /**
+     * 筛选列表
+     *
+     * @param userDTO
+     * @return
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(AppUserDTO userDTO) {
+        return userService.list(userDTO);
+    }
+
+    /**
+     * 数据列表
+     *
+     * @param userDTO
+     * @return
+     */
+    @GetMapping("/findList")
+    @PreAuthorize("@ss.hasPermi('app:user:list')")
+    public TableDataInfo findList(AppUserDTO userDTO) {
+        //原本有两种手机号加密方式,为了兼容,故两种都需要计算
+        if (ObjectUtil.isNotEmpty(userDTO.getPhone())) {
+            userDTO.setEncryptPhone(encryptPhone(userDTO.getPhone()));
+            userDTO.setEncryptPhone2(encryptPhoneOldKey(userDTO.getPhone()));
+        }
+        return userService.findList(userDTO);
+    }
+
+    /**
+     * 导出用户列表
+     */
+    @PreAuthorize("@ss.hasPermi('app:user:export')")
+    @Log(title = "用户", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppUserDTO userDTO) {
+        //原本有两种手机号加密方式,为了兼容,故两种都需要计算
+        if (ObjectUtil.isNotEmpty(userDTO.getPhone())) {
+            userDTO.setEncryptPhone(encryptPhone(userDTO.getPhone()));
+            userDTO.setEncryptPhone2(encryptPhoneOldKey(userDTO.getPhone()));
+        }
+        List<AppUserVO> list = userService.tableList(userDTO);
+        if (list.size() > 10000) {
+            return AjaxResult.error("导出数据不可超过1w条");
+        }
+        ExcelUtil<AppUserVO> util = new ExcelUtil<>(AppUserVO.class);
+        return util.exportExcel(list, "用户数据");
+    }
+
+}

+ 144 - 0
fs-admin/src/main/java/com/fs/app/controller/AppUserPortraitController.java

@@ -0,0 +1,144 @@
+package com.fs.app.controller;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.app.tag.domain.FsTag;
+import com.fs.app.tag.dto.FsContentTagDTO;
+import com.fs.app.tag.service.IFsTagService;
+import com.fs.app.user.domain.FsUserPortrait;
+import com.fs.app.user.domain.FsUserPortraitTag;
+import com.fs.app.user.service.IFsUserPortraitContentTagService;
+import com.fs.app.user.service.IFsUserPortraitService;
+import com.fs.app.user.service.IFsUserPortraitTagService;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.his.utils.PhoneUtil;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/app/user/portrait")
+@RequiredArgsConstructor
+public class AppUserPortraitController extends BaseController {
+
+    @Autowired
+    private IFsUserPortraitService userService;
+
+
+    @Autowired
+    private IFsUserPortraitContentTagService contentTagService;
+    @Autowired
+    private IFsUserPortraitTagService userPortraitTagService;
+    @Autowired
+    private IFsTagService fsTagService;
+
+    /**
+     * 数据列表
+     *
+     * @param userDTO
+     * @return
+     */
+    @GetMapping("/findList")
+    //@PreAuthorize("@ss.hasPermi('app:user:list')")
+    public TableDataInfo findList(FsUserPortrait userDTO) {
+        LambdaQueryWrapper<FsUserPortrait> wrapper = new LambdaQueryWrapper<>();
+        //原本有两种手机号加密方式,为了兼容,故两种都需要计算
+        if (ObjectUtil.isNotEmpty(userDTO.getPhone())) {
+            String s = PhoneUtil.encryptPhone(userDTO.getPhone());
+            String s1 = PhoneUtil.encryptPhoneOldKey(userDTO.getPhone());
+            wrapper.in(FsUserPortrait::getPhone, s, s1);
+        }
+        wrapper.eq(ObjectUtil.isNotEmpty(userDTO.getNickName()),FsUserPortrait::getNickName, userDTO.getNickName());
+        wrapper.eq(ObjectUtil.isNotEmpty(userDTO.getUserId()),FsUserPortrait::getUserId, userDTO.getUserId());
+        wrapper.eq(ObjectUtil.isNotEmpty(userDTO.getActivityLevel()),FsUserPortrait::getActivityLevel, userDTO.getActivityLevel());
+        wrapper.eq(ObjectUtil.isNotEmpty(userDTO.getConsumptionLevel()),FsUserPortrait::getConsumptionLevel, userDTO.getConsumptionLevel());
+        int total = userService.count(wrapper);
+        startPage();
+        List<FsUserPortrait> list = userService.list(wrapper);
+        if (CollUtil.isNotEmpty(list)){
+            List<Long> userIds = list.stream()
+                    .map(FsUserPortrait::getUserId)
+                    .collect(Collectors.toList());
+
+            List<FsUserPortraitTag> relationList = userPortraitTagService.list(
+                    new LambdaQueryWrapper<FsUserPortraitTag>()
+                            .in(FsUserPortraitTag::getUserId, userIds)
+            );
+
+            Set<Long> tagIds = relationList.stream()
+                    .map(FsUserPortraitTag::getTagId)
+                    .collect(Collectors.toSet());
+
+            if (CollUtil.isNotEmpty(tagIds)) {
+                Map<Long, String> tagMap = fsTagService.list(
+                        new LambdaQueryWrapper<FsTag>()
+                                .in(FsTag::getId, tagIds)
+                ).stream().collect(Collectors.toMap(FsTag::getId, FsTag::getName));
+
+                Map<Long, List<FsUserPortraitTag>> userTagMap = relationList.stream()
+                        .collect(Collectors.groupingBy(FsUserPortraitTag::getUserId));
+
+                for (FsUserPortrait user : list) {
+                    List<FsUserPortraitTag> tags = userTagMap.get(user.getUserId());
+                    if (tags == null || tags.isEmpty()) {
+                        user.setInterestTags("");
+                        continue;
+                    }
+                    String result = tags.stream()
+                            .map(t -> {
+                                String tagName = tagMap.get(t.getTagId());
+                                Integer weight = t.getWeight(); // 假设字段叫 weight
+                                return tagName + "|" + weight;
+                            })
+                            .collect(Collectors.joining(","));
+                    user.setInterestTags(result);
+                }
+            }
+        }
+        TableDataInfo tableDataInfo = new TableDataInfo();
+        tableDataInfo.setCode(HttpStatus.SUCCESS);
+        tableDataInfo.setMsg("查询成功");
+        tableDataInfo.setTotal(total);
+        tableDataInfo.setRows(list);
+        return tableDataInfo;
+    }
+//
+//    /**
+//     * 导出用户列表
+//     */
+//    @PreAuthorize("@ss.hasPermi('app:user:export')")
+//    @Log(title = "用户", businessType = BusinessType.EXPORT)
+//    @GetMapping("/export")
+//    public AjaxResult export(AppUserDTO userDTO) {
+//        //原本有两种手机号加密方式,为了兼容,故两种都需要计算
+//        if (ObjectUtil.isNotEmpty(userDTO.getPhone())) {
+//            userDTO.setEncryptPhone(encryptPhone(userDTO.getPhone()));
+//            userDTO.setEncryptPhone2(encryptPhoneOldKey(userDTO.getPhone()));
+//        }
+//        List<AppUserVO> list = userService.tableList(userDTO);
+//        if (list.size() > 10000) {
+//            return AjaxResult.error("导出数据不可超过1w条");
+//        }
+//        ExcelUtil<AppUserVO> util = new ExcelUtil<>(AppUserVO.class);
+//        return util.exportExcel(list, "用户数据");
+//    }
+
+    /**
+     * 批量增加内容的标签
+     */
+    @PostMapping("/bind/tag")
+    public AjaxResult bindTag(@RequestBody FsContentTagDTO fsUserTagDTO) {
+        contentTagService.bindTag(fsUserTagDTO);
+        return AjaxResult.success();
+    }
+
+}

+ 93 - 0
fs-admin/src/main/java/com/fs/app/controller/AppWelcomeController.java

@@ -0,0 +1,93 @@
+package com.fs.app.controller;
+
+import com.fs.app.welcome.dto.AppWelcomeDTO;
+import com.fs.app.welcome.service.IAppWelcomeService;
+import com.fs.app.welcome.vo.AppWelcomeVO;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * app-欢迎语维护Controller
+ *
+ * @author fs
+ * @date 2026-02-27
+ */
+@RestController
+@RequestMapping("/app/welcome")
+public class AppWelcomeController extends BaseController {
+
+    @Autowired
+    private IAppWelcomeService appWelcomeService;
+
+    /**
+     * 查询app-欢迎语维护列表
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:welcome:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AppWelcomeDTO appWelcome) {
+        startPage();
+        List<AppWelcomeVO> list = appWelcomeService.selectAppWelcomeList(appWelcome);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出app-欢迎语维护列表
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:welcome:export')")
+    @Log(title = "app欢迎语", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppWelcomeDTO appWelcome) {
+        List<AppWelcomeVO> list = appWelcomeService.selectAppWelcomeList(appWelcome);
+        ExcelUtil<AppWelcomeVO> util = new ExcelUtil<>(AppWelcomeVO.class);
+        return util.exportExcel(list, "app-欢迎语维护数据");
+    }
+
+    /**
+     * 获取app-欢迎语维护详细信息
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:welcome:list')")
+    @GetMapping(value = "/getById/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(appWelcomeService.selectAppWelcomeById(id));
+    }
+
+    /**
+     * 保存 新增/编辑 欢迎语
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:welcome:add,app:welcome:edit')")
+    @Log(title = "app欢迎语", businessType = BusinessType.UPDATE)
+    @PostMapping(value = "/save")
+    public AjaxResult save(@RequestBody AppWelcomeDTO appWelcome) {
+        return AjaxResult.success(appWelcomeService.saveWelcome(appWelcome));
+    }
+
+
+    /**
+     * 删除app-欢迎语维护
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:welcome:delete')")
+    @Log(title = "app欢迎语", businessType = BusinessType.DELETE)
+    @DeleteMapping("/deleteByIds/{ids}")
+    public AjaxResult remove(@PathVariable List<Long> ids) {
+        return toAjax(appWelcomeService.deleteAppWelcomeByIds(ids));
+    }
+
+    /**
+     * 删除app-设置欢迎语状态
+     */
+    @PreAuthorize("@ss.hasAnyPermi('app:welcome:edit')")
+    @Log(title = "app欢迎语", businessType = BusinessType.UPDATE)
+    @PostMapping("/setSendStatus")
+    public AjaxResult setSendStatus(@RequestBody AppWelcomeDTO appWelcomeDTO) {
+        return toAjax(appWelcomeService.setSendStatus(appWelcomeDTO));
+    }
+}

+ 186 - 0
fs-admin/src/main/java/com/fs/app/controller/CommonV2Controller.java

@@ -0,0 +1,186 @@
+package com.fs.app.controller;
+
+import com.fs.common.config.FSConfig;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.file.FileUploadUtils;
+import com.fs.common.utils.file.FileUtils;
+import com.fs.framework.config.ServerConfig;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
+import com.fs.utils.AudioUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.imageio.ImageIO;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+
+/**
+ * 通用请求处理
+ *
+ */
+@RestController
+@RequiredArgsConstructor
+@Slf4j
+@RequestMapping("/app/common")
+public class CommonV2Controller {
+
+    private final ServerConfig serverConfig;
+
+
+    /**
+     * 通用下载请求
+     *
+     * @param fileName 文件名称
+     * @param delete   是否删除
+     */
+    @GetMapping("/download")
+    public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request) {
+        try {
+            if (!FileUtils.checkAllowDownload(fileName)) {
+                throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
+            }
+            String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
+            String filePath = FSConfig.getDownloadPath() + fileName;
+
+            response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+            FileUtils.setAttachmentResponseHeader(response, realFileName);
+            FileUtils.writeBytes(filePath, response.getOutputStream());
+            if (delete) {
+                FileUtils.deleteFile(filePath);
+            }
+        } catch (Exception e) {
+            log.error("下载文件失败", e);
+        }
+    }
+
+    /**
+     * 通用上传请求
+     */
+    @PostMapping("/upload")
+    public AjaxResult uploadFile(MultipartFile file) throws Exception {
+        try {
+            // 上传文件路径
+            String filePath = FSConfig.getUploadPath();
+            // 上传并返回新文件名称
+            String fileName = FileUploadUtils.upload(filePath, file);
+            String url = serverConfig.getUrl() + fileName;
+            AjaxResult ajax = AjaxResult.success();
+            ajax.put("fileName", fileName);
+            ajax.put("url", url);
+            return ajax;
+        } catch (Exception e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 本地资源通用下载
+     */
+    @GetMapping("/download/resource")
+    public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response)
+            throws Exception {
+        try {
+            if (!FileUtils.checkAllowDownload(resource)) {
+                throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", resource));
+            }
+            // 本地资源路径
+            String localPath = FSConfig.getProfile();
+            // 数据库资源地址
+            String downloadPath = localPath + StringUtils.substringAfter(resource, Constants.RESOURCE_PREFIX);
+            // 下载名称
+            String downloadName = StringUtils.substringAfterLast(downloadPath, "/");
+            response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+            FileUtils.setAttachmentResponseHeader(response, downloadName);
+            FileUtils.writeBytes(downloadPath, response.getOutputStream());
+        } catch (Exception e) {
+            log.error("下载文件失败", e);
+        }
+    }
+
+    @PostMapping("/uploadOSS")
+    public R uploadOSS(@RequestParam("file") MultipartFile file) throws Exception {
+        if (file.isEmpty()) {
+            throw new OssException("上传文件不能为空");
+        }
+        // 上传文件
+        String fileName = file.getOriginalFilename();
+        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
+        return R.ok().put("url", url);
+    }
+
+    @PostMapping("/uploadOSS2")
+    public R uploadOSS2(@RequestParam("file") MultipartFile file) throws Exception {
+        if (file.isEmpty()) {
+            throw new OssException("上传文件不能为空");
+        }
+        // 上传文件
+        String fileName = file.getOriginalFilename();
+        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String prefix = fileName.substring(0, fileName.lastIndexOf("."));
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.upload(file.getBytes(), prefix + System.currentTimeMillis() + suffix);
+        return R.ok().put("url", url);
+    }
+
+    @PostMapping("/uploadOSSByHOOKImage")
+    public R uploadOSSByHOOK(@RequestParam("file") MultipartFile file) throws Exception {
+        if (file.isEmpty()) {
+            throw new OssException("上传文件不能为空");
+        }
+        InputStream inputStream = file.getInputStream();
+        BufferedImage bufferedImage = ImageIO.read(inputStream);
+        if (bufferedImage == null) {
+            throw new OssException("无法解析图片,请检查文件内容是否正确");
+        }
+
+        // 将 BufferedImage 转换为 PNG 格式字节数组
+        ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream();
+        ImageIO.write(bufferedImage, "png", pngOutputStream);
+
+        // 获取 PNG 格式的字节数组
+        byte[] pngBytes = pngOutputStream.toByteArray();
+
+        // 上传文件
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(pngBytes, ".png");
+
+        return R.ok().put("url", url);
+    }
+
+
+    //MP3上传且转化为Sile,并且返回MP3的地址和sile的地址
+    @PostMapping("/uploadOSSByHOOKVoice")
+    public R uploadOSSByQw(@RequestParam("file") MultipartFile file) throws Exception {
+        if (file.isEmpty()) {
+            throw new OssException("上传文件不能为空");
+        }
+
+        // 上传文件
+        String fileName = file.getOriginalFilename();
+        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        CloudStorageService storage = OSSFactory.build();
+        String mp3Url = storage.uploadSuffix(file.getBytes(), suffix);
+        String silkUrl = "";
+        try {
+            silkUrl = AudioUtils.transferAudioSilkFromUrl(mp3Url, true);
+        } catch (Exception e) {
+            throw new Exception("音频转换失败:" + e);
+        }
+
+        return R.ok().put("mp3Url", mp3Url).put("silkUrl", silkUrl);
+    }
+
+}

+ 36 - 0
fs-admin/src/main/java/com/fs/app/controller/FsUserInfoController.java

@@ -0,0 +1,36 @@
+package com.fs.app.controller;
+
+import com.fs.app.user.dto.FsUserInfoDTO;
+import com.fs.app.user.service.IFsUserInfoService;
+import com.fs.common.core.domain.AjaxResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/app/fsUserInfo")
+public class FsUserInfoController {
+
+    @Autowired
+    private IFsUserInfoService fsUserInfoService;
+
+    @GetMapping("/getByUserId/{userId}")
+    public AjaxResult getByUserId(@PathVariable Long userId) {
+        return AjaxResult.success(fsUserInfoService.selectFsUserInfoByUserId(userId));
+    }
+
+    @PostMapping("/saveUserInfo")
+    public AjaxResult saveUserInfo(@RequestBody FsUserInfoDTO reqParam) {
+        return AjaxResult.success(fsUserInfoService.saveUserInfo(reqParam));
+    }
+
+    /**
+     * 获取用户兴趣标签
+     * @param userId
+     * @return
+     */
+    @GetMapping("/getUserTags/{userId}")
+    public AjaxResult getUserTags(@PathVariable Long userId) {
+        return AjaxResult.success(fsUserInfoService.getUserTags(userId));
+    }
+
+}

+ 34 - 0
fs-admin/src/main/java/com/fs/app/controller/VipTagController.java

@@ -0,0 +1,34 @@
+package com.fs.app.controller;
+
+import com.fs.app.tag.param.FsTagParam;
+import com.fs.app.tag.service.IFsTagService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 会员标签组
+ */
+@RestController
+@RequestMapping("/vip/tag")
+@RequiredArgsConstructor
+public class VipTagController extends BaseController {
+
+    private final IFsTagService fsTagService;
+
+    /**
+     * 列表
+     * @param param
+     * @return
+     */
+    @GetMapping("/list")
+//    @PreAuthorize("@ss.hasPermi('vip:tagGroup:list')")
+    public TableDataInfo list(FsTagParam param) {
+        startPage();
+        return getDataTable(fsTagService.findList(param));
+    }
+
+}

+ 98 - 0
fs-admin/src/main/java/com/fs/app/controller/VipTagGroupController.java

@@ -0,0 +1,98 @@
+package com.fs.app.controller;
+
+import com.fs.app.tag.dto.FsTagGroupDTO;
+import com.fs.app.tag.param.FsTagGroupParam;
+import com.fs.app.tag.service.IFsTagGroupService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 会员标签组
+ */
+@RestController
+@RequestMapping("/vip/tagGroup")
+@RequiredArgsConstructor
+public class VipTagGroupController extends BaseController {
+
+    private final IFsTagGroupService fsTagGroupService;
+
+    /**
+     * 列表
+     *
+     * @param param
+     * @return
+     */
+    @GetMapping("/list")
+    @PreAuthorize("@ss.hasPermi('vip:tagGroup:list')")
+    public TableDataInfo list(FsTagGroupParam param) {
+        startPage();
+        return getDataTable(fsTagGroupService.findList(param));
+    }
+
+    /**
+     * 用户绑定标签,标签(组)列表
+     *
+     * @param param
+     * @return
+     */
+    @GetMapping("/listForUserBindTag")
+    @PreAuthorize("@ss.hasPermi('vip:tagGroup:list')")
+    public TableDataInfo listForUserBindTag(FsTagGroupParam param) {
+        return fsTagGroupService.listForUserBindTag(param);
+    }
+
+
+    /**
+     * 新增
+     *
+     * @param dto
+     * @return
+     */
+    @PostMapping("/add")
+    @PreAuthorize("@ss.hasPermi('vip:tagGroup:add')")
+    public AjaxResult add(@RequestBody FsTagGroupDTO dto) {
+        return toAjax(fsTagGroupService.addGroup(dto));
+    }
+
+    /**
+     * 编辑回显
+     *
+     * @param id
+     * @return
+     */
+    @GetMapping("/getById/{id}")
+    @PreAuthorize("@ss.hasPermi('vip:tagGroup:edit')")
+    public AjaxResult getById(@PathVariable Long id) {
+        return AjaxResult.success(fsTagGroupService.getById(id));
+    }
+
+    /**
+     * 根据 id 列表获取所有信息
+     *
+     * @param ids
+     * @return
+     */
+    @GetMapping("/getTagByIds/{ids}")
+    @PreAuthorize("@ss.hasPermi('vip:tagGroup:edit')")
+    public AjaxResult getTagByIds(@PathVariable List<Long> ids) {
+        return AjaxResult.success(fsTagGroupService.getTagByIds(ids));
+    }
+
+    /**
+     * 编辑保存
+     *
+     * @param dto
+     * @return
+     */
+    @PostMapping("/edit")
+    @PreAuthorize("@ss.hasPermi('vip:tagGroup:edit')")
+    public AjaxResult edit(@RequestBody FsTagGroupDTO dto) {
+        return toAjax(fsTagGroupService.editGroup(dto));
+    }
+}

+ 79 - 0
fs-admin/src/main/java/com/fs/app/controller/VipUserCustomerController.java

@@ -0,0 +1,79 @@
+package com.fs.app.controller;
+
+import com.fs.app.tag.dto.FsUserCustomerDTO;
+import com.fs.app.tag.service.IFsUserCustomerService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/vip/userCusRole")
+@RequiredArgsConstructor
+public class VipUserCustomerController {
+
+    private final IFsUserCustomerService fsUserCustomerService;
+
+
+    /**
+     * 用户绑定客服-老版本,后续会弃用
+     *
+     * @param fsUserCustomerDTO
+     * @return
+     */
+    @PostMapping("/bind")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:bindCustomer,app:user:batchBindCustomer')")
+    @Log(title = "APP会员绑定客服", businessType = BusinessType.OTHER)
+    public AjaxResult bind(@RequestBody FsUserCustomerDTO fsUserCustomerDTO) {
+        fsUserCustomerService.bind(fsUserCustomerDTO);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 用户解绑客服-老版本,后续会弃用
+     *
+     * @param fsUserCustomerDTO
+     * @return
+     */
+    @PostMapping("/unbind")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:unbindCustomer,app:user:batchUnbindCustomer')")
+    @Log(title = "APP会员解绑客服", businessType = BusinessType.OTHER)
+    public AjaxResult unbind(@RequestBody FsUserCustomerDTO fsUserCustomerDTO) {
+        fsUserCustomerService.unbind(fsUserCustomerDTO);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 用户绑定客服-新版本,后续需调用这个版本
+     *
+     * @param fsUserCustomerDTO
+     * @return
+     */
+    @PostMapping("/bind/v2")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:bindCustomer,app:user:batchBindCustomer')")
+    @Log(title = "APP会员绑定客服", businessType = BusinessType.OTHER)
+    public AjaxResult bindV2(@RequestBody FsUserCustomerDTO fsUserCustomerDTO) {
+        fsUserCustomerService.bindV2(fsUserCustomerDTO);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 用户解绑客服-新版本,后续需调用这个版本
+     *
+     * @param fsUserCustomerDTO
+     * @return
+     */
+    @PostMapping("/unbind/v2")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:unbindCustomer,app:user:batchUnbindCustomer')")
+    @Log(title = "APP会员解绑客服", businessType = BusinessType.OTHER)
+    public AjaxResult unbindV2(@RequestBody FsUserCustomerDTO fsUserCustomerDTO) {
+        fsUserCustomerService.unbindV2(fsUserCustomerDTO);
+        return AjaxResult.success();
+    }
+
+}

+ 78 - 0
fs-admin/src/main/java/com/fs/app/controller/VipUserTagController.java

@@ -0,0 +1,78 @@
+package com.fs.app.controller;
+
+import com.fs.app.tag.dto.FsUserTagDTO;
+import com.fs.app.tag.service.IFsUserTagService;
+import com.fs.app.user.service.IFsUserPortraitTagService;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/vip/userTag")
+@RequiredArgsConstructor
+public class VipUserTagController extends BaseController {
+
+    private final IFsUserTagService fsUserTagService;
+    private final IFsUserPortraitTagService fsUserPortraitTagService;
+    /**
+     * 用户绑定标签
+     *
+     * @param fsUserTagDTO 用户,标签绑定载体
+     */
+    @PostMapping("/bind")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:bindTag,app:user:batchBindTag')")
+    @Log(title = "APP会员绑定标签", businessType = BusinessType.OTHER)
+    public AjaxResult bind(@RequestBody FsUserTagDTO fsUserTagDTO) {
+        fsUserTagService.bindUserTag(fsUserTagDTO);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 解绑用户标签
+     *
+     * @param fsUserTagDTO 用户,标签绑定载体
+     */
+    @PostMapping("/unbind")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:unbindTag,app:user:batchUnbindTag')")
+    @Log(title = "APP会员解绑标签", businessType = BusinessType.OTHER)
+    public AjaxResult unbind(@RequestBody FsUserTagDTO fsUserTagDTO) {
+        fsUserTagService.unbindUserTag(fsUserTagDTO);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 用户绑定标签-新版本,需后续切换到此版本
+     *
+     * @param fsUserTagDTO 用户,标签绑定载体
+     */
+    @PostMapping("/bind/v2")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:bindTag,app:user:batchBindTag')")
+    @Log(title = "APP会员绑定标签", businessType = BusinessType.OTHER)
+    public AjaxResult bindV2(@RequestBody FsUserTagDTO fsUserTagDTO) {
+        fsUserPortraitTagService.bindTag(fsUserTagDTO.getUserId(),fsUserTagDTO.getTagId());
+        fsUserTagService.bindUserTagV2(fsUserTagDTO);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 解绑用户标签-新版本,需后续切换到此版本
+     *
+     * @param fsUserTagDTO 用户,标签绑定载体
+     */
+    @PostMapping("/unbind/v2")
+    @PreAuthorize("@ss.hasAnyPermi('app:user:unbindTag,app:user:batchUnbindTag')")
+    @Log(title = "APP会员解绑标签", businessType = BusinessType.OTHER)
+    public AjaxResult unbindV2(@RequestBody FsUserTagDTO fsUserTagDTO) {
+        fsUserPortraitTagService.unBindTag(fsUserTagDTO.getUserId(),fsUserTagDTO.getTagId());
+        fsUserTagService.unbindUserTagV2(fsUserTagDTO);
+        return AjaxResult.success();
+    }
+
+}

+ 116 - 0
fs-admin/src/main/java/com/fs/app/job/UserActivityTask.java

@@ -0,0 +1,116 @@
+package com.fs.app.job;
+
+import com.fs.app.user.domain.FsUserPortrait;
+import com.fs.app.user.service.IFsUserPortraitService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@Slf4j
+public class UserActivityTask {
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    @Autowired
+    private IFsUserPortraitService portraitService;
+
+    private static final String LASTS_EEN_KEY = "userPortrait:last_seen";
+    private static final String TOTAL_7D_KEY = "userPortrait:total:7d";
+    /**
+     * 入口 1:每天凌晨 3 点定时执行
+     */
+    @Scheduled(cron = "0 0 3 * * ?")
+    public void cronTask() {
+        // 昨天
+        String d1 = LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+        // 8天前
+        String d8 = LocalDate.now().minusDays(8).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+
+        log.info("开始滑动窗口计算: 增加 {}, 减去 {}", d1, d8);
+
+        String dailyD1Key = "userPortrait:daily:duration:" + d1;
+        String dailyD8Key = "userPortrait:daily:duration:" + d8;
+
+
+
+        Cursor<Map.Entry<Object, Object>> cursor = stringRedisTemplate.opsForHash().scan(LASTS_EEN_KEY,
+                ScanOptions.scanOptions().count(1000).build());
+
+        List<FsUserPortrait> batchList = new ArrayList<>();
+
+        while (cursor.hasNext()) {
+            Map.Entry<Object, Object> entry = cursor.next();
+            String userId = entry.getKey().toString();
+            long lastSeenTs = Long.parseLong(entry.getValue().toString());
+
+            // 1. 获取昨天的增量
+            Object d1Incr = stringRedisTemplate.opsForHash().get(dailyD1Key, userId);
+            long plusSeconds = d1Incr != null ? Long.parseLong(d1Incr.toString()) : 0L;
+
+            // 2. 获取8天前的过期量
+            Object d8Decr = stringRedisTemplate.opsForHash().get(dailyD8Key, userId);
+            long minusSeconds = d8Decr != null ? Long.parseLong(d8Decr.toString()) : 0L;
+
+            // 3. 更新 Redis 中的 7 日滑动窗口总值
+            long current7dTotal = stringRedisTemplate.opsForHash().increment(TOTAL_7D_KEY, userId, plusSeconds - minusSeconds);
+
+            // 防止出现负数
+            if (current7dTotal < 0) {
+                stringRedisTemplate.opsForHash().put(TOTAL_7D_KEY, userId, "0");
+                current7dTotal = 0;
+            }
+
+            // 4. 计算活跃度状态
+            FsUserPortrait activity = calculateStatus(Long.valueOf(userId), lastSeenTs, current7dTotal);
+            batchList.add(activity);
+
+            if (batchList.size() >= 500) {
+                portraitService.updateActivityLevelBatch(batchList);
+                batchList.clear();
+            }
+        }
+        if (!batchList.isEmpty()) {
+            portraitService.updateActivityLevelBatch(batchList);
+            batchList.clear();
+        }
+
+        // 5. 清理 8 天前的数据,释放内存
+        stringRedisTemplate.delete(dailyD8Key);
+    }
+
+    private FsUserPortrait calculateStatus(Long userId, long lastSeenTs, long total7dSeconds) {
+        long now = System.currentTimeMillis() / 1000;
+        long daysInactive = (now - lastSeenTs) / (24 * 3600);
+        double hours7d = total7dSeconds / 3600.0;
+
+        String activityLevel;
+        if (daysInactive >= 30) {
+            activityLevel = "流失";
+            // 删除流失用户数据
+            stringRedisTemplate.opsForHash().delete(LASTS_EEN_KEY, userId);
+            stringRedisTemplate.opsForHash().delete(TOTAL_7D_KEY, userId);
+        } else if (daysInactive >= 7) {
+            activityLevel = "沉默";
+        } else {
+            if (hours7d < 3) activityLevel = "低活跃";
+            else if (hours7d < 7) activityLevel = "活跃";
+            else activityLevel = "高活跃";
+        }
+        FsUserPortrait fsUserPortrait = new FsUserPortrait();
+        fsUserPortrait.setUserId(userId);
+        fsUserPortrait.setActivityLevel(activityLevel);
+        return fsUserPortrait;
+    }
+
+}

+ 97 - 0
fs-admin/src/main/java/com/fs/app/job/UserConsumptionAmountTask.java

@@ -0,0 +1,97 @@
+package com.fs.app.job;
+
+import cn.hutool.core.collection.CollUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.app.user.domain.FsUserPortrait;
+import com.fs.app.user.service.IFsUserPortraitService;
+import com.fs.his.service.IFsStoreOrderService;
+import com.fs.his.vo.UserAmountVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Component
+@Slf4j
+public class UserConsumptionAmountTask {
+    @Autowired
+    private IFsUserPortraitService portraitService;
+    @Autowired
+    private IFsStoreOrderService storeOrderService;
+
+    @Scheduled(cron = "0 0 2 * * ?") // 确保只执行一次
+    public void cronTask() {
+        LocalDateTime start = LocalDateTime.of(LocalDate.now().minusDays(1), LocalTime.MIN);
+        LocalDateTime end = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
+        long startPlay = System.currentTimeMillis();
+        log.error("开始增量统计消费数据: {} - {}", start, end);
+
+        // 1. 订单量大了之后这里要做分页
+        List<UserAmountVO> dailySums = storeOrderService.getSumAmount(start, end);
+        if (CollUtil.isEmpty(dailySums)) {
+            log.info("昨日无新增消费记录");
+            return;
+        }
+
+        // 2. 分批处理(例如每 1000 个用户一批)
+        List<List<UserAmountVO>> batches = CollUtil.split(dailySums, 1000);
+        for (List<UserAmountVO> batch : batches) {
+            processBatch(batch);
+        }
+        long endPlay = System.currentTimeMillis();
+        log.error("增量统计消费数据完成:{}", endPlay - startPlay);
+    }
+
+    public void processBatch(List<UserAmountVO> batch) {
+        List<Long> userIds = batch.stream().map(UserAmountVO::getUserId).collect(Collectors.toList());
+        Map<Long, FsUserPortrait> portraitMap = portraitService.list(
+                new LambdaQueryWrapper<FsUserPortrait>().in(FsUserPortrait::getUserId, userIds)
+        ).stream().collect(Collectors.toMap(FsUserPortrait::getUserId, p -> p));
+
+        List<FsUserPortrait> updateList = new ArrayList<>();
+
+        for (UserAmountVO dailySum : batch) {
+            FsUserPortrait portrait = portraitMap.get(dailySum.getUserId());
+            if (portrait == null) continue;
+            updatePortraitData(portrait, dailySum);
+            updateList.add(portrait);
+        }
+
+        // 3. 批量更新:一次 IO 更新 1000 行
+        portraitService.updateBatchById(updateList);
+    }
+
+    private void updatePortraitData(FsUserPortrait portrait, UserAmountVO dailySum) {
+        // 累加数据
+        portrait.setConsumptionAmount(portrait.getConsumptionAmount().add(dailySum.getDailySum()));
+        portrait.setSmallOrderCount(portrait.getSmallOrderCount() + dailySum.getSmallCount());
+
+        // 重新计算等级
+        portrait.setConsumptionLevel(calculateLevel(portrait.getConsumptionAmount(), portrait.getSmallOrderCount()));
+    }
+
+    private String calculateLevel(BigDecimal amount, int smallCount) {
+        if (amount.compareTo(new BigDecimal("3000")) > 0) return "S+";
+        if (amount.compareTo(new BigDecimal("500")) > 0) return "S";
+        if (amount.compareTo(new BigDecimal("150")) > 0) {
+            return smallCount > 20 ? "E" : "A";
+        }
+        if (smallCount > 20) return "E";
+        if (smallCount > 8) return "D";
+        if (smallCount > 3) return "C";
+        return "B"; // 补全默认等级
+    }
+
+}
+
+
+

+ 24 - 0
fs-admin/src/main/java/com/fs/app/job/UserTagTask.java

@@ -0,0 +1,24 @@
+package com.fs.app.job;
+
+import com.fs.app.user.service.IFsUserPortraitTagService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+@Slf4j
+public class UserTagTask {
+
+    @Autowired
+    private IFsUserPortraitTagService portraitTagService;
+    // 每两天5点执行一次
+    @Scheduled(cron = "0 0 5 1/2 * ?")
+    public void syncDataToDb() {
+        // 权重衰减
+        portraitTagService.weightAttenuation();
+    }
+
+
+
+}

+ 201 - 0
fs-admin/src/main/java/com/fs/app/job/VideoTrackTask.java

@@ -0,0 +1,201 @@
+package com.fs.app.job;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.app.user.domain.FsUserPortraitContentTag;
+import com.fs.app.user.domain.FsUserPortraitTag;
+import com.fs.app.user.service.IFsUserPortraitContentTagService;
+import com.fs.app.user.service.IFsUserPortraitTagService;
+import com.fs.common.constant.VideoRedisKeyConst;
+import com.fs.course.dto.VideoUpdateDTO;
+import com.fs.course.service.IFsUserVideoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Component
+@Slf4j
+public class VideoTrackTask {
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+    @Autowired
+    @Lazy
+    private IFsUserVideoService userVideoService;
+    @Autowired
+    private IFsUserPortraitContentTagService contentTagService;
+    @Autowired
+    private IFsUserPortraitTagService portraitTagService;
+
+
+    @Scheduled(cron = "0 0/2 * * * ?")
+    public void syncDataToDb() {
+        syncVideoMetrics();
+        syncUserTags();
+    }
+
+    private void syncVideoMetrics() {
+        // 每次最多从待处理池中弹出 1000 个视频ID进行同步 (控制单次处理量)
+
+        List<String> pendingVideoIds = stringRedisTemplate.opsForSet().pop(VideoRedisKeyConst.PENDING_SYNC_VIDEOS, 1000);
+
+        if (pendingVideoIds == null || pendingVideoIds.isEmpty()) {
+            return;
+        }
+
+        // 用于收集批量更新参数的 List
+        List<VideoUpdateDTO> updateBatchList = new ArrayList<>();
+
+        for (String
+                videoIdStr : pendingVideoIds) {
+            String key = VideoRedisKeyConst.VIDEO_METRICS_PREFIX + videoIdStr + VideoRedisKeyConst.KEY_END;
+            String syncKey = key + VideoRedisKeyConst.SYNC_SUFFIX;
+
+            try {
+                if (stringRedisTemplate.hasKey(key) && stringRedisTemplate.renameIfAbsent(key, syncKey)) {
+                    Long videoId = Long.parseLong(videoIdStr);
+                    Map<Object, Object> metrics = stringRedisTemplate.opsForHash().entries(syncKey);
+
+                    long play = parseLong(metrics.get("PLAY"));
+                    long play3s = parseLong(metrics.get("PLAY_3S"));
+                    long complete = parseLong(metrics.get("COMPLETE"));
+
+                    updateBatchList.add(new VideoUpdateDTO(videoId, play, play3s, complete));
+                    stringRedisTemplate.delete(syncKey);
+                } else {
+                    // 重命名失败,说明该Key正在被处理,为了防丢,把 videoId 塞回池子里下次处理
+                    stringRedisTemplate.opsForSet().add(VideoRedisKeyConst.PENDING_SYNC_VIDEOS, videoIdStr );
+                }
+            } catch (Exception e) {
+                stringRedisTemplate.opsForSet().add(VideoRedisKeyConst.PENDING_SYNC_VIDEOS, videoIdStr);
+            }
+        }
+
+
+        if (!updateBatchList.isEmpty()) {
+            userVideoService.batchIncrementMetrics(updateBatchList);
+        }
+    }
+
+    private void syncUserTags() {
+        // 1. 从待处理池中一次性弹出最多 1000 个需要更新的用户 ID
+        List<String> pendingUserIds = stringRedisTemplate.opsForSet().pop(VideoRedisKeyConst.PENDING_SYNC_USERS, 1000);
+
+        if (pendingUserIds == null || pendingUserIds.isEmpty()) {
+            return;
+        }
+
+        // userId -> { videoId -> weight }
+        Map<Long, Map<Long, Integer>> pendingSyncData = new HashMap<>();
+        // videoIds
+        Set<Long> allVideoIdsToQuery = new HashSet<>();
+        List<String> syncKeysToDelete = new ArrayList<>();
+
+        // 2. 遍历处理这批 userId
+        for (String userIdStr : pendingUserIds) {
+            String key = VideoRedisKeyConst.USER_VIDEO_WEIGHT_PREFIX + userIdStr + VideoRedisKeyConst.KEY_END;
+            String syncKey = key + VideoRedisKeyConst.SYNC_SUFFIX;
+
+            try {
+                // 原子重命名
+                if (stringRedisTemplate.hasKey(key) && stringRedisTemplate.renameIfAbsent(key, syncKey)) {
+                    Long userId = Long.parseLong(userIdStr);
+                    Map<Object, Object> videoWeights = stringRedisTemplate.opsForHash().entries(syncKey);
+
+                    Map<Long, Integer> userVideoMap = new HashMap<>();
+                    for (Map.Entry<Object, Object> entry : videoWeights.entrySet()) {
+                        Long videoId = Long.parseLong(String.valueOf(entry.getKey()));
+                        Integer weight = Integer.parseInt(String.valueOf(entry.getValue()));
+
+                        userVideoMap.put(videoId, weight);
+                        allVideoIdsToQuery.add(videoId);
+                    }
+
+                    pendingSyncData.put(userId, userVideoMap);
+                    syncKeysToDelete.add(syncKey);
+                } else {
+                    // 重命名失败说明该 key 正处于其它同步流程中,防丢:塞回待处理池下次再试
+                    stringRedisTemplate.opsForSet().add(VideoRedisKeyConst.PENDING_SYNC_USERS, userIdStr);
+                }
+            } catch (Exception e) {
+                log.error("转移用户视频权重 Redis Key 异常, userId: {}", userIdStr, e);
+                // 发生异常防丢:塞回待处理池下次再试
+                stringRedisTemplate.opsForSet().add(VideoRedisKeyConst.PENDING_SYNC_USERS, userIdStr);
+            }
+        }
+
+        // 如果没有任何有效的视频ID需要查询,直接清理 keys 并返回
+        if (allVideoIdsToQuery.isEmpty()) {
+            if (!syncKeysToDelete.isEmpty()) {
+                stringRedisTemplate.delete(syncKeysToDelete);
+            }
+            return;
+        }
+
+        // 3. 批量查询 DB 获取这些视频的标签 (1次网络IO)
+        List<FsUserPortraitContentTag> videoTags = contentTagService.list(new LambdaQueryWrapper<FsUserPortraitContentTag>()
+                .in(FsUserPortraitContentTag::getContentId, allVideoIdsToQuery));
+
+        // 按照 contentId 分组,收集每个 contentId 对应的 tagId 集合
+        Map<Long, List<Long>> videoTagMap = videoTags.stream()
+                .collect(Collectors.groupingBy(
+                        FsUserPortraitContentTag::getContentId,
+                        Collectors.mapping(FsUserPortraitContentTag::getTagId, Collectors.toList())
+                ));
+
+
+        // 用于收集将要批量写入数据库的对象集合
+        List<FsUserPortraitTag> tagBatchList = new ArrayList<>();
+
+        // 4. 在内存中将 videoId 的权重转化为 tag 的权重
+        for (Map.Entry<Long, Map<Long, Integer>> userEntry : pendingSyncData.entrySet()) {
+            Long userId = userEntry.getKey();
+            Map<Long, Integer> videoWeightsMap = userEntry.getValue();
+
+            // 聚合同一个用户在这批数据里的各个标签总权重: tag -> totalWeight
+            Map<Long, Integer> userTagAggregator = new HashMap<>();
+
+            for (Map.Entry<Long, Integer> vwEntry : videoWeightsMap.entrySet()) {
+                Long videoId = vwEntry.getKey();
+                Integer weight = vwEntry.getValue();
+
+                List<Long> tags = videoTagMap.get(videoId);
+                if (tags != null) {
+                    for (Long tag : tags) {
+                        userTagAggregator.put(tag, userTagAggregator.getOrDefault(tag, 0) + weight);
+                    }
+                }
+            }
+
+            // 组装成实体类放入 List
+            for (Map.Entry<Long, Integer> tagEntry : userTagAggregator.entrySet()) {
+                if (tagEntry.getValue() != 0) { // 只处理权重有净增减的
+                    FsUserPortraitTag utw = new FsUserPortraitTag();
+                    utw.setUserId(userId);
+                    utw.setTagId(tagEntry.getKey());
+                    utw.setWeight(tagEntry.getValue());
+                    tagBatchList.add(utw);
+                }
+            }
+        }
+
+        // 5. 排序防死锁并批量落库
+        if (!tagBatchList.isEmpty()) {
+            tagBatchList.sort(Comparator.comparing(FsUserPortraitTag::getUserId).thenComparing(FsUserPortraitTag::getTagId));
+            portraitTagService.batchAddOrUpdateWeight(tagBatchList);
+        }
+
+        // 落库成功,批量清理 Redis 中的临时 syncKeys
+        if (!syncKeysToDelete.isEmpty()) {
+            stringRedisTemplate.delete(syncKeysToDelete);
+        }
+    }
+
+    private long parseLong(Object obj) {
+        return obj == null ? 0L : Long.parseLong(String.valueOf(obj));
+    }
+}

+ 6 - 0
fs-admin/src/main/java/com/fs/company/controller/CompanyUserController.java

@@ -222,4 +222,10 @@ public class CompanyUserController extends BaseController
         List<Company> list = companyUserService.getCompanyList(corpId);
         return  R.ok().put("data",list);
     }
+
+    @GetMapping("/findOptions")
+    public AjaxResult findOptions(String keyword, Long selectedId, Integer pageNumber,  Integer pageSize) {
+        return AjaxResult.success(companyUserService.findCompanyUserOptions(keyword, selectedId, pageNumber, pageSize));
+    }
+
 }

+ 514 - 0
fs-admin/src/main/java/com/fs/course/controller/FsAppCourseWatchLogController.java

@@ -0,0 +1,514 @@
+package com.fs.course.controller;
+
+import com.fs.app.cusrole.service.IAppCustomerRoleService;
+import com.fs.app.watchlog.dto.AppCourseWatchLogDto;
+import com.fs.app.watchlog.param.AppCourseWatchLogListParam;
+import com.fs.app.watchlog.service.IAppCourseWatchLogService;
+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.domain.R;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.vo.OptionVO;
+import com.fs.course.service.IFsCourseWatchLogService;
+import com.fs.his.domain.FsExportTask;
+import com.fs.his.service.IFsExportTaskService;
+import com.fs.qw.param.QwWatchLogStatisticsListParam;
+import com.fs.qw.service.IQwWatchLogService;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 短链课程看课记录Controller
+ *
+ * @author fs
+ * @date 2024-10-24
+ */
+@RestController
+@RequestMapping("/course/appCourseWatchLog")
+public class FsAppCourseWatchLogController extends BaseController {
+    @Autowired
+    private IFsCourseWatchLogService fsCourseWatchLogService;
+
+    @Autowired
+    private IQwWatchLogService qwWatchLogService;
+    @Autowired
+    private IFsExportTaskService exportTaskService;
+    @Autowired
+    private IAppCourseWatchLogService appCourseWatchLogService;
+    @Autowired
+    private IAppCustomerRoleService appCustomerRoleService;
+
+
+    /**
+     * 查询app课程看课记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AppCourseWatchLogListParam param) {
+        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+
+        List<AppCourseWatchLogDto> list = appCourseWatchLogService.selectAppCourseWatchLogListVO(param);
+        return getDataTable(list);
+    }
+
+//    /**
+//     * 查询短链课程 我的部门 看课记录列表
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:deptList')")
+//    @GetMapping("/deptList")
+//    public TableDataInfo deptList(FsCourseWatchLogListParam param) {
+//
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//
+//
+//        List<Long> combinedList = new ArrayList<>();
+//        //本部门
+//        Long deptId = loginUser.getUser().getDeptId();
+//        if (deptId!=null){
+//            combinedList.add(deptId);
+//        }
+//        //本部门的下级部门
+//        List<Long> deptList = companyDeptService.selectCompanyDeptByParentId(deptId);
+//        if (!deptList.isEmpty()){
+//            combinedList.addAll(deptList);
+//        }
+//
+//        param.setCuDeptIdList(combinedList);
+//        param.setUserType(loginUser.getUser().getUserType());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//
+//        startPage();
+//        List<FsCourseWatchLogListVO> list = fsCourseWatchLogService.selectFsCourseWatchLogListVO(param);
+//
+//        TableDataInfo rspData = new TableDataInfo();
+//        rspData.setCode(HttpStatus.SUCCESS);
+//        rspData.setMsg("查询成功");
+//        rspData.setRows(list);
+//        rspData.setTotal(fsCourseWatchLogService.selectFsCourseWatchLogListVOCount(param));
+//        return rspData;
+//    }
+//    /**
+//     * 查询短链课程看课记录列表
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:myList')")
+//    @GetMapping("/myList")
+//    public TableDataInfo myList(FsCourseWatchLogListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyUserId(loginUser.getUser().getUserId());
+//        List<FsCourseWatchLogListVO> list = fsCourseWatchLogService.selectFsCourseWatchLogListVO(param);
+//
+//        TableDataInfo rspData = new TableDataInfo();
+//        rspData.setCode(HttpStatus.SUCCESS);
+//        rspData.setMsg("查询成功");
+//        rspData.setRows(list);
+//        rspData.setTotal(fsCourseWatchLogService.selectFsCourseWatchLogListVOCount(param));
+//        return rspData;
+//    }
+
+
+    /**
+     * 判断传入的日期是否是7天之前
+     * @param dateStr
+     * @return
+     */
+    public boolean isDateBefore7Days(String dateStr) {
+        // 定义日期格式
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+        // 解析输入日期
+        LocalDate inputDate = LocalDate.parse(dateStr, formatter);
+
+        // 获取当前日期(2025-05-23)
+        LocalDate currentDate = LocalDate.now();
+
+        // 计算日期差
+        long daysBetween = ChronoUnit.DAYS.between(inputDate, currentDate);
+
+        // 判断是否超过7天
+        return daysBetween >= 7;
+    }
+
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:statisticsList')")
+//    @GetMapping("/statisticsList")
+//    public TableDataInfo statisticsList(FsCourseWatchLogStatisticsListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<FsCourseWatchLogStatisticsListVO> list = fsCourseWatchLogService.selectFsCourseWatchLogStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+
+//    @GetMapping("/qwWatchLogStatisticsList")
+//    public TableDataInfo qwWatchLogStatisticsList(QwWatchLogStatisticsListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<QwWatchLogStatisticsListVO> list = qwWatchLogService.selectQwWatchLogStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+
+//    @GetMapping("/exportWatchLogStatistics")
+//    public AjaxResult exportWatchLogStatistics(QwWatchLogStatisticsListParam param) {
+//
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setExcelName("进线客户统计记录导出");
+//
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return AjaxResult.error("请筛选时间后导出");
+//        }
+//
+//        boolean res = qwWatchLogService.dateDifferenceMoreThan7Days(param.getSTime(), param.getETime());
+//
+//        if (res) {
+//            return AjaxResult.error("导出数据时间差不能超过七天");
+//        }
+//        AjaxResult returnRes = new AjaxResult();
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        try {
+//            Class<? extends IQwWatchLogService> clazz = qwWatchLogService.getClass();
+//            returnRes = exportCommon(
+//                    param,
+//                    loginUser.getUser().getUserId(),
+//                    "进线客户统计记录",
+//                    10000L,
+//                    "getQwWatchLogStatisticsCount",
+//                    "exportQwWatchLogStatistics",
+//                    clazz);
+//        } catch (Exception ex) {
+//            ex.printStackTrace();
+//            return AjaxResult.error("导出报错了");
+//        }
+//        return returnRes;
+
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setExcelName("进线客户统计记录导出");
+//        return exportQwWatchLogStatistics(param, loginUser.getUser().getUserId());
+//    }
+
+//    @GetMapping("/myQwWatchLogStatisticsList")
+//    public TableDataInfo myQwWatchLogStatisticsList(QwWatchLogStatisticsListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setCompanyUserId(loginUser.getUser().getUserId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<QwWatchLogStatisticsListVO> list = qwWatchLogService.selectQwWatchLogStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+
+    public AjaxResult exportQwWatchLogStatistics(QwWatchLogStatisticsListParam param,Long userId){
+
+        if (param.getSTime() == null || param.getETime() == null) {
+            return AjaxResult.error("请筛选时间后导出");
+        }
+
+        boolean res = qwWatchLogService.dateDifferenceMoreThan7Days(param.getSTime(), param.getETime());
+
+        if (res) {
+            return AjaxResult.error("导出数据时间差不能超过七天");
+        }
+
+        Long count = qwWatchLogService.getQwWatchLogStatisticsCount(param);
+        if (count > 10000) {
+            return AjaxResult.error("导出数据不可超过1w条");
+        }
+//        Long userId = loginUser.getUser().getUserId();
+        FsExportTask task = new FsExportTask();
+        task.setTaskType(2);
+        task.setStatus(0);
+        task.setStartTime(new Date());
+        task.setRemark("客户统计记录");
+        task.setSysType(2);
+        task.setCompanyUserId(userId);
+        exportTaskService.insertFsExportTask(task);
+        param.setTaskId(task.getTaskId());
+        qwWatchLogService.exportQwWatchLogStatistics(param, count);
+
+        return new AjaxResult(200, "后台正在导出,请等待...任务ID:" + task.getTaskId(), task.getTaskId());
+    }
+
+//    @GetMapping("/exportMyQwWatchLogStatistics")
+//    public AjaxResult exportMyQwWatchLogStatistics(QwWatchLogStatisticsListParam param) {
+//
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setCompanyUserId(loginUser.getUser().getUserId());
+//        param.setExcelName("我的客户统计记录导出");
+//        return exportQwWatchLogStatistics(param, loginUser.getUser().getUserId());
+//    }
+
+
+
+//    @GetMapping("/qwWatchLogAllStatisticsList")
+//    public TableDataInfo qwWatchLogAllStatisticsList(QwWatchLogStatisticsListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<QwWatchLogAllStatisticsListVO> list = qwWatchLogService.selectQwWatchLogAllStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+
+//    @GetMapping("/myQwWatchLogAllStatisticsList")
+//    public TableDataInfo myQwWatchLogAllStatisticsList(QwWatchLogStatisticsListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setCompanyUserId(loginUser.getUser().getUserId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<QwWatchLogAllStatisticsListVO> list = qwWatchLogService.selectQwWatchLogAllStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+
+
+    public AjaxResult exportQwWatchLogAllStatistics(QwWatchLogStatisticsListParam param, Long userId) {
+
+        if (param.getSTime() == null || param.getETime() == null) {
+            return AjaxResult.error("请筛选时间后导出");
+        }
+
+        boolean res = qwWatchLogService.dateDifferenceMoreThan7Days(param.getSTime(), param.getETime());
+
+        if (res) {
+            return AjaxResult.error("导出数据时间差不能超过七天");
+        }
+
+        Long count = qwWatchLogService.getQwWatchLogStatisticsCount(param);
+        if (count > 10000) {
+            return AjaxResult.error("导出数据不可超过1w条");
+        }
+//        Long userId = loginUser.getUser().getUserId();
+        FsExportTask task = new FsExportTask();
+        task.setTaskType(2);
+        task.setStatus(0);
+        task.setStartTime(new Date());
+        task.setRemark("我的客户统计记录");
+        task.setSysType(2);
+        task.setCompanyUserId(userId);
+        exportTaskService.insertFsExportTask(task);
+        param.setTaskId(task.getTaskId());
+        qwWatchLogService.exportQwWatchLogAllStatistics(param, count);
+
+        return new AjaxResult(200, "后台正在导出,请等待...任务ID:" + task.getTaskId(), task.getTaskId());
+    }
+
+//    @GetMapping("/exportMyQwWatchLogAllStatistics")
+//    public AjaxResult exportMyQwWatchLogAllStatistics(QwWatchLogStatisticsListParam param) {
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setCompanyUserId(loginUser.getUser().getUserId());
+//        param.setExcelName("我的数据汇总记录导出");
+//        return exportQwWatchLogAllStatistics(param, loginUser.getUser().getUserId());
+//    }
+
+//    @GetMapping("/exportQwWatchLogStatisticsAll")
+//    public AjaxResult exportQwWatchLogStatisticsAll(QwWatchLogStatisticsListParam param) {
+//
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        param.setExcelName("数据汇总记录导出");
+//        return exportQwWatchLogAllStatistics(param, loginUser.getUser().getUserId());
+//
+//    }
+
+//    @GetMapping("/watchLogStatistics")
+//    public TableDataInfo watchLogStatistics(FsCourseOverParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<FsCourseOverVO> list = fsCourseWatchLogService.selectFsCourseWatchLogOverStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+//
+//    @GetMapping("/watchLogStatisticsExport")
+//    public AjaxResult watchLogStatisticsExport(FsCourseOverParam param) {
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return AjaxResult.error("请选择时间");
+//        }
+//        List<FsCourseOverVO> list = fsCourseWatchLogService.selectFsCourseWatchLogOverStatisticsListVO(param);
+//        ExcelUtil<FsCourseOverVO> util = new ExcelUtil<FsCourseOverVO>(FsCourseOverVO.class);
+//        return util.exportExcel(list, "完课数据");
+//    }
+
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:userStatisticsList')")
+//    @GetMapping("/userStatisticsList")
+//    public TableDataInfo userStatisticsList(FsCourseUserStatisticsListParam param) {
+//        startPage();
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        param.setCompanyId(loginUser.getCompany().getCompanyId());
+//        if (param.getSTime() == null || param.getETime() == null) {
+//            return getDataTable(new ArrayList<>());
+//        }
+//        List<FsCourseUserStatisticsListVO> list = fsCourseWatchLogService.selectFsCourseUserStatisticsListVO(param);
+//        return getDataTable(list);
+//    }
+
+    /**
+     * 导出短链课程看课记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:export')")
+    @Log(title = "短链课程看课记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AppCourseWatchLogListParam param)
+    {
+        // 导出
+        List<AppCourseWatchLogDto> list = appCourseWatchLogService.selectAppCourseWatchLogListVO(param);
+        ExcelUtil<AppCourseWatchLogDto> util = new ExcelUtil<AppCourseWatchLogDto>(AppCourseWatchLogDto.class);
+        return util.exportExcel(list, "短链课程看课记录数据");
+//        return exportCourseWatchLog(param);// 放导出任务
+    }
+
+    /**
+     * 导出短链课程看客记录
+     *
+     * @param param 参数
+     * @return AjaxResult
+     */
+    private AjaxResult exportCourseWatchLog(AppCourseWatchLogListParam param) {
+        LoginUser loginUser = getLoginUser();
+        logger.info("导出看课记录");
+        if (appCourseWatchLogService.isEntityNull(param)) {
+            return AjaxResult.error("请筛选数据导出");
+        }
+
+        Long userId = loginUser.getUser().getUserId();
+        Integer exportType = exportTaskService.isExportType1(userId);
+        if (exportType > 0) {
+            return AjaxResult.error("你已经有正在导出的任务");
+        }
+
+        if(param.getSTime() == null || param.getETime() == null || (param.getSTime().getTime() > param.getETime().getTime())) {
+            return AjaxResult.error("导出任务的创建时间范围只能是一天!");
+        }
+
+        Long count = appCourseWatchLogService.selectAppCourseWatchLogListVOCount(param);
+        if (count > 10000) {
+            return AjaxResult.error("导出数据不可超过1w条");
+        }
+
+        FsExportTask task = new FsExportTask();
+        task.setTaskType(2);
+        task.setStatus(0);
+        task.setStartTime(new Date());
+        task.setRemark("app短链课程看课记录");
+        task.setSysType(1);
+        task.setCompanyUserId(userId);
+        exportTaskService.insertFsExportTask(task);
+        param.setTaskId(task.getTaskId());
+
+        appCourseWatchLogService.exportData(param, count);
+        return new AjaxResult(200, "后台正在导出,请等待...任务ID:" + task.getTaskId(), task.getTaskId());
+    }
+
+//    private AjaxResult exportCommon(Object param, Long userId, String remark, Long limitCount,  String getCountMethodName, String exportMethodName, Class<?> clazz) {
+//        AjaxResult res = new AjaxResult();
+//        try {
+//            res = fsCourseWatchLogService.exportCommon(param, userId, remark, limitCount, getCountMethodName, exportMethodName, clazz);
+//        } catch (Exception ex) {
+//            ex.printStackTrace();
+//        }
+//        return res;
+//    }
+
+//    /**
+//     * 导出短链课程看课记录列表
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:myExport')")
+//    @Log(title = "短链课程看课记录", businessType = BusinessType.EXPORT)
+//    @GetMapping("/myExport")
+//    public AjaxResult myExport(FsCourseWatchLogListParam param)
+//    {
+//        // 导出
+//        return exportCourseWatchLog(param);
+//    }
+//    /**
+//     * 获取短链课程看课记录详细信息
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:query')")
+//    @GetMapping(value = "/{logId}")
+//    public AjaxResult getInfo(@PathVariable("logId") Long logId) {
+//        return AjaxResult.success(fsCourseWatchLogService.selectFsCourseWatchLogByLogId(logId));
+//    }
+//
+//    /**
+//     * 新增短链课程看课记录
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:add')")
+//    @Log(title = "短链课程看课记录", businessType = BusinessType.INSERT)
+//    @PostMapping
+//    public AjaxResult add(@RequestBody FsCourseWatchLog fsCourseWatchLog) {
+//        return toAjax(fsCourseWatchLogService.insertFsCourseWatchLog(fsCourseWatchLog));
+//    }
+//
+//    /**
+//     * 修改短链课程看课记录
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:edit')")
+//    @Log(title = "短链课程看课记录", businessType = BusinessType.UPDATE)
+//    @PutMapping
+//    public AjaxResult edit(@RequestBody FsCourseWatchLog fsCourseWatchLog) {
+//        return toAjax(fsCourseWatchLogService.updateFsCourseWatchLog(fsCourseWatchLog));
+//    }
+//
+//    /**
+//     * 删除短链课程看课记录
+//     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:remove')")
+//    @Log(title = "短链课程看课记录", businessType = BusinessType.DELETE)
+//    @DeleteMapping("/{logIds}")
+//    public AjaxResult remove(@PathVariable Long[] logIds) {
+//        return toAjax(fsCourseWatchLogService.deleteFsCourseWatchLogByLogIds(logIds));
+//    }
+
+    /**
+     * 昵称模糊查询客服分页
+     */
+    @GetMapping("/getCustomerListLikeName")
+    public R getCustomerListLikeName(@RequestParam(required = false) String name,
+                                     @RequestParam(required = false, defaultValue = "1") Integer pageNum,
+                                     @RequestParam(required = false, defaultValue = "10") Integer pageSize) {
+        PageHelper.startPage(pageNum,pageSize);
+        List<OptionVO> appCustomerRoles = appCustomerRoleService.getCustomerListLikeName(name);
+        return R.ok().put("data",new PageInfo<>(appCustomerRoles));
+    }
+
+
+}

+ 5 - 0
fs-admin/src/main/java/com/fs/his/controller/FsPackageController.java

@@ -207,4 +207,9 @@ public class FsPackageController extends BaseController
         return R.ok().put("data", new PageInfo<>(list));
     }
 
+    @GetMapping("/getOptions")
+    public AjaxResult getOptions(String keyword, Long metaId, Long limit) {
+        return AjaxResult.success(fsPackageService.getOptions(keyword, metaId, limit));
+    }
+
 }

+ 96 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsPickupGoodsController.java

@@ -0,0 +1,96 @@
+package com.fs.hisStore.controller;
+
+import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
+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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreProductAttrValueScrm;
+import com.fs.hisStore.domain.FsStoreProductScrm;
+import com.fs.hisStore.param.FsStoreProductAddEditParam;
+import com.fs.hisStore.service.IFsStoreProductAttrValueScrmService;
+import com.fs.hisStore.service.IFsStoreProductScrmService;
+import com.fs.hisStore.vo.FsStoreProductListVO;
+import com.mysql.cj.util.StringUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 自提商品Controller
+ */
+@Slf4j
+@RestController
+@RequestMapping("/store/store/pickupGoods")
+public class FsPickupGoodsController extends BaseController {
+
+    @Autowired
+    private IFsStoreProductScrmService fsStoreProductService;
+
+    @Autowired
+    private IFsStoreProductAttrValueScrmService attrValueService;
+
+    /**
+     * 查询自提商品列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:pickupGoods:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreProductScrm fsStoreProduct) {
+        startPage();
+        fsStoreProduct.setIsDel(0);
+        fsStoreProduct.setIsPickup(1);
+        List<FsStoreProductListVO> list;
+        if (StringUtils.isNullOrEmpty(fsStoreProduct.getBarCode())) {
+            list = fsStoreProductService.selectFsStoreProductListVO(fsStoreProduct);
+        } else {
+            list = fsStoreProductService.selectFsStoreProductBarCodeListVO(fsStoreProduct);
+        }
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取自提商品详情
+     */
+    @PreAuthorize("@ss.hasPermi('store:pickupGoods:query')")
+    @GetMapping("/{productId}")
+    public R getInfo(@PathVariable("productId") Long productId) {
+        FsStoreProductScrm product = fsStoreProductService.selectFsStoreProductById(productId);
+        FsStoreProductAttrValueScrm query = new FsStoreProductAttrValueScrm();
+        query.setProductId(productId);
+        List<FsStoreProductAttrValueScrm> values = attrValueService.selectFsStoreProductAttrValueList(query);
+        return R.ok().put("data", product).put("values", values);
+    }
+
+    /**
+     * 新增或修改自提商品
+     */
+    @PreAuthorize("@ss.hasPermi('store:pickupGoods:add') or @ss.hasPermi('store:pickupGoods:edit')")
+    @Log(title = "自提商品", businessType = BusinessType.INSERT, businessTypeExpression = "#p0.getProductId()>0? T(com.fs.common.enums.BusinessType).UPDATE: T(com.fs.common.enums.BusinessType).INSERT")
+    @PostMapping("/addOrEdit")
+    public R addOrEdit(@RequestBody FsStoreProductAddEditParam fsStoreProduct) {
+        if (ObjectUtils.isNotNull(fsStoreProduct.getIsShow()) && fsStoreProduct.getIsShow() == 1) {
+            log.info("自提商品上架:{}{}", fsStoreProduct.getProductName(), new Date());
+        }
+        if (ObjectUtils.isNotNull(fsStoreProduct.getIsDisplay()) && fsStoreProduct.getIsDisplay() == 1) {
+            log.info("自提商品前端展示:{}{}", fsStoreProduct.getProductName(), new Date());
+        }
+        fsStoreProduct.setIsPickup(1);
+        return fsStoreProductService.addOrEdit(fsStoreProduct);
+    }
+
+    /**
+     * 删除自提商品
+     */
+    @PreAuthorize("@ss.hasPermi('store:pickupGoods:remove')")
+    @Log(title = "自提商品", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{productIds}")
+    public AjaxResult remove(@PathVariable Long[] productIds) {
+        return toAjax(fsStoreProductService.deleteFsStoreProductByIds(productIds));
+    }
+}

+ 103 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderPickController.java

@@ -0,0 +1,103 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreOrderPick;
+import com.fs.hisStore.service.IFsStoreOrderPickService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 自提商品订单Controller
+ * 
+ * @author fs
+ * @date 2026-06-24
+ */
+@RestController
+@RequestMapping("/hisStore/storeOrderPick")
+public class FsStoreOrderPickController extends BaseController
+{
+    @Autowired
+    private IFsStoreOrderPickService fsStoreOrderPickService;
+
+    /**
+     * 查询自提商品订单列表
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeOrderPick:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreOrderPick fsStoreOrderPick)
+    {
+        startPage();
+        List<FsStoreOrderPick> list = fsStoreOrderPickService.selectFsStoreOrderPickList(fsStoreOrderPick);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出自提商品订单列表
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeOrderPick:export')")
+    @Log(title = "自提商品订单", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreOrderPick fsStoreOrderPick)
+    {
+        List<FsStoreOrderPick> list = fsStoreOrderPickService.selectFsStoreOrderPickList(fsStoreOrderPick);
+        ExcelUtil<FsStoreOrderPick> util = new ExcelUtil<FsStoreOrderPick>(FsStoreOrderPick.class);
+        return util.exportExcel(list, "自提商品订单数据");
+    }
+
+    /**
+     * 获取自提商品订单详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeOrderPick:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fsStoreOrderPickService.selectFsStoreOrderPickById(id));
+    }
+
+    /**
+     * 新增自提商品订单
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeOrderPick:add')")
+    @Log(title = "自提商品订单", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreOrderPick fsStoreOrderPick)
+    {
+        return toAjax(fsStoreOrderPickService.insertFsStoreOrderPick(fsStoreOrderPick));
+    }
+
+    /**
+     * 修改自提商品订单
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeOrderPick:edit')")
+    @Log(title = "自提商品订单", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreOrderPick fsStoreOrderPick)
+    {
+        return toAjax(fsStoreOrderPickService.updateFsStoreOrderPick(fsStoreOrderPick));
+    }
+
+    /**
+     * 删除自提商品订单
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeOrderPick:remove')")
+    @Log(title = "自提商品订单", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsStoreOrderPickService.deleteFsStoreOrderPickByIds(ids));
+    }
+}

+ 93 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderPickItemController.java

@@ -0,0 +1,93 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreOrderPickItem;
+import com.fs.hisStore.service.IFsStoreOrderPickItemService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 自提商品详情Controller
+ *
+ * @author fs
+ * @date 2026-06-24
+ */
+@RestController
+@RequestMapping("/hisStore/storeOrderPickItem")
+public class FsStoreOrderPickItemController extends BaseController
+{
+    @Autowired
+    private IFsStoreOrderPickItemService fsStoreOrderPickItemService;
+
+    /**
+     * 查询自提商品详情列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreOrderPickItem fsStoreOrderPickItem)
+    {
+        startPage();
+        List<FsStoreOrderPickItem> list = fsStoreOrderPickItemService.selectFsStoreOrderPickItemList(fsStoreOrderPickItem);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出自提商品详情列表
+     */
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreOrderPickItem fsStoreOrderPickItem)
+    {
+        List<FsStoreOrderPickItem> list = fsStoreOrderPickItemService.selectFsStoreOrderPickItemList(fsStoreOrderPickItem);
+        ExcelUtil<FsStoreOrderPickItem> util = new ExcelUtil<FsStoreOrderPickItem>(FsStoreOrderPickItem.class);
+        return util.exportExcel(list, "自提商品详情数据");
+    }
+
+    /**
+     * 获取自提商品详情详细信息
+     */
+    @GetMapping(value = "/{itemId}")
+    public AjaxResult getInfo(@PathVariable("itemId") Long itemId)
+    {
+        return AjaxResult.success(fsStoreOrderPickItemService.selectFsStoreOrderPickItemByItemId(itemId));
+    }
+
+    /**
+     * 新增自提商品详情
+     */
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreOrderPickItem fsStoreOrderPickItem)
+    {
+        return toAjax(fsStoreOrderPickItemService.insertFsStoreOrderPickItem(fsStoreOrderPickItem));
+    }
+
+    /**
+     * 修改自提商品详情
+     */
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreOrderPickItem fsStoreOrderPickItem)
+    {
+        return toAjax(fsStoreOrderPickItemService.updateFsStoreOrderPickItem(fsStoreOrderPickItem));
+    }
+
+    /**
+     * 删除自提商品详情
+     */
+	@DeleteMapping("/{itemIds}")
+    public AjaxResult remove(@PathVariable Long[] itemIds)
+    {
+        return toAjax(fsStoreOrderPickItemService.deleteFsStoreOrderPickItemByItemIds(itemIds));
+    }
+}

+ 106 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserRecordController.java

@@ -0,0 +1,106 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreProductUserRecord;
+import com.fs.hisStore.service.IFsStoreProductUserRecordService;
+import com.fs.hisStore.vo.FsStoreProductUserRecordVO;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 商品信息采集记录Controller
+ *
+ * @author fs
+ * @date 2026-06-18
+ */
+@RestController
+@RequestMapping("/store/store/storeProductUserRecord")
+public class FsStoreProductUserRecordController extends BaseController
+{
+    @Autowired
+    private IFsStoreProductUserRecordService fsStoreProductUserRecordService;
+
+    /**
+     * 查询商品信息采集记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserRecord:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreProductUserRecordVO query)
+    {
+        startPage();
+        query.setIsDel(0);
+        List<FsStoreProductUserRecordVO> list = fsStoreProductUserRecordService.selectFsStoreProductUserRecordListVO(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出商品信息采集记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserRecord:export')")
+    @Log(title = "商品信息采集记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreProductUserRecordVO query)
+    {
+        query.setIsDel(0);
+        List<FsStoreProductUserRecordVO> list = fsStoreProductUserRecordService.selectFsStoreProductUserRecordListVO(query);
+        ExcelUtil<FsStoreProductUserRecordVO> util = new ExcelUtil<FsStoreProductUserRecordVO>(FsStoreProductUserRecordVO.class);
+        return util.exportExcel(list, "商品信息采集记录数据");
+    }
+
+    /**
+     * 获取商品信息采集记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserRecord:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fsStoreProductUserRecordService.selectFsStoreProductUserRecordDetailById(id));
+    }
+
+    /**
+     * 新增商品信息采集记录
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserRecord:add')")
+    @Log(title = "商品信息采集记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreProductUserRecord fsStoreProductUserRecord)
+    {
+        return toAjax(fsStoreProductUserRecordService.insertFsStoreProductUserRecord(fsStoreProductUserRecord));
+    }
+
+    /**
+     * 修改商品信息采集记录
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserRecord:edit')")
+    @Log(title = "商品信息采集记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreProductUserRecord fsStoreProductUserRecord)
+    {
+        return toAjax(fsStoreProductUserRecordService.updateFsStoreProductUserRecord(fsStoreProductUserRecord));
+    }
+
+    /**
+     * 删除商品信息采集记录
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserRecord:remove')")
+    @Log(title = "商品信息采集记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsStoreProductUserRecordService.deleteFsStoreProductUserRecordByIds(ids));
+    }
+}

+ 103 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserRecordValueController.java

@@ -0,0 +1,103 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreProductUserRecordValue;
+import com.fs.hisStore.service.IFsStoreProductUserRecordValueService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 商品信息采集记录值Controller
+ * 
+ * @author fs
+ * @date 2026-06-18
+ */
+@RestController
+@RequestMapping("/FsStoreProductUserRecordValue/FsStoreProductUserRecordValue")
+public class FsStoreProductUserRecordValueController extends BaseController
+{
+    @Autowired
+    private IFsStoreProductUserRecordValueService fsStoreProductUserRecordValueService;
+
+    /**
+     * 查询商品信息采集记录值列表
+     */
+    @PreAuthorize("@ss.hasPermi('FsStoreProductUserRecordValue:FsStoreProductUserRecordValue:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreProductUserRecordValue fsStoreProductUserRecordValue)
+    {
+        startPage();
+        List<FsStoreProductUserRecordValue> list = fsStoreProductUserRecordValueService.selectFsStoreProductUserRecordValueList(fsStoreProductUserRecordValue);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出商品信息采集记录值列表
+     */
+    @PreAuthorize("@ss.hasPermi('FsStoreProductUserRecordValue:FsStoreProductUserRecordValue:export')")
+    @Log(title = "商品信息采集记录值", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreProductUserRecordValue fsStoreProductUserRecordValue)
+    {
+        List<FsStoreProductUserRecordValue> list = fsStoreProductUserRecordValueService.selectFsStoreProductUserRecordValueList(fsStoreProductUserRecordValue);
+        ExcelUtil<FsStoreProductUserRecordValue> util = new ExcelUtil<FsStoreProductUserRecordValue>(FsStoreProductUserRecordValue.class);
+        return util.exportExcel(list, "商品信息采集记录值数据");
+    }
+
+    /**
+     * 获取商品信息采集记录值详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('FsStoreProductUserRecordValue:FsStoreProductUserRecordValue:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fsStoreProductUserRecordValueService.selectFsStoreProductUserRecordValueById(id));
+    }
+
+    /**
+     * 新增商品信息采集记录值
+     */
+    @PreAuthorize("@ss.hasPermi('FsStoreProductUserRecordValue:FsStoreProductUserRecordValue:add')")
+    @Log(title = "商品信息采集记录值", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreProductUserRecordValue fsStoreProductUserRecordValue)
+    {
+        return toAjax(fsStoreProductUserRecordValueService.insertFsStoreProductUserRecordValue(fsStoreProductUserRecordValue));
+    }
+
+    /**
+     * 修改商品信息采集记录值
+     */
+    @PreAuthorize("@ss.hasPermi('FsStoreProductUserRecordValue:FsStoreProductUserRecordValue:edit')")
+    @Log(title = "商品信息采集记录值", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreProductUserRecordValue fsStoreProductUserRecordValue)
+    {
+        return toAjax(fsStoreProductUserRecordValueService.updateFsStoreProductUserRecordValue(fsStoreProductUserRecordValue));
+    }
+
+    /**
+     * 删除商品信息采集记录值
+     */
+    @PreAuthorize("@ss.hasPermi('FsStoreProductUserRecordValue:FsStoreProductUserRecordValue:remove')")
+    @Log(title = "商品信息采集记录值", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsStoreProductUserRecordValueService.deleteFsStoreProductUserRecordValueByIds(ids));
+    }
+}

+ 109 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserTempController.java

@@ -0,0 +1,109 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreProductUserTemp;
+import com.fs.hisStore.service.IFsStoreProductUserTempService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 商品信息采集模板Controller
+ *
+ * @author fs
+ * @date 2026-06-18
+ */
+@RestController
+@RequestMapping("/store/store/storeProductUserTemp")
+public class FsStoreProductUserTempController extends BaseController
+{
+    @Autowired
+    private IFsStoreProductUserTempService fsStoreProductUserTempService;
+
+    /**
+     * 查询商品信息采集模板列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTemp:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreProductUserTemp fsStoreProductUserTemp)
+    {
+        startPage();
+        fsStoreProductUserTemp.setIsDel(0);
+        List<FsStoreProductUserTemp> list = fsStoreProductUserTempService.selectFsStoreProductUserTempList(fsStoreProductUserTemp);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出商品信息采集模板列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTemp:export')")
+    @Log(title = "商品信息采集模板", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreProductUserTemp fsStoreProductUserTemp)
+    {
+        fsStoreProductUserTemp.setIsDel(0);
+        List<FsStoreProductUserTemp> list = fsStoreProductUserTempService.selectFsStoreProductUserTempList(fsStoreProductUserTemp);
+        ExcelUtil<FsStoreProductUserTemp> util = new ExcelUtil<FsStoreProductUserTemp>(FsStoreProductUserTemp.class);
+        return util.exportExcel(list, "商品信息采集模板数据");
+    }
+
+    /**
+     * 获取商品信息采集模板详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTemp:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        FsStoreProductUserTemp data = fsStoreProductUserTempService.selectFsStoreProductUserTempById(id);
+        if (data == null || Integer.valueOf(1).equals(data.getIsDel())) {
+            return AjaxResult.error("数据不存在");
+        }
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 新增商品信息采集模板
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTemp:add')")
+    @Log(title = "商品信息采集模板", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreProductUserTemp fsStoreProductUserTemp)
+    {
+        return toAjax(fsStoreProductUserTempService.insertFsStoreProductUserTemp(fsStoreProductUserTemp));
+    }
+
+    /**
+     * 修改商品信息采集模板
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTemp:edit')")
+    @Log(title = "商品信息采集模板", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreProductUserTemp fsStoreProductUserTemp)
+    {
+        return toAjax(fsStoreProductUserTempService.updateFsStoreProductUserTemp(fsStoreProductUserTemp));
+    }
+
+    /**
+     * 删除商品信息采集模板
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTemp:remove')")
+    @Log(title = "商品信息采集模板", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsStoreProductUserTempService.deleteFsStoreProductUserTempByIds(ids));
+    }
+}

+ 143 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductUserTempFieldController.java

@@ -0,0 +1,143 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreProductUserTempField;
+import com.fs.hisStore.service.IFsStoreProductUserTempFieldService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.StringUtils;
+
+/**
+ * 商品信息采集模板字段Controller
+ *
+ * @author fs
+ * @date 2026-06-18
+ */
+@RestController
+@RequestMapping("/store/store/storeProductUserTempField")
+public class FsStoreProductUserTempFieldController extends BaseController
+{
+    @Autowired
+    private IFsStoreProductUserTempFieldService fsStoreProductUserTempFieldService;
+
+    /**
+     * 查询商品信息采集模板字段列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTempField:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreProductUserTempField fsStoreProductUserTempField)
+    {
+        startPage();
+        fsStoreProductUserTempField.setIsDel(0);
+        List<FsStoreProductUserTempField> list = fsStoreProductUserTempFieldService.selectFsStoreProductUserTempFieldList(fsStoreProductUserTempField);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出商品信息采集模板字段列表
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTempField:export')")
+    @Log(title = "商品信息采集模板字段", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreProductUserTempField fsStoreProductUserTempField)
+    {
+        fsStoreProductUserTempField.setIsDel(0);
+        List<FsStoreProductUserTempField> list = fsStoreProductUserTempFieldService.selectFsStoreProductUserTempFieldList(fsStoreProductUserTempField);
+        ExcelUtil<FsStoreProductUserTempField> util = new ExcelUtil<FsStoreProductUserTempField>(FsStoreProductUserTempField.class);
+        return util.exportExcel(list, "商品信息采集模板字段数据");
+    }
+
+    /**
+     * 获取商品信息采集模板字段详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTempField:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        FsStoreProductUserTempField data = fsStoreProductUserTempFieldService.selectFsStoreProductUserTempFieldById(id);
+        if (data == null || Integer.valueOf(1).equals(data.getIsDel())) {
+            return AjaxResult.error("数据不存在");
+        }
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 新增商品信息采集模板字段
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTempField:add')")
+    @Log(title = "商品信息采集模板字段", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreProductUserTempField fsStoreProductUserTempField)
+    {
+        AjaxResult validateResult = validateAndNormalizeFieldOption(fsStoreProductUserTempField);
+        if (validateResult != null) {
+            return validateResult;
+        }
+        return toAjax(fsStoreProductUserTempFieldService.insertFsStoreProductUserTempField(fsStoreProductUserTempField));
+    }
+
+    /**
+     * 修改商品信息采集模板字段
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTempField:edit')")
+    @Log(title = "商品信息采集模板字段", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreProductUserTempField fsStoreProductUserTempField)
+    {
+        AjaxResult validateResult = validateAndNormalizeFieldOption(fsStoreProductUserTempField);
+        if (validateResult != null) {
+            return validateResult;
+        }
+        return toAjax(fsStoreProductUserTempFieldService.updateFsStoreProductUserTempField(fsStoreProductUserTempField));
+    }
+
+    /**
+     * 删除商品信息采集模板字段
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeProductUserTempField:remove')")
+    @Log(title = "商品信息采集模板字段", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsStoreProductUserTempFieldService.deleteFsStoreProductUserTempFieldByIds(ids));
+    }
+
+    /**
+     * 单选/多选时要求填写选项内容,并统一分号格式
+     */
+    private AjaxResult validateAndNormalizeFieldOption(FsStoreProductUserTempField field) {
+        if (field == null) {
+            return AjaxResult.error("参数不能为空");
+        }
+        String fieldType = field.getFieldType();
+        String fieldOption = field.getFieldOption();
+        if ("radio".equals(fieldType) || "checkbox".equals(fieldType)) {
+            if (StringUtils.isEmpty(fieldOption)) {
+                return AjaxResult.error("单选/复选类型必须填写选项内容");
+            }
+            String normalized = fieldOption
+                    .replace(';', ';')
+                    .replaceAll("\\s*;\\s*", ";")
+                    .trim();
+            field.setFieldOption(normalized);
+        } else {
+            // 非单选/复选类型不保存选项内容
+            field.setFieldOption(null);
+        }
+        return null;
+    }
+}

+ 103 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreRedeemedRecordController.java

@@ -0,0 +1,103 @@
+package com.fs.hisStore.controller;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreRedeemedRecord;
+import com.fs.hisStore.service.IFsStoreRedeemedRecordService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 自提商品核销记录Controller
+ * 
+ * @author fs
+ * @date 2026-06-24
+ */
+@RestController
+@RequestMapping("/hisStore/storeRedeemedRecord")
+public class FsStoreRedeemedRecordController extends BaseController
+{
+    @Autowired
+    private IFsStoreRedeemedRecordService fsStoreRedeemedRecordService;
+
+    /**
+     * 查询自提商品核销记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeRedeemedRecord:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreRedeemedRecord fsStoreRedeemedRecord)
+    {
+        startPage();
+        List<FsStoreRedeemedRecord> list = fsStoreRedeemedRecordService.selectFsStoreRedeemedRecordList(fsStoreRedeemedRecord);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出自提商品核销记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeRedeemedRecord:export')")
+    @Log(title = "自提商品核销记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStoreRedeemedRecord fsStoreRedeemedRecord)
+    {
+        List<FsStoreRedeemedRecord> list = fsStoreRedeemedRecordService.selectFsStoreRedeemedRecordList(fsStoreRedeemedRecord);
+        ExcelUtil<FsStoreRedeemedRecord> util = new ExcelUtil<FsStoreRedeemedRecord>(FsStoreRedeemedRecord.class);
+        return util.exportExcel(list, "自提商品核销记录数据");
+    }
+
+    /**
+     * 获取自提商品核销记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeRedeemedRecord:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fsStoreRedeemedRecordService.selectFsStoreRedeemedRecordById(id));
+    }
+
+    /**
+     * 新增自提商品核销记录
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeRedeemedRecord:add')")
+    @Log(title = "自提商品核销记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreRedeemedRecord fsStoreRedeemedRecord)
+    {
+        return toAjax(fsStoreRedeemedRecordService.insertFsStoreRedeemedRecord(fsStoreRedeemedRecord));
+    }
+
+    /**
+     * 修改自提商品核销记录
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeRedeemedRecord:edit')")
+    @Log(title = "自提商品核销记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreRedeemedRecord fsStoreRedeemedRecord)
+    {
+        return toAjax(fsStoreRedeemedRecordService.updateFsStoreRedeemedRecord(fsStoreRedeemedRecord));
+    }
+
+    /**
+     * 删除自提商品核销记录
+     */
+    @PreAuthorize("@ss.hasPermi('hisStore:storeRedeemedRecord:remove')")
+    @Log(title = "自提商品核销记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsStoreRedeemedRecordService.deleteFsStoreRedeemedRecordByIds(ids));
+    }
+}

+ 308 - 0
fs-admin/src/main/java/com/fs/utils/AudioUtils.java

@@ -0,0 +1,308 @@
+package com.fs.utils;
+
+import com.fs.common.exception.ServiceException;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public class AudioUtils {
+
+    /**
+     * 工具地址
+     **/
+    static String path = "c:\\";
+    static String destinationDir = "c:\\hook\\";
+
+    /**
+     * 从网络 URL 转换 MP3/WAV到SILk格式
+     *
+     * @param audioUrl  音频文件的 URL
+     * @param isSource  是否清空原文件
+     * @return SILK 文件路径
+     */
+    public static String transferAudioSilkFromUrl(String audioUrl, boolean isSource) {
+        try {
+            // 下载文件到本地临时路径
+            File tempFile = downloadFileFromUrl(audioUrl);
+            if (tempFile == null) {
+                throw new ServiceException("下载文件失败");
+            }
+
+            String silkUrl = transferAudioSilk(tempFile.getParent()+"\\", tempFile.getName(), isSource);
+            // 删除临时文件
+            tempFile.delete();
+
+            // 调用原来的转换方法
+            return silkUrl;
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return null;
+    }
+    /**
+     * MP3/WAV转SILk格式
+     *
+     * @param path 文件路径 例:D:\\file\\
+     * @param name 文件名称 例:audio.mp3/audio.wav
+     * @param isSource 是否清空原文件
+     * @return silk文件路径
+     * @throws Exception
+     */
+    public static String transferAudioSilk(String path, String name, boolean isSource) {
+        try {
+            // 判断后缀格式
+            String suffix = name.split("\\.")[1];
+            if (!suffix.toLowerCase().equals("mp3") && !suffix.toLowerCase().equals("wav")) {
+                throw new ServiceException("文件格式必须是mp3/wav");
+            }
+            String filePath = path + name;
+            File file = new File(filePath);
+            if (!file.exists()) {
+                throw new Exception("文件不存在!");
+            }
+            // 文件名时拼接
+            SimpleDateFormat ttime = new SimpleDateFormat("yyyyMMddhhMMSS");
+            String time = ttime.format(new Date());
+            // 导出的pcm格式路径
+            String pcmPath = path + "PCM_" + time + ".pcm";
+            // 先将mp3/wav转换成pcm格式
+            transferAudioPcm(filePath, pcmPath);
+            // 导出的silk格式路径
+            String silkPath = path + "SILK_" + time + ".silk";
+            // 转换成silk格式
+            transferPcmSilk(pcmPath, silkPath);
+            // 删除pcm文件
+            File pcmFile = new File(pcmPath);
+            if (pcmFile.exists()) {
+                pcmFile.delete();
+            }
+            if (isSource) {
+                File audioFile = new File(filePath);
+
+                if (audioFile.exists()) {
+                    audioFile.delete();
+                }
+            }
+
+            File silkFile = new File(silkPath);
+            //上传silk文件
+            String silkPathSuffix = silkPath.split("\\.")[1];
+            CloudStorageService storage = OSSFactory.build();
+            byte[] fileBytes = Files.readAllBytes(silkFile.toPath());
+            String silkUrl = storage.uploadSuffix(fileBytes, "."+silkPathSuffix);
+
+            if (silkFile.exists()) {
+                silkFile.delete();
+            }
+            return silkUrl;
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return null;
+    }
+
+    /**
+     * mp3/wav 通用
+     * @param fpath
+     * @param target
+     */
+    private static void transferAudioPcm(String fpath, String target) {
+        List<String> commend = new ArrayList<String>();
+        commend.add(path + "ffmpeg.exe");
+        commend.add("-y");
+        commend.add("-i");
+        commend.add(fpath);
+        commend.add("-f");
+        commend.add("s16le");
+        commend.add("-ar");
+        commend.add("24000");
+        commend.add("-ac");
+        commend.add("1");
+        commend.add(target);
+        try {
+            ProcessBuilder builder = new ProcessBuilder();
+            builder.command(commend);
+            Process p = builder.start();
+            p.waitFor();
+            p.destroy();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * silk_v3_encoder.exe,转成Silk格式
+     * @param pcmPath pcm 文件地址
+     * @param target  转换后的silk地址
+     * silk_v3_encoder.exe 路径
+     * pcm文件地址
+     * silk输出地址
+     * -Fs_API <Hz>            : API sampling rate in Hz, default: 24000
+     * -Fs_maxInternal <Hz>    : Maximum internal sampling rate in Hz, default: 24000
+     * -packetlength <ms>      : Packet interval in ms, default: 20
+     * -rate <bps>            : Target bitrate;   default: 25000
+     * -loss <perc>          : Uplink loss estimate, in percent (0-100);  default: 0
+     * -complexity <comp>   : Set complexity, 0: low, 1: medium, 2: high; default: 2
+     * -DTX <flag>          : Enable DTX (0/1); default: 0
+     * -quiet               : Print only some basic values
+     * -tencent             : Compatible with QQ/Wechat
+     */
+    public static void transferPcmSilk(String pcmPath, String target) {
+        Process process = null;
+        try {
+            /**
+             // 1、这一节的,语音长度太长会使音频长度丢失
+             List<String> commend = new ArrayList<>();
+             // 指令,可参照方法注释, 请不要在commend.add()里同时写【-参数 值】
+             commend.add(path + "silk_v3_encoder.exe");
+             commend.add(pcmPath);
+             commend.add(target);
+             commend.add("-tencent");
+             ProcessBuilder builder = new ProcessBuilder();
+             builder.command(commend);
+             process = builder.start();
+             // 如果删除下班这行写process.waitFor() ,太长的语音会阻塞,BufferedReader 打印出来太长的语音也会阻塞
+             process = Runtime.getRuntime().exec("taskkill -f -t -im silk_v3_encoder.exe");
+             */
+            // 方法2,除了会弹出弹窗,没什么问题 cmd /c 极为重要,执行完毕后会自动关闭
+            process = Runtime.getRuntime().exec("cmd /c start " + path + "silk_v3_encoder.exe " + pcmPath + " " + target + " -tencent");
+            process .waitFor();
+            Thread.sleep(1000);
+            // 有更好的方法会后续慢慢更新..
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (process != null) {
+                process.destroy();
+            }
+        }
+    }
+    /**
+     * 下载文件从网络 URL 到本地临时文件夹
+     *
+     * @param fileUrl 文件的网络 URL
+     * @return 下载到本地的临时文件
+     */
+    private static File downloadFileFromUrl(String fileUrl) {
+        InputStream inputStream = null;
+        FileOutputStream outputStream = null;
+        try {
+            // 创建 HTTP 连接
+            URL url = new URL(fileUrl);
+            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+            // 设置Referer请求头
+            connection.setRequestProperty("Referer", "cos.his.cdwjyyh.com");
+            connection.setRequestMethod("GET");
+            connection.connect();
+
+            // 检查是否成功连接
+            if (connection.getResponseCode() != 200) {
+                throw new ServiceException("无法下载音频文件,HTTP 响应码:" + connection.getResponseCode());
+            }
+
+            // 获取输入流
+            inputStream = connection.getInputStream();
+
+            // 创建临时文件,并指定存放地址
+            String tempFileName = "temp_" + System.currentTimeMillis() + "_" + getFileExtension(fileUrl);
+            File destinationDirectory = new File(destinationDir);
+
+            // 确保目标目录存在
+            if (!destinationDirectory.exists()) {
+                destinationDirectory.mkdirs();
+            }
+
+            // 将文件保存到指定路径
+            File tempFile = new File(destinationDirectory, tempFileName);
+
+            // 写入文件
+            outputStream = new FileOutputStream(tempFile);
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+
+            return tempFile;
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            try {
+                if (inputStream != null) inputStream.close();
+                if (outputStream != null) outputStream.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+        return null;
+    }
+
+//    private static File downloadFileFromUrl(String fileUrl)
+//    {
+//        InputStream inputStream = null;
+//        FileOutputStream outputStream = null;
+//        try {
+//            // 创建 HTTP 连接
+//            URL url = new URL(fileUrl);
+//            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+//            connection.setRequestMethod("GET");
+//            connection.connect();
+//
+//            // 检查是否成功连接
+//            if (connection.getResponseCode() != 200) {
+//                throw new ServiceException("无法下载音频文件,HTTP 响应码:" + connection.getResponseCode());
+//            }
+//
+//            // 获取输入流
+//            inputStream = connection.getInputStream();
+//
+//            // 创建临时文件
+//            String tempFileName = "temp_" + System.currentTimeMillis() + "_" + getFileExtension(fileUrl);
+//            File tempFile = new File(tempFileName);
+//
+//            // 写入文件
+//            outputStream = new FileOutputStream(tempFile);
+//            byte[] buffer = new byte[8192];
+//            int bytesRead;
+//            while ((bytesRead = inputStream.read(buffer)) != -1) {
+//                outputStream.write(buffer, 0, bytesRead);
+//            }
+//
+//            return tempFile;
+//        } catch (Exception e) {
+//            e.printStackTrace();
+//        } finally {
+//            try {
+//                if (inputStream != null) inputStream.close();
+//                if (outputStream != null) outputStream.close();
+//            } catch (IOException e) {
+//                e.printStackTrace();
+//            }
+//        }
+//        return null;
+//    }
+
+    /**
+     * 获取文件扩展名
+     *
+     * @param fileUrl 文件路径
+     * @return 文件扩展名
+     */
+    private static String getFileExtension(String fileUrl) {
+        return fileUrl.substring(fileUrl.lastIndexOf("."));
+    }
+
+    // 省略其他方法的实现
+
+}

+ 136 - 0
fs-admin/src/main/java/com/fs/web/controller/system/AppDeptController.java

@@ -0,0 +1,136 @@
+package com.fs.web.controller.system;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.constant.UserConstants;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.entity.AppDept;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.system.service.IAppDeptService;
+import org.apache.commons.lang3.ArrayUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * 部门信息
+ */
+@RestController
+@RequestMapping("/app/dept")
+public class AppDeptController extends BaseController {
+    @Autowired
+    private IAppDeptService deptService;
+
+    /**
+     * 获取部门列表
+     */
+    @PreAuthorize("@ss.hasPermi('system:dept:list')")
+    @GetMapping("/list")
+    public AjaxResult list(AppDept dept) {
+        List<AppDept> depts = deptService.selectDeptList(dept);
+        return AjaxResult.success(depts);
+    }
+
+    /**
+     * 查询部门列表(排除节点)
+     */
+    @PreAuthorize("@ss.hasPermi('system:dept:list')")
+    @GetMapping("/list/exclude/{deptId}")
+    public AjaxResult excludeChild(@PathVariable(value = "deptId", required = false) Long deptId) {
+        List<AppDept> depts = deptService.selectDeptList(new AppDept());
+        Iterator<AppDept> it = depts.iterator();
+        while (it.hasNext()) {
+            AppDept d = (AppDept) it.next();
+            if (d.getDeptId().intValue() == deptId
+                    || ArrayUtils.contains(StringUtils.split(d.getAncestors(), ","), deptId + "")) {
+                it.remove();
+            }
+        }
+        return AjaxResult.success(depts);
+    }
+
+    /**
+     * 根据部门编号获取详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('system:dept:query')")
+    @GetMapping(value = "/{deptId}")
+    public AjaxResult getInfo(@PathVariable Long deptId) {
+        deptService.checkDeptDataScope(deptId);
+        return AjaxResult.success(deptService.selectDeptById(deptId));
+    }
+
+    /**
+     * 获取部门下拉树列表
+     */
+    @GetMapping("/treeselect")
+    public AjaxResult treeselect(AppDept dept) {
+        List<AppDept> depts = deptService.selectDeptList(dept);
+        return AjaxResult.success(deptService.buildDeptTreeSelect(depts));
+    }
+
+    /**
+     * 加载对应角色部门列表树
+     */
+    @GetMapping(value = "/roleDeptTreeselect/{roleId}")
+    public AjaxResult roleDeptTreeselect(@PathVariable("roleId") Long roleId) {
+        List<AppDept> depts = deptService.selectDeptList(new AppDept());
+        AjaxResult ajax = AjaxResult.success();
+        ajax.put("checkedKeys", deptService.selectDeptListByRoleId(roleId));
+        ajax.put("depts", deptService.buildDeptTreeSelect(depts));
+        return ajax;
+    }
+
+    /**
+     * 新增部门
+     */
+    @PreAuthorize("@ss.hasPermi('system:dept:add')")
+    @Log(title = "部门管理", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@Validated @RequestBody AppDept dept) {
+        if (UserConstants.NOT_UNIQUE.equals(deptService.checkDeptNameUnique(dept))) {
+            return AjaxResult.error("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在");
+        }
+        dept.setCreateBy(getUsername());
+        return toAjax(deptService.insertDept(dept));
+    }
+
+    /**
+     * 修改部门
+     */
+    @PreAuthorize("@ss.hasPermi('system:dept:edit')")
+    @Log(title = "部门管理", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@Validated @RequestBody AppDept dept) {
+        if (UserConstants.NOT_UNIQUE.equals(deptService.checkDeptNameUnique(dept))) {
+            return AjaxResult.error("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在");
+        } else if (dept.getParentId().equals(dept.getDeptId())) {
+            return AjaxResult.error("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己");
+        } else if (StringUtils.equals(UserConstants.DEPT_DISABLE, dept.getStatus())
+                && deptService.selectNormalChildrenDeptById(dept.getDeptId()) > 0) {
+            return AjaxResult.error("该部门包含未停用的子部门!");
+        }
+        dept.setUpdateBy(getUsername());
+        return toAjax(deptService.updateDept(dept));
+    }
+
+    /**
+     * 删除部门
+     */
+    @PreAuthorize("@ss.hasPermi('system:dept:remove')")
+    @Log(title = "部门管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{deptId}")
+    public AjaxResult remove(@PathVariable Long deptId) {
+        if (deptService.hasChildByDeptId(deptId)) {
+            return AjaxResult.error("存在下级部门,不允许删除");
+        }
+        if (deptService.checkDeptExistUser(deptId)) {
+            return AjaxResult.error("部门存在邀请码,不允许删除");
+        }
+        return toAjax(deptService.deleteDeptById(deptId));
+    }
+}

+ 152 - 0
fs-app-task-course/pom.xml

@@ -0,0 +1,152 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>fs</artifactId>
+        <groupId>com.fs</groupId>
+        <version>1.1.0</version>
+    </parent>
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.fs</groupId>
+    <artifactId>fs-app-task-course</artifactId>
+    <description>
+        APP课程状态定时任务
+    </description>
+
+    <dependencies>
+        <!-- spring-boot-devtools -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-devtools</artifactId>
+            <optional>true</optional> <!-- 表示依赖不会传递 -->
+        </dependency>
+        <!-- swagger2-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+
+        <!-- swagger2-UI-->
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.xiaoymin</groupId>
+            <artifactId>swagger-bootstrap-ui</artifactId>
+            <version>1.9.3</version>
+        </dependency>
+
+
+        <!-- Mysql驱动包 -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+
+        <!-- SpringBoot Web容器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- SpringBoot 拦截器 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <!-- 阿里数据库连接池 -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+
+        <!--clickhouse-->
+        <dependency>
+            <groupId>com.clickhouse</groupId>
+            <artifactId>clickhouse-jdbc</artifactId>
+            <version>0.4.6</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.retry</groupId>
+            <artifactId>spring-retry</artifactId>
+            <version>1.3.1</version>
+        </dependency>
+
+        <!--        <dependency>-->
+<!--            <groupId>ru.yandex.clickhouse</groupId>-->
+<!--            <artifactId>clickhouse-jdbc</artifactId>-->
+<!--            <version>0.3.2</version>-->
+<!--        </dependency>-->
+
+        <!-- 验证码 -->
+        <dependency>
+            <groupId>com.github.penggle</groupId>
+            <artifactId>kaptcha</artifactId>
+            <exclusions>
+                <exclusion>
+                    <artifactId>javax.servlet-api</artifactId>
+                    <groupId>javax.servlet</groupId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <!-- 获取系统信息 -->
+        <dependency>
+            <groupId>com.github.oshi</groupId>
+            <artifactId>oshi-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <fork>true</fork> <!-- 如果没有该配置,devtools不会生效 -->
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+                <version>3.1.0</version>
+                <configuration>
+                    <failOnMissingWebXml>false</failOnMissingWebXml>
+                    <warName>${project.artifactId}</warName>
+                </configuration>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+
+
+</project>

+ 14 - 0
fs-app-task-course/src/main/java/com/fs/FSServletInitializer.java

@@ -0,0 +1,14 @@
+package com.fs;
+
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+
+
+public class FSServletInitializer extends SpringBootServletInitializer
+{
+    @Override
+    protected SpringApplicationBuilder configure(SpringApplicationBuilder application)
+    {
+        return application.sources(FsAppTaskCourseApplication.class);
+    }
+}

+ 24 - 0
fs-app-task-course/src/main/java/com/fs/FsAppTaskCourseApplication.java

@@ -0,0 +1,24 @@
+package com.fs;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * 启动程序
+ */
+@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
+@EnableTransactionManagement
+@EnableAsync
+@EnableScheduling
+public class FsAppTaskCourseApplication
+{
+    public static void main(String[] args){
+        // System.setProperty("spring.devtools.restart.enabled", "false");
+        SpringApplication.run(FsAppTaskCourseApplication.class, args);
+        System.out.println("appTask启动成功");
+    }
+}

+ 29 - 0
fs-app-task-course/src/main/java/com/fs/app/controller/CommonController.java

@@ -0,0 +1,29 @@
+package com.fs.app.controller;
+
+
+
+import com.fs.app.task.AppCourseWatchLogScheduler;
+import com.fs.common.core.domain.R;
+import io.swagger.annotations.Api;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+
+
+@Api("公共接口")
+@RestController
+@RequestMapping(value="/app/common")
+@Slf4j
+public class CommonController {
+
+    @Autowired
+    private AppCourseWatchLogScheduler appCourseWatchLogScheduler;
+    @GetMapping("/test")
+    public void test(){
+        appCourseWatchLogScheduler.checkWatchStatus();
+    }
+
+}

+ 51 - 0
fs-app-task-course/src/main/java/com/fs/app/exception/FSException.java

@@ -0,0 +1,51 @@
+package com.fs.app.exception;
+
+/**
+ * 自定义异常
+ */
+public class FSException extends RuntimeException {
+	private static final long serialVersionUID = 1L;
+
+    private String msg;
+    private int code = 500;
+
+    public FSException(String msg) {
+		super(msg);
+		this.msg = msg;
+	}
+
+	public FSException(String msg, Throwable e) {
+		super(msg, e);
+		this.msg = msg;
+	}
+
+	public FSException(String msg, int code) {
+		super(msg);
+		this.msg = msg;
+		this.code = code;
+	}
+
+	public FSException(String msg, int code, Throwable e) {
+		super(msg, e);
+		this.msg = msg;
+		this.code = code;
+	}
+
+	public String getMsg() {
+		return msg;
+	}
+
+	public void setMsg(String msg) {
+		this.msg = msg;
+	}
+
+	public int getCode() {
+		return code;
+	}
+
+	public void setCode(int code) {
+		this.code = code;
+	}
+
+
+}

+ 81 - 0
fs-app-task-course/src/main/java/com/fs/app/exception/FSExceptionHandler.java

@@ -0,0 +1,81 @@
+package com.fs.app.exception;
+
+
+
+
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.validation.BindException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+
+/**
+ * 异常处理器
+ */
+@RestControllerAdvice
+public class FSExceptionHandler {
+	private Logger logger = LoggerFactory.getLogger(getClass());
+
+	/**
+	 * 处理自定义异常
+	 */
+	@ExceptionHandler(FSException.class)
+	public R handleRRException(FSException e){
+		R r = new R();
+		r.put("code", e.getCode());
+		r.put("msg", e.getMessage());
+
+		return r;
+	}
+
+	@ExceptionHandler(NoHandlerFoundException.class)
+	public R handlerNoFoundException(Exception e) {
+		logger.error(e.getMessage(), e);
+		return R.error(404, "路径不存在,请检查路径是否正确");
+	}
+
+	@ExceptionHandler(DuplicateKeyException.class)
+	public R handleDuplicateKeyException(DuplicateKeyException e){
+		logger.error(e.getMessage(), e);
+		return R.error("数据库中已存在该记录");
+	}
+
+
+	@ExceptionHandler(Exception.class)
+	public R handleException(Exception e){
+		logger.error(e.getMessage(), e);
+		return R.error();
+	}
+	@ExceptionHandler(AccessDeniedException.class)
+	public R handleAccessDeniedException(AccessDeniedException e){
+		logger.error(e.getMessage(), e);
+		return R.error("没有权限");
+	}
+
+	@ExceptionHandler(BindException.class)
+	public R bindExceptionHandler(BindException e) {
+		FieldError error = e.getFieldError();
+		String message = String.format("%s",  error.getDefaultMessage());
+		return R.error(message);
+	}
+
+	@ExceptionHandler(MethodArgumentNotValidException.class)
+	public R exceptionHandler(MethodArgumentNotValidException e) {
+		FieldError error = e.getBindingResult().getFieldError();
+		String message = String.format("%s",  error.getDefaultMessage());
+		return R.error(message);
+	}
+	@ExceptionHandler(CustomException.class)
+	public R handleException(CustomException e){
+
+		return R.error(e.getMessage());
+	}
+}

+ 90 - 0
fs-app-task-course/src/main/java/com/fs/app/task/AppCourseWatchLogScheduler.java

@@ -0,0 +1,90 @@
+package com.fs.app.task;
+
+import com.fs.app.course.service.IAppCourseLinkService;
+import com.fs.app.taskService.SopLogsTaskService;
+import com.fs.app.watchlog.service.IAppCourseWatchLogService;
+import com.fs.common.core.redis.RedisCache;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Calendar;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Component("AppCourseWatchLogScheduler")
+@Slf4j
+public class AppCourseWatchLogScheduler {
+    private final AtomicBoolean isRunning1 = new AtomicBoolean(false);
+    @Autowired
+    RedisCache redisCache;
+
+    @Autowired
+    private IAppCourseWatchLogService courseWatchLogService;
+
+    @Autowired
+    private SopLogsTaskService sopLogsTaskService;
+
+
+    @Autowired
+    private IAppCourseLinkService appCourseLinkService;
+
+
+    @Scheduled(fixedRate = 60000) // 每分钟执行一次
+    public void checkWatchStatus() {
+        // 尝试设置标志为 true,表示任务开始执行
+        if (!isRunning1.compareAndSet(false, true)) {
+            log.info("检查看课中任务执行 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
+
+        try {
+            log.info("检查看课中任务执行>>>>>>>>>>>>");
+            courseWatchLogService.scheduleBatchUpdateToDatabase();
+            courseWatchLogService.checkWatchStatus();
+            log.info("检查看课中任务执行完成>>>>>>>>>>>>");
+
+            // 检查当前时间是否为整五分钟(0, 5, 10, 15, ... 55分钟)
+            Calendar calendar = Calendar.getInstance();
+            int minute = calendar.get(Calendar.MINUTE);
+
+            // 只有当分钟数是5的倍数时才执行创建完课消息
+            if (minute % 5 == 0) {
+            try {
+                long startTime = System.currentTimeMillis();
+                log.info("创建完课消息 - 定时任务开始" + System.currentTimeMillis());
+                sopLogsTaskService.createCourseFinishMsg();
+                long endTime = System.currentTimeMillis();
+                long duration = endTime - startTime;
+                log.info("创建完课消息 - 定时任务成功完成" + duration);
+            } catch (Exception e) {
+                log.error("创建完课消息 - 定时任务执行失败", ExceptionUtils.getStackTrace(e));
+            }
+            }
+        } catch (Exception e) {
+            log.error("检查看课中任务执行完成 - 定时任务执行失败", ExceptionUtils.getStackTrace(e));
+        } finally {
+            // 重置标志为 false,表示任务已完成
+            isRunning1.set(false);
+        }
+    }
+
+    /**
+     * 每天删除过期短链
+     */
+    @Scheduled(cron = "0 5 0 * * ?")  // 0点5分0秒执行
+    public void delCourseExpireLink() {
+        try {
+            log.info("删除过期短链 - 定时任务开始");
+            appCourseLinkService.delCourseExpireLinkApp();
+//            courseLinkService.delCourseExpireLink();
+            log.info("删除过期短链 - 定时任务成功完成");
+        } catch (Exception e) {
+            log.error("删除过期短链 - 定时任务执行失败", e);
+        }
+
+    }
+
+}
+

+ 12 - 0
fs-app-task-course/src/main/java/com/fs/app/taskService/SopLogsTaskService.java

@@ -0,0 +1,12 @@
+package com.fs.app.taskService;
+
+import java.time.LocalDateTime;
+
+public interface SopLogsTaskService {
+
+    /**
+     * 创建完课消息
+     */
+    void createCourseFinishMsg();
+
+}

+ 309 - 0
fs-app-task-course/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -0,0 +1,309 @@
+package com.fs.app.taskService.impl;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.fs.app.course.domain.AppCourseFinishTemp;
+import com.fs.app.course.service.IAppCourseFinishTempService;
+import com.fs.app.sop.domain.AppSopLogs;
+import com.fs.app.sop.result.AppSopCourseFinishTempSetting;
+import com.fs.app.sop.service.IAppSopLogsService;
+import com.fs.app.taskService.SopLogsTaskService;
+import com.fs.app.watchlog.domain.AppCourseWatchLog;
+import com.fs.app.watchlog.mapper.AppCourseWatchLogMapper;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.*;
+
+@Service
+@Slf4j
+public class SopLogsTaskServiceImpl implements SopLogsTaskService {
+
+    @Autowired
+    private AppCourseWatchLogMapper fsCourseWatchLogMapper;
+
+    @Autowired
+    private IAppSopLogsService appSopLogsService;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Autowired
+    private IAppCourseFinishTempService appCourseFinishTempService;
+
+
+    @Value("${config.batch-size:500}")
+    private Integer batchSize;
+
+
+    @Override
+    public void createCourseFinishMsg() {
+        long startTime = System.currentTimeMillis();
+        log.info("创建完课消息 - 定时任务开始 {}", startTime);
+        // 线程池配置
+        int threadPoolSize = 4;
+        ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
+
+        // 用于收集所有处理结果的队列
+        BlockingQueue<List<AppCourseWatchLog>> batchQueue = new LinkedBlockingQueue<>();
+
+        try {
+            // 查询当天日期范围
+            LocalDate today = LocalDate.now();
+            Date startDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+            Date endDate = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+
+            // 启动生产者线程 - 流式分批查询数据
+            executorService.submit(() -> {
+                try {
+                    int batchSize = 1000;
+                    long maxId = 0;
+                    boolean hasMore = true;
+
+                    while (hasMore) {
+                        // 查询当前批次数据
+                        List<AppCourseWatchLog> batch = fsCourseWatchLogMapper.selectFsCourseWatchLogFinishBatchByDate(
+                                startDate, endDate, maxId, batchSize);
+
+                        if (!batch.isEmpty()) {
+                            // 将批次放入队列
+                            batchQueue.put(batch);
+                            // 更新maxId为当前批次的最后一个ID
+                            maxId = batch.get(batch.size() - 1).getLogId();
+                            log.debug("已生产批次数据,最后logId: {}, 数量: {}", maxId, batch.size());
+                        }
+
+                        if (batch.size() < batchSize) {
+                            hasMore = false;
+                            batchQueue.put(Collections.emptyList());// 结束标志
+                            log.info("数据生产完成,最后logId: {}", maxId);
+                        }
+                    }
+                } catch (Exception e) {
+                    log.error("生产数据时出错", e);
+                    try {
+                        batchQueue.put(Collections.emptyList()); // 确保消费者能退出
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            });
+
+            // 消费者线程处理数据
+            List<Future<?>> futures = new ArrayList<>();
+            for (int i = 0; i < threadPoolSize; i++) {
+                futures.add(executorService.submit(() -> {
+                    try {
+                        while (true) {
+                            List<AppCourseWatchLog> batch = batchQueue.take();
+
+                            // 空列表表示处理结束
+                            if (batch.isEmpty()) {
+                                batchQueue.put(Collections.emptyList()); // 传递给其他消费者
+                                break;
+                            }
+                            log.info("开始处理批次数据");
+                            processBatch(batch); // 处理批次数据
+                        }
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        log.error("处理数据时被中断", e);
+                    } catch (Exception e) {
+                        log.error("处理数据时出错", e);
+                    }
+                }));
+            }
+
+            // 等待所有任务完成
+            for (Future<?> future : futures) {
+                try {
+                    future.get();
+                } catch (InterruptedException | ExecutionException e) {
+                    log.error("等待任务完成时出错", e);
+                    Thread.currentThread().interrupt();
+                }
+            }
+
+            log.info("所有批次处理完成,总耗时: {}ms", System.currentTimeMillis() - startTime);
+
+        } finally {
+            executorService.shutdown();
+            try {
+                if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
+                    executorService.shutdownNow();
+                }
+            } catch (InterruptedException e) {
+                executorService.shutdownNow();
+                Thread.currentThread().interrupt();
+            }
+
+            batchQueue.clear();
+        }
+    }
+
+    // 处理单个批次的方法
+    private void processBatch(List<AppCourseWatchLog> batch) {
+        List<AppCourseWatchLog> finishLogsToUpdate = new ArrayList<>();
+        List<AppSopLogs> sopLogsToInsert = new ArrayList<>();
+        log.info("开始执行处理批次方法-数量:{}", batch.size());
+        Long batchId = IdUtil.getSnowflake(0, 0).nextId();
+        for (AppCourseWatchLog finishLog : batch) {
+            try {
+                FsUser fsUser = fsUserMapper.selectFsUserByUserId(finishLog.getUserId());
+                if (fsUser == null) {
+                    log.error("fsUser不存在: {}", finishLog.getUserId());
+                    continue;
+                }
+
+                // 设置 finishLog 为已发送状态,并加入批量更新列表
+                finishLog.setSendFinishMsg(1L);
+                finishLogsToUpdate.add(finishLog);
+                //完课模板
+                AppCourseFinishTemp finishTemp = this.appCourseFinishTempService.lambdaQuery()
+                        .eq(AppCourseFinishTemp::getCourseId, finishLog.getCourseId())
+                        .eq(AppCourseFinishTemp::getVideoId, finishLog.getVideoId())
+                        .eq(AppCourseFinishTemp::getStatus, 1)
+                        .orderByDesc(AppCourseFinishTemp::getCreateTime)
+                        .last(" LIMIT 1")
+                        .one();
+                if (ObjectUtil.isEmpty(finishTemp)) {
+                    log.error("完课模板不存在: 用户:{}, 课程:{},课节:{}", finishLog.getUserId(), finishLog.getCourseId(), finishLog.getVideoId());
+                    continue;
+                }
+                String meta;
+                //如果当前完课模板配置了客服(组)
+                if (ObjectUtil.isNotEmpty(meta = finishTemp.getAppCustomerIds())) {
+                    //看课记录绑定的也是客服组
+                    String target = "," + finishLog.getAppCustomerId() + ",";
+                    meta = "," + meta + ",";
+                    //当前看课记录的客服不再本次完课模板的客服中
+                    if (!meta.contains(target)) {
+                        log.error("当前看课记录对应的客服:{},不再当前完课模板(ID):{}配置的客服:{}中",
+                                finishLog.getAppCustomerId(), finishTemp.getId(), finishTemp.getAppCustomerIds());
+                        continue;
+                    }
+                }
+
+                // 构建 sopLogs 对象
+                AppSopLogs sopLogs = buildSopLogs(finishLog, finishTemp, batchId);
+                if (ObjectUtil.isEmpty(sopLogs)) {
+                    log.error("生成完课发送记录为空-:{}", finishLog.getUserId());
+                    continue;
+                }
+
+                // 如果客户状态有效,则加入批量插入列表
+                if (isValidUser(fsUser)) {
+                    sopLogsToInsert.add(sopLogs);
+                } else {
+                    log.info("完课消息-客户信息有误,不生成完课消息: {}", finishLog.getUserId());
+                }
+//                try {
+//                    fsUserCompanyBindService.finishApp(fsUser.getUserId(), finishLog);
+//                } catch (Exception e) {
+//                    log.error("更新重粉看课状态失败", e);
+//                }//todo 需新建表,优化全局重粉相关 //qw未处理,暂时不处理
+            } catch (Exception e) {
+                log.error("处理完课记录失败: {}", finishLog.getLogId(), e);
+            }
+        }
+
+        // 批量更新和插入
+        if (!finishLogsToUpdate.isEmpty()) {
+            try {
+                fsCourseWatchLogMapper.batchUpdateWatchLogSendMsg(finishLogsToUpdate);
+                log.info("批量更新 finishLog 成功,数量: {}", finishLogsToUpdate.size());
+            } catch (Exception e) {
+                log.error("批量更新 finishLog 失败", e);
+            }
+        }
+
+        if (!sopLogsToInsert.isEmpty()) {
+            try {
+                this.appSopLogsService.saveBatch(sopLogsToInsert, batchSize);
+//                appSopLogsServiceImpl.batchInsertQwSopLogs(sopLogsToInsert);
+//                qwSopLogsService.batchInsertQwSopLogs(sopLogsToInsert);
+                log.info("批量插入 sopLogs 成功,数量: {}", sopLogsToInsert.size());
+            } catch (Exception e) {
+                log.error("批量插入 sopLogs 失败", e);
+            }
+        }
+        log.info("结束处理批次方法-数量:{}", batch.size());
+    }
+
+    /**
+     * 构建 AppSopLogs 对象
+     */
+    private AppSopLogs buildSopLogs(AppCourseWatchLog finishLog, AppCourseFinishTemp finishTemp, Long batchId) {
+        // 解析模板设置
+        List<AppSopCourseFinishTempSetting.Setting> settings = JSONArray.parseArray(finishTemp.getSetting(), AppSopCourseFinishTempSetting.Setting.class);
+        if (settings == null) {
+            return null;
+        }
+        LocalDateTime currentTime = LocalDateTime.now();
+        LocalDateTime newTime = currentTime.plusMinutes(3);
+        Date newTimeDate = Date.from(newTime.atZone(ZoneId.systemDefault()).toInstant());
+        AppSopLogs sopLogs = new AppSopLogs();
+        sopLogs.setSendTime(newTimeDate);
+        sopLogs.setSendStatus(3);//原发送状态
+        sopLogs.setReceivingStatus(0);//接收状态
+        sopLogs.setAppSendStatus(0);//app专用发送状态
+        sopLogs.setIsHaveApp(1);
+        sopLogs.setSort(40000000);
+        sopLogs.setSopId(finishLog.getSopId());
+        sopLogs.setCreateTime(new Date());
+        sopLogs.setSopId(finishLog.getSopId());
+        sopLogs.setFsUserId(finishLog.getUserId());
+        sopLogs.setUserLogsId(finishLog.getSopPeriodId());
+        sopLogs.setSourceFrom(2);//来源:完课回复生成
+        sopLogs.setAppCustomerId(finishLog.getAppCustomerId());
+        AppSopCourseFinishTempSetting setting = new AppSopCourseFinishTempSetting();
+        setting.setVideoId(finishTemp.getVideoId());
+        setting.setCourseId(finishTemp.getCourseId());
+        setting.setType(2);//课程
+        setting.setCourseType(finishTemp.getCourseType());
+        setting.setSetting(settings);
+        sopLogs.setBatchId(batchId);
+        sopLogs.setContentJson(JSON.toJSONString(setting));
+        return sopLogs;
+    }
+
+
+    /**
+     * 解析模板设置
+     */
+    private static List<AppSopCourseFinishTempSetting.Setting> parseSettings(String jsonData) {
+        try {
+            if (jsonData.startsWith("[") && jsonData.endsWith("]")) {
+                return JSONArray.parseArray(jsonData, AppSopCourseFinishTempSetting.Setting.class);
+            } else {
+                String fixedJson = JSON.parseObject(jsonData, String.class);
+                return JSONArray.parseArray(fixedJson, AppSopCourseFinishTempSetting.Setting.class);
+            }
+        } catch (Exception e) {
+            log.error("解析模板设置失败", e);
+            return null;
+        }
+
+
+    }
+
+    /**
+     * 检查状态是否有效
+     */
+    private boolean isValidUser(FsUser fsUser) {
+        return fsUser.getStatus() == 1;
+    }
+}

+ 171 - 0
fs-app-task-course/src/main/java/com/fs/framework/aspectj/DataScopeAspect.java

@@ -0,0 +1,171 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataScope;
+import com.fs.common.core.domain.BaseEntity;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyRole;
+import com.fs.company.domain.CompanyUser;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+
+/**
+ * 数据过滤处理
+ *
+
+ */
+@Aspect
+@Component
+public class DataScopeAspect
+{
+    /**
+     * 全部数据权限
+     */
+    public static final String DATA_SCOPE_ALL = "1";
+
+    /**
+     * 自定数据权限
+     */
+    public static final String DATA_SCOPE_CUSTOM = "2";
+
+    /**
+     * 部门数据权限
+     */
+    public static final String DATA_SCOPE_DEPT = "3";
+
+    /**
+     * 部门及以下数据权限
+     */
+    public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";
+
+    /**
+     * 仅本人数据权限
+     */
+    public static final String DATA_SCOPE_SELF = "5";
+
+    /**
+     * 数据权限过滤关键字
+     */
+    public static final String DATA_SCOPE = "dataScope";
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.DataScope)")
+    public void dataScopePointCut()
+    {
+    }
+
+    @Before("dataScopePointCut()")
+    public void doBefore(JoinPoint point) throws Throwable
+    {
+        handleDataScope(point);
+    }
+
+    protected void handleDataScope(final JoinPoint joinPoint)
+    {
+        // 获得注解
+        DataScope controllerDataScope = getAnnotationLog(joinPoint);
+        if (controllerDataScope == null)
+        {
+            return;
+        }
+        // 获取当前的用户
+        LoginUser loginUser = SpringUtils.getBean(TokenService.class).getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNotNull(loginUser))
+        {
+            CompanyUser currentUser = loginUser.getUser();
+            // 如果是超级管理员,则不过滤数据
+            if (StringUtils.isNotNull(currentUser) && !currentUser.isAdmin())
+            {
+                dataScopeFilter(joinPoint, currentUser, controllerDataScope.deptAlias(),
+                        controllerDataScope.userAlias());
+            }
+        }
+    }
+
+    /**
+     * 数据范围过滤
+     *
+     * @param joinPoint 切点
+     * @param user 用户
+     * @param userAlias 别名
+     */
+    public static void dataScopeFilter(JoinPoint joinPoint, CompanyUser user, String deptAlias, String userAlias)
+    {
+        StringBuilder sqlString = new StringBuilder();
+
+        for (CompanyRole role : user.getRoles())
+        {
+            String dataScope = role.getDataScope();
+            if (DATA_SCOPE_ALL.equals(dataScope))
+            {
+                sqlString = new StringBuilder();
+                break;
+            }
+            else if (DATA_SCOPE_CUSTOM.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(
+                        " OR {}.dept_id IN ( SELECT dept_id FROM company_role_dept WHERE role_id = {} ) ", deptAlias,
+                        role.getRoleId()));
+            }
+            else if (DATA_SCOPE_DEPT.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
+            }
+            else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope))
+            {
+                sqlString.append(StringUtils.format(
+                        " OR {}.dept_id IN ( SELECT dept_id FROM company_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",
+                        deptAlias, user.getDeptId(), user.getDeptId()));
+            }
+            else if (DATA_SCOPE_SELF.equals(dataScope))
+            {
+                if (StringUtils.isNotBlank(userAlias))
+                {
+                    sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
+                }
+                else
+                {
+                    // 数据权限为仅本人且没有userAlias别名不查询任何数据
+                    //sqlString.append(" OR 1=0 ");
+                        sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
+                }
+            }
+        }
+
+        if (StringUtils.isNotBlank(sqlString.toString()))
+        {
+            Object params = joinPoint.getArgs()[0];
+            if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
+            {
+                BaseEntity baseEntity = (BaseEntity) params;
+                baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
+            }
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private DataScope getAnnotationLog(JoinPoint joinPoint)
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(DataScope.class);
+        }
+        return null;
+    }
+}

+ 73 - 0
fs-app-task-course/src/main/java/com/fs/framework/aspectj/DataSourceAspect.java

@@ -0,0 +1,73 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * 多数据源处理
+ *
+
+ */
+@Aspect
+@Order(1)
+@Component
+public class DataSourceAspect
+{
+    protected Logger logger = LoggerFactory.getLogger(getClass());
+
+    @Pointcut("@annotation(com.fs.common.annotation.DataSource)"
+            + "|| @within(com.fs.common.annotation.DataSource)")
+    public void dsPointCut()
+    {
+
+    }
+
+    @Around("dsPointCut()")
+    public Object around(ProceedingJoinPoint point) throws Throwable
+    {
+        DataSource dataSource = getDataSource(point);
+
+        if (StringUtils.isNotNull(dataSource))
+        {
+            DynamicDataSourceContextHolder.setDataSourceType(dataSource.value().name());
+        }
+
+        try
+        {
+            return point.proceed();
+        }
+        finally
+        {
+            // 销毁数据源 在执行方法之后
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取需要切换的数据源
+     */
+    public DataSource getDataSource(ProceedingJoinPoint point)
+    {
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        DataSource dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
+        if (Objects.nonNull(dataSource))
+        {
+            return dataSource;
+        }
+
+        return AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
+    }
+}

+ 219 - 0
fs-app-task-course/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -0,0 +1,219 @@
+package com.fs.framework.aspectj;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.annotation.Log;
+import com.fs.common.enums.BusinessStatus;
+import com.fs.common.enums.HttpMethod;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyOperLog;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.util.Map;
+
+/**
+ * 操作日志记录处理
+ *
+
+ */
+@Aspect
+@Component
+public class LogAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.Log)")
+    public void logPointCut()
+    {
+    }
+
+    /**
+     * 处理完请求后执行
+     *
+     * @param joinPoint 切点
+     */
+    @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
+    public void doAfterReturning(JoinPoint joinPoint, Object jsonResult)
+    {
+        handleLog(joinPoint, null, jsonResult);
+    }
+
+    /**
+     * 拦截异常操作
+     *
+     * @param joinPoint 切点
+     * @param e 异常
+     */
+    @AfterThrowing(value = "logPointCut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e)
+    {
+        handleLog(joinPoint, e, null);
+    }
+
+    protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult)
+    {
+        try
+        {
+            // 获得注解
+            Log controllerLog = getAnnotationLog(joinPoint);
+            if (controllerLog == null)
+            {
+                return;
+            }
+
+            // 获取当前的用户
+            LoginUser loginUser = SpringUtils.getBean(TokenService.class).getLoginUser(ServletUtils.getRequest());
+
+            // *========数据库日志=========*//
+            CompanyOperLog operLog = new CompanyOperLog();
+            operLog.setCompanyId(loginUser.getCompany().getCompanyId());
+            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
+            // 请求的地址
+            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+            operLog.setOperIp(ip);
+            // 返回参数
+            operLog.setJsonResult(JSON.toJSONString(jsonResult));
+
+            operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
+            if (loginUser != null)
+            {
+                operLog.setOperName(loginUser.getUsername());
+            }
+
+            if (e != null)
+            {
+                operLog.setStatus(BusinessStatus.FAIL.ordinal());
+                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
+            }
+            // 设置方法名称
+            String className = joinPoint.getTarget().getClass().getName();
+            String methodName = joinPoint.getSignature().getName();
+            operLog.setMethod(className + "." + methodName + "()");
+            // 设置请求方式
+            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
+            // 处理设置注解上的参数
+            getControllerMethodDescription(joinPoint, controllerLog, operLog);
+            // 保存数据库
+            AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
+        }
+        catch (Exception exp)
+        {
+            // 记录本地异常日志
+            log.error("==前置通知异常==");
+            log.error("异常信息:{}", exp.getMessage());
+            exp.printStackTrace();
+        }
+    }
+
+    /**
+     * 获取注解中对方法的描述信息 用于Controller层注解
+     *
+     * @param log 日志
+     * @param operLog 操作日志
+     * @throws Exception
+     */
+    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, CompanyOperLog operLog) throws Exception
+    {
+        // 设置action动作
+        operLog.setBusinessType(log.businessType().ordinal());
+        // 设置标题
+        operLog.setTitle(log.title());
+        // 设置操作人类别
+        operLog.setOperatorType(log.operatorType().ordinal());
+        // 是否需要保存request,参数和值
+        if (log.isSaveRequestData())
+        {
+            // 获取参数的信息,传入到数据库中。
+            setRequestValue(joinPoint, operLog);
+        }
+    }
+
+    /**
+     * 获取请求的参数,放到log中
+     *
+     * @param operLog 操作日志
+     * @throws Exception 异常
+     */
+    private void setRequestValue(JoinPoint joinPoint, CompanyOperLog operLog) throws Exception
+    {
+        String requestMethod = operLog.getRequestMethod();
+        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))
+        {
+            String params = argsArrayToString(joinPoint.getArgs());
+            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
+        }
+        else
+        {
+            Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
+            operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private Log getAnnotationLog(JoinPoint joinPoint) throws Exception
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(Log.class);
+        }
+        return null;
+    }
+
+    /**
+     * 参数拼装
+     */
+    private String argsArrayToString(Object[] paramsArray)
+    {
+        String params = "";
+        if (paramsArray != null && paramsArray.length > 0)
+        {
+            for (int i = 0; i < paramsArray.length; i++)
+            {
+                if (!isFilterObject(paramsArray[i]))
+                {
+                    Object jsonObj = JSON.toJSON(paramsArray[i]);
+                    params += jsonObj.toString() + " ";
+                }
+            }
+        }
+        return params.trim();
+    }
+
+    /**
+     * 判断是否需要过滤的对象。
+     *
+     * @param o 对象信息。
+     * @return 如果是需要过滤的对象,则返回true;否则返回false。
+     */
+    public boolean isFilterObject(final Object o)
+    {
+        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse;
+    }
+}

+ 117 - 0
fs-app-task-course/src/main/java/com/fs/framework/aspectj/RateLimiterAspect.java

@@ -0,0 +1,117 @@
+package com.fs.framework.aspectj;
+
+import com.fs.common.annotation.RateLimiter;
+import com.fs.common.enums.LimitType;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.Signature;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 限流处理
+ *
+
+ */
+@Aspect
+@Component
+public class RateLimiterAspect
+{
+    private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);
+
+    private RedisTemplate<Object, Object> redisTemplate;
+
+    private RedisScript<Long> limitScript;
+
+    @Autowired
+    public void setRedisTemplate1(RedisTemplate<Object, Object> redisTemplate)
+    {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Autowired
+    public void setLimitScript(RedisScript<Long> limitScript)
+    {
+        this.limitScript = limitScript;
+    }
+
+    // 配置织入点
+    @Pointcut("@annotation(com.fs.common.annotation.RateLimiter)")
+    public void rateLimiterPointCut()
+    {
+    }
+
+    @Before("rateLimiterPointCut()")
+    public void doBefore(JoinPoint point) throws Throwable
+    {
+        RateLimiter rateLimiter = getAnnotationRateLimiter(point);
+        String key = rateLimiter.key();
+        int time = rateLimiter.time();
+        int count = rateLimiter.count();
+
+        String combineKey = getCombineKey(rateLimiter, point);
+        List<Object> keys = Collections.singletonList(combineKey);
+        try
+        {
+            Long number = redisTemplate.execute(limitScript, keys, count, time);
+            if (StringUtils.isNull(number) || number.intValue() > count)
+            {
+                throw new ServiceException("访问过于频繁,请稍后再试");
+            }
+            log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), key);
+        }
+        catch (ServiceException e)
+        {
+            throw e;
+        }
+        catch (Exception e)
+        {
+            throw new RuntimeException("服务器限流异常,请稍后再试");
+        }
+    }
+
+    /**
+     * 是否存在注解,如果存在就获取
+     */
+    private RateLimiter getAnnotationRateLimiter(JoinPoint joinPoint)
+    {
+        Signature signature = joinPoint.getSignature();
+        MethodSignature methodSignature = (MethodSignature) signature;
+        Method method = methodSignature.getMethod();
+
+        if (method != null)
+        {
+            return method.getAnnotation(RateLimiter.class);
+        }
+        return null;
+    }
+
+    public String getCombineKey(RateLimiter rateLimiter, JoinPoint point)
+    {
+        StringBuffer stringBuffer = new StringBuffer(rateLimiter.key());
+        if (rateLimiter.limitType() == LimitType.IP)
+        {
+            stringBuffer.append(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        }
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        Method method = signature.getMethod();
+        Class<?> targetClass = method.getDeclaringClass();
+        stringBuffer.append("-").append(targetClass.getName()).append("- ").append(method.getName());
+        return stringBuffer.toString();
+    }
+}

+ 31 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/ApplicationConfig.java

@@ -0,0 +1,31 @@
+package com.fs.framework.config;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+
+import java.util.TimeZone;
+
+/**
+ * 程序注解配置
+ *
+
+ */
+@Configuration
+// 表示通过aop框架暴露该代理对象,AopContext能够访问
+@EnableAspectJAutoProxy(exposeProxy = true)
+// 指定要扫描的Mapper类的包的路径
+@MapperScan("com.fs.**.mapper")
+public class ApplicationConfig
+{
+    /**
+     * 时区配置
+     */
+    @Bean
+    public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization()
+    {
+        return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault());
+    }
+}

+ 58 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/ArrayStringTypeHandler.java

@@ -0,0 +1,58 @@
+package com.fs.framework.config;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.springframework.context.annotation.Configuration;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+
+@Configuration
+public class ArrayStringTypeHandler extends BaseTypeHandler<List<String>> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
+        // 将 List<String> 转换为字符串,ClickHouse 支持的格式为 "['item1', 'item2']"
+        StringBuilder sb = new StringBuilder();
+        sb.append("[");
+        for (int j = 0; j < parameter.size(); j++) {
+            sb.append("'").append(parameter.get(j)).append("'");
+            if (j < parameter.size() - 1) {
+                sb.append(",");
+            }
+        }
+        sb.append("]");
+        ps.setString(i, sb.toString());
+    }
+
+    @Override
+    public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        // 处理查询结果,将其转换为 List<String>
+        String result = rs.getString(columnName);
+        return parseArray(result);
+    }
+
+    @Override
+    public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String result = rs.getString(columnIndex);
+        return parseArray(result);
+    }
+
+    @Override
+    public List<String> getNullableResult(java.sql.CallableStatement cs, int columnIndex) throws SQLException {
+        String result = cs.getString(columnIndex);
+        return parseArray(result);
+    }
+
+    private List<String> parseArray(String arrayStr) {
+        // 将 ClickHouse 的 Array 字符串转换为 List<String>
+        if (arrayStr == null || arrayStr.isEmpty()) {
+            return null;
+        }
+        arrayStr = arrayStr.substring(1, arrayStr.length() - 1);  // 去掉 "[" 和 "]"
+        String[] elements = arrayStr.split(",");
+        return java.util.Arrays.asList(elements);
+    }
+}

+ 85 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/CaptchaConfig.java

@@ -0,0 +1,85 @@
+package com.fs.framework.config;
+
+import com.google.code.kaptcha.impl.DefaultKaptcha;
+import com.google.code.kaptcha.util.Config;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Properties;
+
+import static com.google.code.kaptcha.Constants.*;
+
+/**
+ * 验证码配置
+ *
+
+ */
+@Configuration
+public class CaptchaConfig
+{
+    @Bean(name = "captchaProducer")
+    public DefaultKaptcha getKaptchaBean()
+    {
+        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
+        Properties properties = new Properties();
+        // 是否有边框 默认为true 我们可以自己设置yes,no
+        properties.setProperty(KAPTCHA_BORDER, "yes");
+        // 验证码文本字符颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, "black");
+        // 验证码图片宽度 默认为200
+        properties.setProperty(KAPTCHA_IMAGE_WIDTH, "160");
+        // 验证码图片高度 默认为50
+        properties.setProperty(KAPTCHA_IMAGE_HEIGHT, "60");
+        // 验证码文本字符大小 默认为40
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, "38");
+        // KAPTCHA_SESSION_KEY
+        properties.setProperty(KAPTCHA_SESSION_CONFIG_KEY, "kaptchaCode");
+        // 验证码文本字符长度 默认为5
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
+        // 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Arial,Courier");
+        // 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy 阴影com.google.code.kaptcha.impl.ShadowGimpy
+        properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.ShadowGimpy");
+        Config config = new Config(properties);
+        defaultKaptcha.setConfig(config);
+        return defaultKaptcha;
+    }
+
+    @Bean(name = "captchaProducerMath")
+    public DefaultKaptcha getKaptchaBeanMath()
+    {
+        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
+        Properties properties = new Properties();
+        // 是否有边框 默认为true 我们可以自己设置yes,no
+        properties.setProperty(KAPTCHA_BORDER, "yes");
+        // 边框颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_BORDER_COLOR, "105,179,90");
+        // 验证码文本字符颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, "blue");
+        // 验证码图片宽度 默认为200
+        properties.setProperty(KAPTCHA_IMAGE_WIDTH, "160");
+        // 验证码图片高度 默认为50
+        properties.setProperty(KAPTCHA_IMAGE_HEIGHT, "60");
+        // 验证码文本字符大小 默认为40
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, "35");
+        // KAPTCHA_SESSION_KEY
+        properties.setProperty(KAPTCHA_SESSION_CONFIG_KEY, "kaptchaCodeMath");
+        // 验证码文本生成器
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_IMPL, "com.fs.framework.config.KaptchaTextCreator");
+        // 验证码文本字符间距 默认为2
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_SPACE, "3");
+        // 验证码文本字符长度 默认为5
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "6");
+        // 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
+        properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Arial,Courier");
+        // 验证码噪点颜色 默认为Color.BLACK
+        properties.setProperty(KAPTCHA_NOISE_COLOR, "white");
+        // 干扰实现类
+        properties.setProperty(KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
+        // 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy 阴影com.google.code.kaptcha.impl.ShadowGimpy
+        properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.ShadowGimpy");
+        Config config = new Config(properties);
+        defaultKaptcha.setConfig(config);
+        return defaultKaptcha;
+    }
+}

+ 119 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -0,0 +1,119 @@
+package com.fs.framework.config;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+import com.alibaba.druid.util.Utils;
+import com.fs.common.enums.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+import javax.servlet.*;
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class DataSourceConfig {
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.master")
+    public DataSource sopDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.sop.druid.read")
+    public DataSource sopReadDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.clickhouse")
+    public DataSource clickhouseDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.master")
+    public DataSource masterDataSource() {
+        return new DruidDataSource();
+    }
+
+    @Bean
+    @ConfigurationProperties(prefix = "spring.datasource.mysql.druid.slave")
+    public DataSource slaveDataSource() {
+        return new DruidDataSource();
+    }
+
+
+
+    @Bean
+    @Primary
+    public DynamicDataSource dataSource(@Qualifier("clickhouseDataSource") DataSource clickhouseDataSource,
+                                        @Qualifier("masterDataSource") DataSource masterDataSource,
+                                        @Qualifier("sopDataSource") DataSource sopDataSource,
+                                        @Qualifier("slaveDataSource") DataSource slaveDataSource,
+                                        @Qualifier("sopReadDataSource") DataSource sopReadDataSource
+                                        ) {
+        Map<Object, Object> targetDataSources = new HashMap<>();
+        targetDataSources.put(DataSourceType.MASTER, masterDataSource);
+
+        targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
+        targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
+        targetDataSources.put(DataSourceType.SopREAD.name(), sopReadDataSource);
+        targetDataSources.put(DataSourceType.CLICKHOUSE.name(), clickhouseDataSource); // Ensure matching key
+        return new DynamicDataSource(masterDataSource, targetDataSources);
+    }
+
+    /**
+     * 去除监控页面底部的广告
+     */
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    @ConditionalOnProperty(name = "spring.datasource.druid.statViewServlet.enabled", havingValue = "true")
+    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties)
+    {
+        // 获取web监控页面的参数
+        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
+        // 提取common.js的配置路径
+        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
+        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
+        final String filePath = "support/http/resources/js/common.js";
+        // 创建filter进行过滤
+        Filter filter = new Filter()
+        {
+            @Override
+            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException
+            {
+            }
+            @Override
+            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+                    throws IOException, ServletException
+            {
+                chain.doFilter(request, response);
+                // 重置缓冲区,响应头不会被重置
+                response.resetBuffer();
+                // 获取common.js
+                String text = Utils.readFromResource(filePath);
+                // 正则替换banner, 除去底部的广告信息
+                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
+                text = text.replaceAll("powered.*?shrek.wang</a>", "");
+                response.getWriter().write(text);
+            }
+            @Override
+            public void destroy()
+            {
+            }
+        };
+        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
+        registrationBean.setFilter(filter);
+        registrationBean.addUrlPatterns(commonJsPattern);
+        return registrationBean;
+    }
+}

+ 123 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/DruidConfig.java

@@ -0,0 +1,123 @@
+package com.fs.framework.config;//package com.fs.framework.config;
+//
+//import com.alibaba.druid.pool.DruidDataSource;
+//import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
+//import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
+//import com.alibaba.druid.util.Utils;
+//import com.fs.framework.datasource.DynamicDataSource;
+//import com.fs.common.enums.DataSourceType;
+//import com.fs.common.utils.spring.SpringUtils;
+//import com.fs.framework.config.properties.DruidProperties;
+//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+//import org.springframework.boot.context.properties.ConfigurationProperties;
+//import org.springframework.boot.web.servlet.FilterRegistrationBean;
+//import org.springframework.context.annotation.Bean;
+//import org.springframework.context.annotation.Configuration;
+//import org.springframework.context.annotation.Primary;
+//
+//import javax.servlet.*;
+//import javax.sql.DataSource;
+//import java.io.IOException;
+//import java.util.HashMap;
+//import java.util.Map;
+//
+///**
+// * druid 配置多数据源
+// *
+//
+// */
+//@Configuration
+//public class DruidConfig
+//{
+//    @Bean
+//    @ConfigurationProperties("spring.datasource.druid.master")
+//    public DataSource masterDataSource(DruidProperties druidProperties)
+//    {
+//        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
+//        return druidProperties.dataSource(dataSource);
+//    }
+//
+//    @Bean
+//    @ConfigurationProperties("spring.datasource.druid.slave")
+//    @ConditionalOnProperty(prefix = "spring.datasource.druid.slave", name = "enabled", havingValue = "true")
+//    public DataSource slaveDataSource(DruidProperties druidProperties)
+//    {
+//        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
+//        return druidProperties.dataSource(dataSource);
+//    }
+//
+//    @Bean(name = "dynamicDataSource")
+//    @Primary
+//    public DynamicDataSource dataSource(DataSource masterDataSource)
+//    {
+//        Map<Object, Object> targetDataSources = new HashMap<>();
+//        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
+//        setDataSource(targetDataSources, DataSourceType.SLAVE.name(), "slaveDataSource");
+//        return new DynamicDataSource(masterDataSource, targetDataSources);
+//    }
+//
+//    /**
+//     * 设置数据源
+//     *
+//     * @param targetDataSources 备选数据源集合
+//     * @param sourceName 数据源名称
+//     * @param beanName bean名称
+//     */
+//    public void setDataSource(Map<Object, Object> targetDataSources, String sourceName, String beanName)
+//    {
+//        try
+//        {
+//            DataSource dataSource = SpringUtils.getBean(beanName);
+//            targetDataSources.put(sourceName, dataSource);
+//        }
+//        catch (Exception e)
+//        {
+//        }
+//    }
+//
+//    /**
+//     * 去除监控页面底部的广告
+//     */
+//    @SuppressWarnings({ "rawtypes", "unchecked" })
+//    @Bean
+//    @ConditionalOnProperty(name = "spring.datasource.druid.statViewServlet.enabled", havingValue = "true")
+//    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties)
+//    {
+//        // 获取web监控页面的参数
+//        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
+//        // 提取common.js的配置路径
+//        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
+//        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
+//        final String filePath = "support/http/resources/js/common.js";
+//        // 创建filter进行过滤
+//        Filter filter = new Filter()
+//        {
+//            @Override
+//            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException
+//            {
+//            }
+//            @Override
+//            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+//                    throws IOException, ServletException
+//            {
+//                chain.doFilter(request, response);
+//                // 重置缓冲区,响应头不会被重置
+//                response.resetBuffer();
+//                // 获取common.js
+//                String text = Utils.readFromResource(filePath);
+//                // 正则替换banner, 除去底部的广告信息
+//                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
+//                text = text.replaceAll("powered.*?shrek.wang</a>", "");
+//                response.getWriter().write(text);
+//            }
+//            @Override
+//            public void destroy()
+//            {
+//            }
+//        };
+//        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
+//        registrationBean.setFilter(filter);
+//        registrationBean.addUrlPatterns(commonJsPattern);
+//        return registrationBean;
+//    }
+//}

+ 72 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/FastJson2JsonRedisSerializer.java

@@ -0,0 +1,72 @@
+package com.fs.framework.config;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.ParserConfig;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.data.redis.serializer.SerializationException;
+import org.springframework.util.Assert;
+
+import java.nio.charset.Charset;
+
+/**
+ * Redis使用FastJson序列化
+ *
+
+ */
+public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
+{
+    @SuppressWarnings("unused")
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
+
+    private Class<T> clazz;
+
+    static
+    {
+        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+    }
+
+    public FastJson2JsonRedisSerializer(Class<T> clazz)
+    {
+        super();
+        this.clazz = clazz;
+    }
+
+    @Override
+    public byte[] serialize(T t) throws SerializationException
+    {
+        if (t == null)
+        {
+            return new byte[0];
+        }
+        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
+    }
+
+    @Override
+    public T deserialize(byte[] bytes) throws SerializationException
+    {
+        if (bytes == null || bytes.length <= 0)
+        {
+            return null;
+        }
+        String str = new String(bytes, DEFAULT_CHARSET);
+
+        return JSON.parseObject(str, clazz);
+    }
+
+    public void setObjectMapper(ObjectMapper objectMapper)
+    {
+        Assert.notNull(objectMapper, "'objectMapper' must not be null");
+        this.objectMapper = objectMapper;
+    }
+
+    protected JavaType getJavaType(Class<?> clazz)
+    {
+        return TypeFactory.defaultInstance().constructType(clazz);
+    }
+}

+ 59 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -0,0 +1,59 @@
+package com.fs.framework.config;
+
+import com.fs.common.filter.RepeatableFilter;
+import com.fs.common.filter.XssFilter;
+import com.fs.common.utils.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.servlet.DispatcherType;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Filter配置
+ *
+
+ */
+@Configuration
+@ConditionalOnProperty(value = "xss.enabled", havingValue = "true")
+public class FilterConfig
+{
+    @Value("${xss.excludes}")
+    private String excludes;
+
+    @Value("${xss.urlPatterns}")
+    private String urlPatterns;
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    public FilterRegistrationBean xssFilterRegistration()
+    {
+        FilterRegistrationBean registration = new FilterRegistrationBean();
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setFilter(new XssFilter());
+        registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
+        registration.setName("xssFilter");
+        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        Map<String, String> initParameters = new HashMap<String, String>();
+        initParameters.put("excludes", excludes);
+        registration.setInitParameters(initParameters);
+        return registration;
+    }
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    @Bean
+    public FilterRegistrationBean someFilterRegistration()
+    {
+        FilterRegistrationBean registration = new FilterRegistrationBean();
+        registration.setFilter(new RepeatableFilter());
+        registration.addUrlPatterns("/*");
+        registration.setName("repeatableFilter");
+        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        return registration;
+    }
+
+}

+ 76 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/KaptchaTextCreator.java

@@ -0,0 +1,76 @@
+package com.fs.framework.config;
+
+import com.google.code.kaptcha.text.impl.DefaultTextCreator;
+
+import java.util.Random;
+
+/**
+ * 验证码文本生成器
+ *
+
+ */
+public class KaptchaTextCreator extends DefaultTextCreator
+{
+    private static final String[] CNUMBERS = "0,1,2,3,4,5,6,7,8,9,10".split(",");
+
+    @Override
+    public String getText()
+    {
+        Integer result = 0;
+        Random random = new Random();
+        int x = random.nextInt(10);
+        int y = random.nextInt(10);
+        StringBuilder suChinese = new StringBuilder();
+        int randomoperands = (int) Math.round(Math.random() * 2);
+        if (randomoperands == 0)
+        {
+            result = x * y;
+            suChinese.append(CNUMBERS[x]);
+            suChinese.append("*");
+            suChinese.append(CNUMBERS[y]);
+        }
+        else if (randomoperands == 1)
+        {
+            if (!(x == 0) && y % x == 0)
+            {
+                result = y / x;
+                suChinese.append(CNUMBERS[y]);
+                suChinese.append("/");
+                suChinese.append(CNUMBERS[x]);
+            }
+            else
+            {
+                result = x + y;
+                suChinese.append(CNUMBERS[x]);
+                suChinese.append("+");
+                suChinese.append(CNUMBERS[y]);
+            }
+        }
+        else if (randomoperands == 2)
+        {
+            if (x >= y)
+            {
+                result = x - y;
+                suChinese.append(CNUMBERS[x]);
+                suChinese.append("-");
+                suChinese.append(CNUMBERS[y]);
+            }
+            else
+            {
+                result = y - x;
+                suChinese.append(CNUMBERS[y]);
+                suChinese.append("-");
+                suChinese.append(CNUMBERS[x]);
+            }
+        }
+        else
+        {
+            result = x + y;
+            suChinese.append(CNUMBERS[x]);
+            suChinese.append("+");
+            suChinese.append(CNUMBERS[y]);
+        }
+        suChinese.append("=?@" + result);
+        return suChinese.toString();
+    }
+}

+ 150 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/MyBatisConfig.java

@@ -0,0 +1,150 @@
+package com.fs.framework.config;
+
+import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
+import com.fs.common.utils.StringUtils;
+import org.apache.ibatis.io.VFS;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.mybatis.spring.boot.autoconfigure.SpringBootVFS;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReader;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.util.ClassUtils;
+
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Mybatis支持*匹配扫描包
+ *
+
+ */
+@Configuration
+public class MyBatisConfig
+{
+    @Autowired
+    private Environment env;
+
+    static final String DEFAULT_RESOURCE_PATTERN = "**/*.class";
+
+    public static String setTypeAliasesPackage(String typeAliasesPackage)
+    {
+        ResourcePatternResolver resolver = (ResourcePatternResolver) new PathMatchingResourcePatternResolver();
+        MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+        List<String> allResult = new ArrayList<String>();
+        try
+        {
+            for (String aliasesPackage : typeAliasesPackage.split(","))
+            {
+                List<String> result = new ArrayList<String>();
+                aliasesPackage = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX
+                        + ClassUtils.convertClassNameToResourcePath(aliasesPackage.trim()) + "/" + DEFAULT_RESOURCE_PATTERN;
+                Resource[] resources = resolver.getResources(aliasesPackage);
+                if (resources != null && resources.length > 0)
+                {
+                    MetadataReader metadataReader = null;
+                    for (Resource resource : resources)
+                    {
+                        if (resource.isReadable())
+                        {
+                            metadataReader = metadataReaderFactory.getMetadataReader(resource);
+                            try
+                            {
+                                result.add(Class.forName(metadataReader.getClassMetadata().getClassName()).getPackage().getName());
+                            }
+                            catch (ClassNotFoundException e)
+                            {
+                                e.printStackTrace();
+                            }
+                        }
+                    }
+                }
+                if (result.size() > 0)
+                {
+                    HashSet<String> hashResult = new HashSet<String>(result);
+                    allResult.addAll(hashResult);
+                }
+            }
+            if (allResult.size() > 0)
+            {
+                typeAliasesPackage = String.join(",", (String[]) allResult.toArray(new String[0]));
+            }
+            else
+            {
+                throw new RuntimeException("mybatis typeAliasesPackage 路径扫描错误,参数typeAliasesPackage:" + typeAliasesPackage + "未找到任何包");
+            }
+        }
+        catch (IOException e)
+        {
+            e.printStackTrace();
+        }
+        return typeAliasesPackage;
+    }
+
+    public Resource[] resolveMapperLocations(String[] mapperLocations)
+    {
+        ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
+        List<Resource> resources = new ArrayList<Resource>();
+        if (mapperLocations != null)
+        {
+            for (String mapperLocation : mapperLocations)
+            {
+                try
+                {
+                    Resource[] mappers = resourceResolver.getResources(mapperLocation);
+                    resources.addAll(Arrays.asList(mappers));
+                }
+                catch (IOException e)
+                {
+                    // ignore
+                }
+            }
+        }
+        return resources.toArray(new Resource[resources.size()]);
+    }
+
+//    @Bean
+//    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception
+//    {
+//        String typeAliasesPackage = env.getProperty("mybatis.typeAliasesPackage");
+//        String mapperLocations = env.getProperty("mybatis.mapperLocations");
+//        String configLocation = env.getProperty("mybatis.configLocation");
+//        typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage);
+//        VFS.addImplClass(SpringBootVFS.class);
+//
+//        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
+//        sessionFactory.setDataSource(dataSource);
+//        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
+//        sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ",")));
+//        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
+//        return sessionFactory.getObject();
+//    }
+    @Bean
+    public SqlSessionFactory sqlSessionFactorys(DataSource dataSource) throws Exception
+    {
+        String typeAliasesPackage = env.getProperty("mybatis-plus.typeAliasesPackage");
+        String mapperLocations = env.getProperty("mybatis-plus.mapperLocations");
+        String configLocation = env.getProperty("mybatis-plus.configLocation");
+        typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage);
+        VFS.addImplClass(SpringBootVFS.class);
+
+        final MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
+        sessionFactory.setDataSource(dataSource);
+        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
+        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
+        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
+        return sessionFactory.getObject();
+    }
+}

+ 153 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/RedisConfig.java

@@ -0,0 +1,153 @@
+package com.fs.framework.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+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.annotation.CachingConfigurerSupport;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.data.redis.serializer.GenericToStringSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.math.BigDecimal;
+
+/**
+ * redis配置
+ *
+
+ */
+@Configuration
+@EnableCaching
+public class RedisConfig extends CachingConfigurerSupport
+{
+    @Bean
+    @SuppressWarnings(value = { "unchecked", "rawtypes" })
+    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
+    {
+        RedisTemplate<Object, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
+
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
+        serializer.setObjectMapper(mapper);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(serializer);
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(serializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+    @Bean
+    public RedisTemplate<String, Boolean> redisTemplateForBoolean(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Boolean> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(new GenericToStringSerializer<>(Boolean.class));
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(Boolean.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean(name = "redisTemplateForInteger")
+    public RedisTemplate<String, Integer> redisTemplateForInteger(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Integer> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
+
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(Integer.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean(name = "redisTemplateForBigDecimal")
+    public RedisTemplate<String, BigDecimal> redisTemplateForBigDecimal(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, BigDecimal> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(new GenericToStringSerializer<>(BigDecimal.class));
+
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(new GenericToStringSerializer<>(BigDecimal.class));
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    @SuppressWarnings(value = { "unchecked", "rawtypes" })
+    public RedisTemplate<String, Object> redisTemplateForObject(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
+
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
+        serializer.setObjectMapper(mapper);
+
+        // 使用StringRedisSerializer来序列化和反序列化redis的key值
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(serializer);
+
+        // Hash的key也采用StringRedisSerializer的序列化方式
+        template.setHashKeySerializer(new StringRedisSerializer());
+        template.setHashValueSerializer(serializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    public DefaultRedisScript<Long> limitScript()
+    {
+        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
+        redisScript.setScriptText(limitScriptText());
+        redisScript.setResultType(Long.class);
+        return redisScript;
+    }
+
+    /**
+     * 限流脚本
+     */
+    private String limitScriptText()
+    {
+        return "local key = KEYS[1]\n" +
+                "local count = tonumber(ARGV[1])\n" +
+                "local time = tonumber(ARGV[2])\n" +
+                "local current = redis.call('get', key);\n" +
+                "if current and tonumber(current) > count then\n" +
+                "    return current;\n" +
+                "end\n" +
+                "current = redis.call('incr', key)\n" +
+                "if tonumber(current) == 1 then\n" +
+                "    redis.call('expire', key, time)\n" +
+                "end\n" +
+                "return current;";
+    }
+}

+ 76 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -0,0 +1,76 @@
+package com.fs.framework.config;
+
+import com.fs.common.config.FSConfig;
+import com.fs.common.constant.Constants;
+import com.fs.framework.interceptor.RepeatSubmitInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.format.FormatterRegistry;
+import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 通用配置
+ *
+
+ */
+@Configuration
+public class ResourcesConfig implements WebMvcConfigurer
+{
+    @Autowired
+    private RepeatSubmitInterceptor repeatSubmitInterceptor;
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry)
+    {
+        /** 本地文件上传路径 */
+        registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**").addResourceLocations("file:" + FSConfig.getProfile() + "/");
+
+        /** swagger配置 */
+        registry.addResourceHandler("/swagger-ui/**").addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/");
+    }
+
+    /**
+     * 自定义拦截规则
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry)
+    {
+        registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
+    }
+
+    /**
+     * 跨域配置
+     */
+    @Bean
+    public CorsFilter corsFilter()
+    {
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        CorsConfiguration config = new CorsConfiguration();
+        config.setAllowCredentials(true);
+        // 设置访问源地址
+        config.addAllowedOrigin("*");
+        // 设置访问源请求头
+        config.addAllowedHeader("*");
+        // 设置访问源请求方法
+        config.addAllowedMethod("*");
+        // 对接口配置跨域设置
+        source.registerCorsConfiguration("/**", config);
+        return new CorsFilter(source);
+    }
+
+    @Override
+    public void addFormatters(FormatterRegistry registry) {
+        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
+        registrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 统一日期格式
+        registrar.registerFormatters(registry);
+    }
+}

+ 11 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/RetryConfig.java

@@ -0,0 +1,11 @@
+package com.fs.framework.config;
+
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.retry.annotation.EnableRetry;
+
+@Configuration
+@EnableRetry
+public class RetryConfig {
+
+}

+ 158 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,158 @@
+package com.fs.framework.config;
+
+
+import com.fs.framework.security.filter.JwtAuthenticationTokenFilter;
+import com.fs.framework.security.handle.AuthenticationEntryPointImpl;
+import com.fs.framework.security.handle.LogoutSuccessHandlerImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.authentication.logout.LogoutFilter;
+import org.springframework.web.filter.CorsFilter;
+
+/**
+ * spring security配置
+ *
+
+ */
+@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
+public class SecurityConfig extends WebSecurityConfigurerAdapter
+{
+    /**
+     * 自定义用户认证逻辑
+     */
+    @Autowired
+    private UserDetailsService userDetailsService;
+
+    /**
+     * 认证失败处理类
+     */
+    @Autowired
+    private AuthenticationEntryPointImpl unauthorizedHandler;
+
+    /**
+     * 退出处理类
+     */
+    @Autowired
+    private LogoutSuccessHandlerImpl logoutSuccessHandler;
+
+    /**
+     * token认证过滤器
+     */
+    @Autowired
+    private JwtAuthenticationTokenFilter authenticationTokenFilter;
+
+    /**
+     * 跨域过滤器
+     */
+    @Autowired
+    private CorsFilter corsFilter;
+
+    /**
+     * 解决 无法直接注入 AuthenticationManager
+     *
+     * @return
+     * @throws Exception
+     */
+    @Bean
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception
+    {
+        return super.authenticationManagerBean();
+    }
+
+    /**
+     * anyRequest          |   匹配所有请求路径
+     * access              |   SpringEl表达式结果为true时可以访问
+     * anonymous           |   匿名可以访问
+     * denyAll             |   用户不能访问
+     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
+     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
+     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
+     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
+     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
+     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
+     * permitAll           |   用户可以任意访问
+     * rememberMe          |   允许通过remember-me登录的用户访问
+     * authenticated       |   用户登录后可访问
+     */
+    @Override
+    protected void configure(HttpSecurity httpSecurity) throws Exception
+    {
+        httpSecurity
+                // CSRF禁用,因为不使用session
+                .csrf().disable()
+                // 认证失败处理类
+                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
+                // 基于token,所以不需要session
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
+                // 过滤请求
+                .authorizeRequests()
+                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
+                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage").anonymous()
+                .antMatchers(
+                        HttpMethod.GET,
+                        "/",
+                        "/*.html",
+                        "/**/*.html",
+                        "/**/*.css",
+                        "/**/*.js",
+                        "/profile/**"
+                ).permitAll()
+                .antMatchers("**/errorLogUpload").anonymous()
+                .antMatchers("**").anonymous()
+                .antMatchers("/qw/getJsapiTicket/**").anonymous()
+                .antMatchers("/msg/**").anonymous()
+                .antMatchers("/msg/**/**").anonymous()
+                .antMatchers("/msg").anonymous()
+                .antMatchers("/app/common/**").anonymous()
+                .antMatchers("/common/getId**").anonymous()
+                .antMatchers("/common/uploadOSS**").anonymous()
+                .antMatchers("/common/uploadWang**").anonymous()
+                .antMatchers("/common/download**").anonymous()
+                .antMatchers("/common/download/resource**").anonymous()
+                .antMatchers("/swagger-ui.html").anonymous()
+                .antMatchers("/swagger-resources/**").anonymous()
+                .antMatchers("/webjars/**").anonymous()
+                .antMatchers("/*/api-docs").anonymous()
+                .antMatchers("/druid/**").anonymous()
+                .antMatchers("/qw/data/**").anonymous()
+                // 除上面外的所有请求全部需要鉴权认证
+                .anyRequest().authenticated()
+                .and()
+                .headers().frameOptions().disable();
+        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
+        // 添加JWT filter
+        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
+        // 添加CORS filter
+        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
+        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
+    }
+
+    /**
+     * 强散列哈希加密实现
+     */
+    @Bean
+    public BCryptPasswordEncoder bCryptPasswordEncoder()
+    {
+        return new BCryptPasswordEncoder();
+    }
+
+    /**
+     * 身份认证接口
+     */
+    @Override
+    protected void configure(AuthenticationManagerBuilder auth) throws Exception
+    {
+        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
+    }
+}

+ 33 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/ServerConfig.java

@@ -0,0 +1,33 @@
+package com.fs.framework.config;
+
+import com.fs.common.utils.ServletUtils;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 服务相关配置
+ *
+
+ */
+@Component
+public class ServerConfig
+{
+    /**
+     * 获取完整的请求路径,包括:域名,端口,上下文访问路径
+     *
+     * @return 服务地址
+     */
+    public String getUrl()
+    {
+        HttpServletRequest request = ServletUtils.getRequest();
+        return getDomain(request);
+    }
+
+    public static String getDomain(HttpServletRequest request)
+    {
+        StringBuffer url = request.getRequestURL();
+        String contextPath = request.getServletContext().getContextPath();
+        return url.delete(url.length() - request.getRequestURI().length(), url.length()).append(contextPath).toString();
+    }
+}

+ 121 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/SwaggerConfig.java

@@ -0,0 +1,121 @@
+package com.fs.framework.config;
+
+import com.fs.common.config.FSConfig;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.models.auth.In;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import springfox.documentation.builders.ApiInfoBuilder;
+import springfox.documentation.builders.PathSelectors;
+import springfox.documentation.builders.RequestHandlerSelectors;
+import springfox.documentation.service.*;
+import springfox.documentation.spi.DocumentationType;
+import springfox.documentation.spi.service.contexts.SecurityContext;
+import springfox.documentation.spring.web.plugins.Docket;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Swagger2的接口配置
+ *
+
+ */
+@Configuration
+public class SwaggerConfig
+{
+    /** 系统基础配置 */
+    @Autowired
+    private FSConfig fsConfig;
+
+    /** 是否开启swagger */
+    @Value("${swagger.enabled}")
+    private boolean enabled;
+
+    /** 设置请求的统一前缀 */
+    @Value("${swagger.pathMapping}")
+    private String pathMapping;
+
+    /**
+     * 创建API
+     */
+    @Bean
+    public Docket createRestApi()
+    {
+        return new Docket(DocumentationType.SWAGGER_2)
+                // 是否启用Swagger
+                .enable(enabled)
+                // 用来创建该API的基本信息,展示在文档的页面中(自定义展示的信息)
+                .apiInfo(apiInfo())
+                // 设置哪些接口暴露给Swagger展示
+                .select()
+                // 扫描所有有注解的api,用这种方式更灵活
+                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
+                // 扫描指定包中的swagger注解
+                // .apis(RequestHandlerSelectors.basePackage("com.fs.project.tool.swagger"))
+                // 扫描所有 .apis(RequestHandlerSelectors.any())
+                .paths(PathSelectors.any())
+                .build()
+                /* 设置安全模式,swagger可以设置访问token */
+                .securitySchemes(securitySchemes())
+                .securityContexts(securityContexts())
+                .pathMapping(pathMapping);
+    }
+
+    /**
+     * 安全模式,这里指定token通过Authorization头请求头传递
+     */
+    private List<ApiKey> securitySchemes()
+    {
+        List<ApiKey> apiKeyList = new ArrayList<ApiKey>();
+        apiKeyList.add(new ApiKey("Authorization", "Authorization", "header"));
+        return apiKeyList;
+    }
+
+    /**
+     * 安全上下文
+     */
+    private List<SecurityContext> securityContexts()
+    {
+        List<SecurityContext> securityContexts = new ArrayList<>();
+        securityContexts.add(
+                SecurityContext.builder()
+                        .securityReferences(defaultAuth())
+                        .forPaths(PathSelectors.regex("^(?!auth).*$"))
+                        .build());
+        return securityContexts;
+    }
+
+    /**
+     * 默认的安全上引用
+     */
+    private List<SecurityReference> defaultAuth()
+    {
+        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
+        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
+        authorizationScopes[0] = authorizationScope;
+        List<SecurityReference> securityReferences = new ArrayList<>();
+        securityReferences.add(new SecurityReference("Authorization", authorizationScopes));
+        return securityReferences;
+    }
+
+    /**
+     * 添加摘要信息
+     */
+    private ApiInfo apiInfo()
+    {
+        // 用ApiInfoBuilder进行定制
+        return new ApiInfoBuilder()
+                // 设置标题
+                .title("标题:FS管理系统_接口文档")
+                // 描述
+                .description("描述:用于管理集团旗下公司的人员信息,具体包括XXX,XXX模块...")
+                // 作者信息
+                .contact(new Contact(fsConfig.getName(), null, null))
+                // 版本
+                .version("版本号:" + fsConfig.getVersion())
+                .build();
+    }
+}

+ 102 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/ThreadPoolConfig.java

@@ -0,0 +1,102 @@
+package com.fs.framework.config;
+
+import com.fs.common.utils.Threads;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 线程池配置
+ *
+
+ **/
+@Configuration
+@EnableAsync
+public class ThreadPoolConfig
+{
+    // 核心线程池大小
+    private int corePoolSize = 50;
+
+    // 最大可创建的线程数
+    private int maxPoolSize = 300;
+
+    // 队列最大长度
+    private int queueCapacity = 1000;
+
+    // 线程池维护线程所允许的空闲时间
+    private int keepAliveSeconds = 300;
+
+    @Bean(name = "threadPoolTaskExecutor")
+    public ThreadPoolTaskExecutor threadPoolTaskExecutor()
+    {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setCorePoolSize(corePoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(keepAliveSeconds);
+        // 线程池对拒绝任务(无线程可用)的处理策略
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        return executor;
+    }
+
+    /**
+     * 执行周期性或定时任务
+     */
+    @Bean(name = "scheduledExecutorService")
+    protected ScheduledExecutorService scheduledExecutorService()
+    {
+        return new ScheduledThreadPoolExecutor(corePoolSize,
+                new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build())
+        {
+            @Override
+            protected void afterExecute(Runnable r, Throwable t)
+            {
+                super.afterExecute(r, t);
+                Threads.printException(r, t);
+            }
+        };
+    }
+
+    @Bean(name = "sopTaskExecutor")
+    public Executor sopTaskExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(16); // 根据需求调整
+        executor.setMaxPoolSize(32);  // 根据需求调整
+        executor.setQueueCapacity(800); // 根据需求调整
+        executor.setThreadNamePrefix("SopTask-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+
+    @Bean(name = "sopChatTaskExecutor")
+    public Executor batchInsertExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(16);
+        executor.setMaxPoolSize(32);
+        executor.setQueueCapacity(800);
+        executor.setThreadNamePrefix("BatchInsert-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+
+    @Bean(name = "sopRatingExecutor")
+    public Executor sopRatingExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(16); // 根据需求调整
+        executor.setMaxPoolSize(32);  // 根据需求调整
+        executor.setQueueCapacity(800); // 根据需求调整
+        executor.setThreadNamePrefix("SopRating-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+}

+ 77 - 0
fs-app-task-course/src/main/java/com/fs/framework/config/properties/DruidProperties.java

@@ -0,0 +1,77 @@
+package com.fs.framework.config.properties;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * druid 配置属性
+ *
+
+ */
+@Configuration
+public class DruidProperties
+{
+    @Value("${spring.datasource.mysql.druid.initialSize}")
+    private int initialSize;
+
+    @Value("${spring.datasource.mysql.druid.minIdle}")
+    private int minIdle;
+
+    @Value("${spring.datasource.mysql.druid.maxActive}")
+    private int maxActive;
+
+    @Value("${spring.datasource.mysql.druid.maxWait}")
+    private int maxWait;
+
+    @Value("${spring.datasource.mysql.druid.timeBetweenEvictionRunsMillis}")
+    private int timeBetweenEvictionRunsMillis;
+
+    @Value("${spring.datasource.mysql.druid.minEvictableIdleTimeMillis}")
+    private int minEvictableIdleTimeMillis;
+
+    @Value("${spring.datasource.mysql.druid.maxEvictableIdleTimeMillis}")
+    private int maxEvictableIdleTimeMillis;
+
+    @Value("${spring.datasource.mysql.druid.validationQuery}")
+    private String validationQuery;
+
+    @Value("${spring.datasource.mysql.druid.testWhileIdle}")
+    private boolean testWhileIdle;
+
+    @Value("${spring.datasource.mysql.druid.testOnBorrow}")
+    private boolean testOnBorrow;
+
+    @Value("${spring.datasource.mysql.druid.testOnReturn}")
+    private boolean testOnReturn;
+
+    public DruidDataSource dataSource(DruidDataSource datasource)
+    {
+        /** 配置初始化大小、最小、最大 */
+        datasource.setInitialSize(initialSize);
+        datasource.setMaxActive(maxActive);
+        datasource.setMinIdle(minIdle);
+
+        /** 配置获取连接等待超时的时间 */
+        datasource.setMaxWait(maxWait);
+
+        /** 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 */
+        datasource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
+
+        /** 配置一个连接在池中最小、最大生存的时间,单位是毫秒 */
+        datasource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
+        datasource.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);
+
+        /**
+         * 用来检测连接是否有效的sql,要求是一个查询语句,常用select 'x'。如果validationQuery为null,testOnBorrow、testOnReturn、testWhileIdle都不会起作用。
+         */
+        datasource.setValidationQuery(validationQuery);
+        /** 建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。 */
+        datasource.setTestWhileIdle(testWhileIdle);
+        /** 申请连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。 */
+        datasource.setTestOnBorrow(testOnBorrow);
+        /** 归还连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。 */
+        datasource.setTestOnReturn(testOnReturn);
+        return datasource;
+    }
+}

+ 27 - 0
fs-app-task-course/src/main/java/com/fs/framework/datasource/DynamicDataSource.java

@@ -0,0 +1,27 @@
+package com.fs.framework.datasource;
+
+import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
+
+import javax.sql.DataSource;
+import java.util.Map;
+
+/**
+ * 动态数据源
+ *
+
+ */
+public class DynamicDataSource extends AbstractRoutingDataSource
+{
+    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources)
+    {
+        super.setDefaultTargetDataSource(defaultTargetDataSource);
+        super.setTargetDataSources(targetDataSources);
+        super.afterPropertiesSet();
+    }
+
+    @Override
+    protected Object determineCurrentLookupKey()
+    {
+        return DynamicDataSourceContextHolder.getDataSourceType();
+    }
+}

+ 45 - 0
fs-app-task-course/src/main/java/com/fs/framework/datasource/DynamicDataSourceContextHolder.java

@@ -0,0 +1,45 @@
+package com.fs.framework.datasource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 数据源切换处理
+ *
+
+ */
+public class DynamicDataSourceContextHolder
+{
+    public static final Logger log = LoggerFactory.getLogger(DynamicDataSourceContextHolder.class);
+
+    /**
+     * 使用ThreadLocal维护变量,ThreadLocal为每个使用该变量的线程提供独立的变量副本,
+     *  所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
+     */
+    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
+
+    /**
+     * 设置数据源的变量
+     */
+    public static void setDataSourceType(String dsType)
+    {
+//        log.info("切换到{}数据源", dsType);
+        CONTEXT_HOLDER.set(dsType);
+    }
+
+    /**
+     * 获得数据源的变量
+     */
+    public static String getDataSourceType()
+    {
+        return CONTEXT_HOLDER.get();
+    }
+
+    /**
+     * 清空数据源变量
+     */
+    public static void clearDataSourceType()
+    {
+        CONTEXT_HOLDER.remove();
+    }
+}

+ 115 - 0
fs-app-task-course/src/main/java/com/fs/framework/exception/GlobalExceptionHandler.java

@@ -0,0 +1,115 @@
+package com.fs.framework.exception;
+
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.exception.DemoModeException;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 全局异常处理器
+ *
+
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    /**
+     * 权限校验异常
+     */
+    @ExceptionHandler(AccessDeniedException.class)
+    public AjaxResult handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage());
+        return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
+    }
+
+    /**
+     * 请求方式不支持
+     */
+    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+    public AjaxResult handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
+            HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 业务异常
+     */
+    @ExceptionHandler(ServiceException.class)
+    public AjaxResult handleServiceException(ServiceException e, HttpServletRequest request)
+    {
+        log.error(e.getMessage(), e);
+        Integer code = e.getCode();
+        return StringUtils.isNotNull(code) ? AjaxResult.error(code, e.getMessage()) : AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 拦截未知的运行时异常
+     */
+    @ExceptionHandler(RuntimeException.class)
+    public AjaxResult handleRuntimeException(RuntimeException e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生未知异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 系统异常
+     */
+    @ExceptionHandler(Exception.class)
+    public AjaxResult handleException(Exception e, HttpServletRequest request)
+    {
+        String requestURI = request.getRequestURI();
+        log.error("请求地址'{}',发生系统异常.", requestURI, e);
+        return AjaxResult.error(e.getMessage());
+    }
+
+    /**
+     * 自定义验证异常
+     */
+    @ExceptionHandler(BindException.class)
+    public AjaxResult handleBindException(BindException e)
+    {
+        log.error(e.getMessage(), e);
+        String message = e.getAllErrors().get(0).getDefaultMessage();
+        return AjaxResult.error(message);
+    }
+
+    /**
+     * 自定义验证异常
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e)
+    {
+        log.error(e.getMessage(), e);
+        String message = e.getBindingResult().getFieldError().getDefaultMessage();
+        return AjaxResult.error(message);
+    }
+
+    /**
+     * 演示模式异常
+     */
+    @ExceptionHandler(DemoModeException.class)
+    public AjaxResult handleDemoModeException(DemoModeException e)
+    {
+        return AjaxResult.error("演示模式,不允许操作");
+    }
+}

+ 56 - 0
fs-app-task-course/src/main/java/com/fs/framework/interceptor/RepeatSubmitInterceptor.java

@@ -0,0 +1,56 @@
+package com.fs.framework.interceptor;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+/**
+ * 防止重复提交拦截器
+ *
+
+ */
+@Component
+public abstract class RepeatSubmitInterceptor extends HandlerInterceptorAdapter
+{
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
+    {
+        if (handler instanceof HandlerMethod)
+        {
+            HandlerMethod handlerMethod = (HandlerMethod) handler;
+            Method method = handlerMethod.getMethod();
+            RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
+            if (annotation != null)
+            {
+                if (this.isRepeatSubmit(request))
+                {
+                    AjaxResult ajaxResult = AjaxResult.error("不允许重复提交,请稍后再试");
+                    ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
+                    return false;
+                }
+            }
+            return true;
+        }
+        else
+        {
+            return super.preHandle(request, response, handler);
+        }
+    }
+
+    /**
+     * 验证是否重复提交由子类实现具体的防重复提交的规则
+     *
+     * @param request
+     * @return
+     * @throws Exception
+     */
+    public abstract boolean isRepeatSubmit(HttpServletRequest request);
+}

+ 126 - 0
fs-app-task-course/src/main/java/com/fs/framework/interceptor/impl/SameUrlDataInterceptor.java

@@ -0,0 +1,126 @@
+package com.fs.framework.interceptor.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.filter.RepeatedlyRequestWrapper;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.http.HttpHelper;
+import com.fs.framework.interceptor.RepeatSubmitInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 判断请求url和数据是否和上一次相同,
+ * 如果和上次相同,则是重复提交表单。 有效时间为10秒内。
+ *
+
+ */
+@Component
+public class SameUrlDataInterceptor extends RepeatSubmitInterceptor
+{
+    public final String REPEAT_PARAMS = "repeatParams";
+
+    public final String REPEAT_TIME = "repeatTime";
+
+    // 令牌自定义标识
+    @Value("${token.header}")
+    private String header;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 间隔时间,单位:秒 默认10秒
+     *
+     * 两次相同参数的请求,如果间隔时间大于该参数,系统不会认定为重复提交的数据
+     */
+    private int intervalTime = 10;
+
+    public void setIntervalTime(int intervalTime)
+    {
+        this.intervalTime = intervalTime;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public boolean isRepeatSubmit(HttpServletRequest request)
+    {
+        String nowParams = "";
+        if (request instanceof RepeatedlyRequestWrapper)
+        {
+            RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
+            nowParams = HttpHelper.getBodyString(repeatedlyRequest);
+        }
+
+        // body参数为空,获取Parameter的数据
+        if (StringUtils.isEmpty(nowParams))
+        {
+            nowParams = JSONObject.toJSONString(request.getParameterMap());
+        }
+        Map<String, Object> nowDataMap = new HashMap<String, Object>();
+        nowDataMap.put(REPEAT_PARAMS, nowParams);
+        nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
+
+        // 请求地址(作为存放cache的key值)
+        String url = request.getRequestURI();
+
+        // 唯一值(没有消息头则使用请求地址)
+        String submitKey = request.getHeader(header);
+        if (StringUtils.isEmpty(submitKey))
+        {
+            submitKey = url;
+        }
+
+        // 唯一标识(指定key + 消息头)
+        String cacheRepeatKey = Constants.REPEAT_SUBMIT_KEY + submitKey;
+
+        Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
+        if (sessionObj != null)
+        {
+            Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
+            if (sessionMap.containsKey(url))
+            {
+                Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
+                if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap))
+                {
+                    return true;
+                }
+            }
+        }
+        Map<String, Object> cacheMap = new HashMap<String, Object>();
+        cacheMap.put(url, nowDataMap);
+        redisCache.setCacheObject(cacheRepeatKey, cacheMap, intervalTime, TimeUnit.SECONDS);
+        return false;
+    }
+
+    /**
+     * 判断参数是否相同
+     */
+    private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap)
+    {
+        String nowParams = (String) nowMap.get(REPEAT_PARAMS);
+        String preParams = (String) preMap.get(REPEAT_PARAMS);
+        return nowParams.equals(preParams);
+    }
+
+    /**
+     * 判断两次间隔时间
+     */
+    private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap)
+    {
+        long time1 = (Long) nowMap.get(REPEAT_TIME);
+        long time2 = (Long) preMap.get(REPEAT_TIME);
+        if ((time1 - time2) < (this.intervalTime * 1000))
+        {
+            return true;
+        }
+        return false;
+    }
+}

+ 56 - 0
fs-app-task-course/src/main/java/com/fs/framework/manager/AsyncManager.java

@@ -0,0 +1,56 @@
+package com.fs.framework.manager;
+
+import com.fs.common.utils.Threads;
+import com.fs.common.utils.spring.SpringUtils;
+
+import java.util.TimerTask;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 异步任务管理器
+ *
+
+ */
+public class AsyncManager
+{
+    /**
+     * 操作延迟10毫秒
+     */
+    private final int OPERATE_DELAY_TIME = 10;
+
+    /**
+     * 异步操作任务调度线程池
+     */
+    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
+
+    /**
+     * 单例模式
+     */
+    private AsyncManager(){}
+
+    private static AsyncManager me = new AsyncManager();
+
+    public static AsyncManager me()
+    {
+        return me;
+    }
+
+    /**
+     * 执行任务
+     *
+     * @param task 任务
+     */
+    public void execute(TimerTask task)
+    {
+        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
+    }
+
+    /**
+     * 停止任务线程池
+     */
+    public void shutdown()
+    {
+        Threads.shutdownAndAwaitTermination(executor);
+    }
+}

+ 40 - 0
fs-app-task-course/src/main/java/com/fs/framework/manager/ShutdownManager.java

@@ -0,0 +1,40 @@
+package com.fs.framework.manager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PreDestroy;
+
+/**
+ * 确保应用退出时能关闭后台线程
+ *
+
+ */
+@Component
+public class ShutdownManager
+{
+    private static final Logger logger = LoggerFactory.getLogger("sys-user");
+
+    @PreDestroy
+    public void destroy()
+    {
+        shutdownAsyncManager();
+    }
+
+    /**
+     * 停止异步执行任务
+     */
+    private void shutdownAsyncManager()
+    {
+        try
+        {
+            logger.info("====关闭后台任务任务线程池====");
+            AsyncManager.me().shutdown();
+        }
+        catch (Exception e)
+        {
+            logger.error(e.getMessage(), e);
+        }
+    }
+}

+ 106 - 0
fs-app-task-course/src/main/java/com/fs/framework/manager/factory/AsyncFactory.java

@@ -0,0 +1,106 @@
+package com.fs.framework.manager.factory;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.utils.LogUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.ip.AddressUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyLogininfor;
+import com.fs.company.domain.CompanyOperLog;
+import com.fs.company.service.ICompanyLogininforService;
+import com.fs.company.service.ICompanyOperLogService;
+import eu.bitwalker.useragentutils.UserAgent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Date;
+import java.util.TimerTask;
+
+/**
+ * 异步工厂(产生任务用)
+ *
+
+ */
+public class AsyncFactory
+{
+    private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");
+
+    /**
+     * 记录登录信息
+     *
+     * @param username 用户名
+     * @param status 状态
+     * @param message 消息
+     * @param args 列表
+     * @return 任务task
+     */
+    public static TimerTask recordLogininfor(final Long companyId,final String username, final String status, final String message,
+            final Object... args)
+    {
+        final UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        final String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                String address = AddressUtils.getRealAddressByIP(ip);
+                StringBuilder s = new StringBuilder();
+                s.append(LogUtils.getBlock(ip));
+                s.append(address);
+                s.append(LogUtils.getBlock(username));
+                s.append(LogUtils.getBlock(status));
+                s.append(LogUtils.getBlock(message));
+                // 打印信息到日志
+                sys_user_logger.info(s.toString(), args);
+                // 获取客户端操作系统
+                String os = userAgent.getOperatingSystem().getName();
+                // 获取客户端浏览器
+                String browser = userAgent.getBrowser().getName();
+                // 封装对象
+                CompanyLogininfor logininfor = new CompanyLogininfor();
+                logininfor.setCompanyId(companyId);
+                logininfor.setUserName(username);
+                logininfor.setIpaddr(ip);
+                logininfor.setLoginLocation(address);
+                logininfor.setBrowser(browser);
+                logininfor.setOs(os);
+                logininfor.setMsg(message);
+                // 日志状态
+                if (Constants.LOGIN_SUCCESS.equals(status) || Constants.LOGOUT.equals(status))
+                {
+                    logininfor.setStatus(Constants.SUCCESS);
+                }
+                else if (Constants.LOGIN_FAIL.equals(status))
+                {
+                    logininfor.setStatus(Constants.FAIL);
+                }
+                logininfor.setLoginTime(new Date());
+                // 插入数据
+                SpringUtils.getBean(ICompanyLogininforService.class).insertCompanyLogininfor(logininfor);
+            }
+        };
+    }
+
+    /**
+     * 操作日志记录
+     *
+     * @param operLog 操作日志信息
+     * @return 任务task
+     */
+    public static TimerTask recordOper(final CompanyOperLog operLog)
+    {
+        return new TimerTask()
+        {
+            @Override
+            public void run()
+            {
+                // 远程查询操作地点
+                operLog.setOperTime(new Date());
+                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
+                SpringUtils.getBean(ICompanyOperLogService.class).insertCompanyOperLog(operLog);
+            }
+        };
+    }
+}

+ 69 - 0
fs-app-task-course/src/main/java/com/fs/framework/security/LoginBody.java

@@ -0,0 +1,69 @@
+package com.fs.framework.security;
+
+/**
+ * 用户登录对象
+ *
+
+ */
+public class LoginBody
+{
+    /**
+     * 用户名
+     */
+    private String username;
+
+    /**
+     * 用户密码
+     */
+    private String password;
+
+    /**
+     * 验证码
+     */
+    private String code;
+
+    /**
+     * 唯一标识
+     */
+    private String uuid = "";
+
+    public String getUsername()
+    {
+        return username;
+    }
+
+    public void setUsername(String username)
+    {
+        this.username = username;
+    }
+
+    public String getPassword()
+    {
+        return password;
+    }
+
+    public void setPassword(String password)
+    {
+        this.password = password;
+    }
+
+    public String getCode()
+    {
+        return code;
+    }
+
+    public void setCode(String code)
+    {
+        this.code = code;
+    }
+
+    public String getUuid()
+    {
+        return uuid;
+    }
+
+    public void setUuid(String uuid)
+    {
+        this.uuid = uuid;
+    }
+}

+ 255 - 0
fs-app-task-course/src/main/java/com/fs/framework/security/LoginUser.java

@@ -0,0 +1,255 @@
+package com.fs.framework.security;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * 登录用户身份权限
+ *
+
+ */
+public class LoginUser implements UserDetails
+{
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 用户唯一标识
+     */
+    private String token;
+
+    /**
+     * 登录时间
+     */
+    private Long loginTime;
+
+    /**
+     * 过期时间
+     */
+    private Long expireTime;
+
+    /**
+     * 登录IP地址
+     */
+    private String ipaddr;
+
+    /**
+     * 登录地点
+     */
+    private String loginLocation;
+
+    /**
+     * 浏览器类型
+     */
+    private String browser;
+
+    /**
+     * 操作系统
+     */
+    private String os;
+
+
+
+    /**
+     * 权限列表
+     */
+    private Set<String> permissions;
+
+    /**
+     * 用户信息
+     */
+    private CompanyUser user;
+
+    private Company company;
+
+
+
+    public static long getSerialVersionUID() {
+        return serialVersionUID;
+    }
+
+
+
+    public Company getCompany() {
+        return company;
+    }
+
+    public void setCompany(Company company) {
+        this.company = company;
+    }
+
+    public String getToken()
+    {
+        return token;
+    }
+
+    public void setToken(String token)
+    {
+        this.token = token;
+    }
+
+    public LoginUser()
+    {
+    }
+    public LoginUser(CompanyUser user, Set<String> permissions, Company company)
+    {
+        this.user = user;
+        this.permissions = permissions;
+        this.company=company;
+    }
+    public LoginUser(CompanyUser user, Set<String> permissions)
+    {
+        this.user = user;
+        this.permissions = permissions;
+    }
+
+    @JsonIgnore
+    @Override
+    public String getPassword()
+    {
+        return user.getPassword();
+    }
+
+    @Override
+    public String getUsername()
+    {
+        return user.getUserName();
+    }
+
+    /**
+     * 账户是否未过期,过期无法验证
+     */
+    @JsonIgnore
+    @Override
+    public boolean isAccountNonExpired()
+    {
+        return true;
+    }
+
+    /**
+     * 指定用户是否解锁,锁定的用户无法进行身份验证
+     *
+     * @return
+     */
+    @JsonIgnore
+    @Override
+    public boolean isAccountNonLocked()
+    {
+        return true;
+    }
+
+    /**
+     * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
+     *
+     * @return
+     */
+    @JsonIgnore
+    @Override
+    public boolean isCredentialsNonExpired()
+    {
+        return true;
+    }
+
+    /**
+     * 是否可用 ,禁用的用户不能身份验证
+     *
+     * @return
+     */
+    @JsonIgnore
+    @Override
+    public boolean isEnabled()
+    {
+        return true;
+    }
+
+    public Long getLoginTime()
+    {
+        return loginTime;
+    }
+
+    public void setLoginTime(Long loginTime)
+    {
+        this.loginTime = loginTime;
+    }
+
+    public String getIpaddr()
+    {
+        return ipaddr;
+    }
+
+    public void setIpaddr(String ipaddr)
+    {
+        this.ipaddr = ipaddr;
+    }
+
+    public String getLoginLocation()
+    {
+        return loginLocation;
+    }
+
+    public void setLoginLocation(String loginLocation)
+    {
+        this.loginLocation = loginLocation;
+    }
+
+    public String getBrowser()
+    {
+        return browser;
+    }
+
+    public void setBrowser(String browser)
+    {
+        this.browser = browser;
+    }
+
+    public String getOs()
+    {
+        return os;
+    }
+
+    public void setOs(String os)
+    {
+        this.os = os;
+    }
+
+    public Long getExpireTime()
+    {
+        return expireTime;
+    }
+
+    public void setExpireTime(Long expireTime)
+    {
+        this.expireTime = expireTime;
+    }
+
+    public Set<String> getPermissions()
+    {
+        return permissions;
+    }
+
+    public void setPermissions(Set<String> permissions)
+    {
+        this.permissions = permissions;
+    }
+
+    public CompanyUser getUser()
+    {
+        return user;
+    }
+
+    public void setUser(CompanyUser user)
+    {
+        this.user = user;
+    }
+
+    @Override
+    public Collection<? extends GrantedAuthority> getAuthorities()
+    {
+        return null;
+    }
+}

+ 89 - 0
fs-app-task-course/src/main/java/com/fs/framework/security/SecurityUtils.java

@@ -0,0 +1,89 @@
+package com.fs.framework.security;
+
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.exception.CustomException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+/**
+ * 安全服务工具类
+ *
+
+ */
+public class SecurityUtils
+{
+    /**
+     * 获取用户账户
+     **/
+    public static String getUsername()
+    {
+        try
+        {
+            return getLoginUser().getUsername();
+        }
+        catch (Exception e)
+        {
+            throw new CustomException("获取用户账户异常", HttpStatus.UNAUTHORIZED);
+        }
+    }
+
+    /**
+     * 获取用户
+     **/
+    public static LoginUser getLoginUser()
+    {
+        try
+        {
+            return (LoginUser) getAuthentication().getPrincipal();
+        }
+        catch (Exception e)
+        {
+            throw new CustomException("获取用户信息异常", HttpStatus.UNAUTHORIZED);
+        }
+    }
+
+    /**
+     * 获取Authentication
+     */
+    public static Authentication getAuthentication()
+    {
+        return SecurityContextHolder.getContext().getAuthentication();
+    }
+
+    /**
+     * 生成BCryptPasswordEncoder密码
+     *
+     * @param password 密码
+     * @return 加密字符串
+     */
+    public static String encryptPassword(String password)
+    {
+        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+        return passwordEncoder.encode(password);
+    }
+
+    /**
+     * 判断密码是否相同
+     *
+     * @param rawPassword 真实密码
+     * @param encodedPassword 加密后字符
+     * @return 结果
+     */
+    public static boolean matchesPassword(String rawPassword, String encodedPassword)
+    {
+        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+        return passwordEncoder.matches(rawPassword, encodedPassword);
+    }
+
+    /**
+     * 是否为管理员
+     *
+     * @param userId 用户ID
+     * @return 结果
+     */
+    public static boolean isAdmin(Long userId)
+    {
+        return userId != null && 1L == userId;
+    }
+}

+ 47 - 0
fs-app-task-course/src/main/java/com/fs/framework/security/filter/JwtAuthenticationTokenFilter.java

@@ -0,0 +1,47 @@
+package com.fs.framework.security.filter;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.security.SecurityUtils;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * token过滤器 验证token有效性
+ *
+
+ */
+@Component
+public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
+{
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private RedisCache redisCache;
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+            throws ServletException, IOException
+    {
+        LoginUser loginUser = tokenService.getLoginUser(request);
+        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
+        {
+            tokenService.verifyToken(loginUser);
+            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
+            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
+        }
+        chain.doFilter(request, response);
+    }
+}

+ 35 - 0
fs-app-task-course/src/main/java/com/fs/framework/security/handle/AuthenticationEntryPointImpl.java

@@ -0,0 +1,35 @@
+package com.fs.framework.security.handle;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Serializable;
+
+/**
+ * 认证失败处理类 返回未授权
+ *
+
+ */
+@Component
+public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
+{
+    private static final long serialVersionUID = -8970718410437077606L;
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
+            throws IOException
+    {
+        int code = HttpStatus.UNAUTHORIZED;
+        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
+    }
+}

+ 54 - 0
fs-app-task-course/src/main/java/com/fs/framework/security/handle/LogoutSuccessHandlerImpl.java

@@ -0,0 +1,54 @@
+package com.fs.framework.security.handle;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.Constants;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 自定义退出处理类 返回成功
+ *
+
+ */
+@Configuration
+public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
+{
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 退出处理
+     *
+     * @return
+     */
+    @Override
+    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
+            throws IOException, ServletException
+    {
+        LoginUser loginUser = tokenService.getLoginUser(request);
+        if (StringUtils.isNotNull(loginUser))
+        {
+            String userName = loginUser.getUsername();
+            // 删除用户缓存记录
+            tokenService.delLoginUser(loginUser.getToken());
+            // 记录用户退出日志
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getCompany().getCompanyId(),userName, Constants.LOGOUT, "退出成功"));
+        }
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));
+    }
+}

+ 92 - 0
fs-app-task-course/src/main/java/com/fs/framework/service/CompanyLoginService.java

@@ -0,0 +1,92 @@
+package com.fs.framework.service;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.exception.user.CaptchaException;
+import com.fs.common.exception.user.CaptchaExpireException;
+import com.fs.common.exception.user.UserPasswordNotMatchException;
+import com.fs.common.utils.MessageUtils;
+import com.fs.framework.manager.AsyncManager;
+import com.fs.framework.manager.factory.AsyncFactory;
+import com.fs.framework.security.LoginUser;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 登录校验方法
+ *
+
+ */
+@Component
+public class CompanyLoginService
+{
+    @Autowired
+    private TokenService tokenService;
+
+    @Resource
+    private AuthenticationManager authenticationManager;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 登录验证
+     *
+     * @param username 用户名
+     * @param password 密码
+     * @param code 验证码
+     * @param uuid 唯一标识
+     * @return 结果
+     */
+    public String login(String username, String password, String code, String uuid)
+    {
+        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
+        String captcha = redisCache.getCacheObject(verifyKey);
+        redisCache.deleteObject(verifyKey);
+        if (captcha == null)
+        {
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
+            throw new CaptchaExpireException();
+        }
+        if (!code.equalsIgnoreCase(captcha))
+        {
+            AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
+            throw new CaptchaException();
+        }
+        // 用户验证
+        Authentication authentication = null;
+        try
+        {
+            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
+            authentication = authenticationManager
+                    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
+        }
+        catch (Exception e)
+        {
+            if (e instanceof BadCredentialsException)
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
+                throw new UserPasswordNotMatchException();
+            }
+            else
+            {
+                AsyncManager.me().execute(AsyncFactory.recordLogininfor(0l,username, Constants.LOGIN_FAIL, e.getMessage()));
+                throw new ServiceException(e.getMessage());
+            }
+        }
+        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
+        AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getUser().getCompanyId(),username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
+        redisCache.setCacheObject("companyId:"+loginUser.getUser().getUserId(),loginUser.getUser().getCompanyId(),604800, TimeUnit.SECONDS);
+        // 生成token
+        return tokenService.createToken(loginUser);
+    }
+
+}

+ 66 - 0
fs-app-task-course/src/main/java/com/fs/framework/service/CompanyPermissionService.java

@@ -0,0 +1,66 @@
+package com.fs.framework.service;
+
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyMenuService;
+import com.fs.company.service.ICompanyRoleService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 用户权限处理
+ *
+
+ */
+@Component
+public class CompanyPermissionService
+{
+    @Autowired
+    private ICompanyRoleService roleService;
+    @Autowired
+    private ICompanyMenuService menuService;
+
+    /**
+     * 获取角色数据权限
+     *
+     * @param user 用户信息
+     * @return 角色权限信息
+     */
+    public Set<String> getRolePermission(CompanyUser user)
+    {
+        Set<String> roles = new HashSet<String>();
+        // 管理员拥有所有权限
+        if (user.isAdmin())
+        {
+            roles.add("admin");
+        }
+        else
+        {
+            roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
+        }
+        return roles;
+    }
+
+    /**
+     * 获取菜单数据权限
+     *
+     * @param user 用户信息
+     * @return 菜单权限信息
+     */
+    public Set<String> getMenuPermission(CompanyUser user)
+    {
+        Set<String> perms = new HashSet<String>();
+        // 管理员拥有所有权限
+        if (user.isAdmin())
+        {
+            perms.add("*:*:*");
+        }
+        else
+        {
+            perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
+        }
+        return perms;
+    }
+}

+ 170 - 0
fs-app-task-course/src/main/java/com/fs/framework/service/PermissionService.java

@@ -0,0 +1,170 @@
+package com.fs.framework.service;
+
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyRole;
+import com.fs.framework.security.LoginUser;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Set;
+
+/**
+ * 自定义权限实现,ss取自SpringSecurity首字母
+ */
+@Service("ss")
+public class PermissionService
+{
+    /** 所有权限标识 */
+    private static final String ALL_PERMISSION = "*:*:*";
+
+    /** 管理员角色权限标识 */
+    private static final String SUPER_ADMIN = "admin";
+
+    private static final String ROLE_DELIMETER = ",";
+
+    private static final String PERMISSION_DELIMETER = ",";
+
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 验证用户是否具备某权限
+     *
+     * @param permission 权限字符串
+     * @return 用户是否具备某权限
+     */
+    public boolean hasPermi(String permission)
+    {
+        if (StringUtils.isEmpty(permission))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
+        {
+            return false;
+        }
+        return hasPermissions(loginUser.getPermissions(), permission);
+    }
+
+    /**
+     * 验证用户是否不具备某权限,与 hasPermi逻辑相反
+     *
+     * @param permission 权限字符串
+     * @return 用户是否不具备某权限
+     */
+    public boolean lacksPermi(String permission)
+    {
+        return hasPermi(permission) != true;
+    }
+
+    /**
+     * 验证用户是否具有以下任意一个权限
+     *
+     * @param permissions 以 PERMISSION_NAMES_DELIMETER 为分隔符的权限列表
+     * @return 用户是否具有以下任意一个权限
+     */
+    public boolean hasAnyPermi(String permissions)
+    {
+        if (StringUtils.isEmpty(permissions))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
+        {
+            return false;
+        }
+        Set<String> authorities = loginUser.getPermissions();
+        for (String permission : permissions.split(PERMISSION_DELIMETER))
+        {
+            if (permission != null && hasPermissions(authorities, permission))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断用户是否拥有某个角色
+     *
+     * @param role 角色字符串
+     * @return 用户是否具备某角色
+     */
+    public boolean hasRole(String role)
+    {
+        if (StringUtils.isEmpty(role))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
+        {
+            return false;
+        }
+        for (CompanyRole sysRole : loginUser.getUser().getRoles())
+        {
+            String roleKey = sysRole.getRoleKey();
+            if (SUPER_ADMIN.contains(roleKey) || roleKey.contains(StringUtils.trim(role)))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 验证用户是否不具备某角色,与 isRole逻辑相反。
+     *
+     * @param role 角色名称
+     * @return 用户是否不具备某角色
+     */
+    public boolean lacksRole(String role)
+    {
+        return hasRole(role) != true;
+    }
+
+    /**
+     * 验证用户是否具有以下任意一个角色
+     *
+     * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
+     * @return 用户是否具有以下任意一个角色
+     */
+    public boolean hasAnyRoles(String roles)
+    {
+        if (StringUtils.isEmpty(roles))
+        {
+            return false;
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
+        {
+            return false;
+        }
+        for (String role : roles.split(ROLE_DELIMETER))
+        {
+            if (hasRole(role))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断是否包含权限
+     *
+     * @param permissions 权限列表
+     * @param permission 权限字符串
+     * @return 用户是否具备某权限
+     */
+    private boolean hasPermissions(Set<String> permissions, String permission)
+    {
+        return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
+    }
+
+
+}

+ 236 - 0
fs-app-task-course/src/main/java/com/fs/framework/service/TokenService.java

@@ -0,0 +1,236 @@
+package com.fs.framework.service;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.AddressUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.uuid.IdUtils;
+import com.fs.framework.security.LoginUser;
+import eu.bitwalker.useragentutils.UserAgent;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * token验证处理
+ *
+
+ */
+@Component
+public class TokenService
+{
+    // 令牌自定义标识
+    @Value("${token.header}")
+    private String header;
+
+    // 令牌秘钥
+    @Value("${token.secret}")
+    private String secret;
+
+    // 令牌有效期(默认30分钟)
+    @Value("${token.expireTime}")
+    private int expireTime;
+
+    protected static final long MILLIS_SECOND = 1000;
+
+    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
+
+    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 100000000000L;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 获取用户身份信息
+     *
+     * @return 用户信息
+     */
+    public LoginUser getLoginUser(HttpServletRequest request)
+    {
+        // 获取请求携带的令牌
+        String token = getToken(request);
+        if (StringUtils.isNotEmpty(token))
+        {
+            Claims claims = parseToken(token);
+            // 解析对应的权限以及用户信息
+            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+            String userKey = getTokenKey(uuid);
+            LoginUser user = redisCache.getCacheObject(userKey);
+            return user;
+        }
+        token=getUrlToken(request);
+        if (StringUtils.isNotEmpty(token))
+        {
+            Claims claims = parseToken(token);
+            // 解析对应的权限以及用户信息
+            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+            String userKey = getTokenKey(uuid);
+            LoginUser user = redisCache.getCacheObject(userKey);
+            return user;
+        }
+
+        return null;
+    }
+
+    /**
+     * 设置用户身份信息
+     */
+    public void setLoginUser(LoginUser loginUser)
+    {
+        if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
+        {
+            refreshToken(loginUser);
+        }
+    }
+
+    /**
+     * 删除用户身份信息
+     */
+    public void delLoginUser(String token)
+    {
+        if (StringUtils.isNotEmpty(token))
+        {
+            String userKey = getTokenKey(token);
+            redisCache.deleteObject(userKey);
+        }
+    }
+
+    /**
+     * 创建令牌
+     *
+     * @param loginUser 用户信息
+     * @return 令牌
+     */
+    public String createToken(LoginUser loginUser)
+    {
+        String token = IdUtils.fastUUID();
+        loginUser.setToken(token);
+        setUserAgent(loginUser);
+        refreshToken(loginUser);
+
+        Map<String, Object> claims = new HashMap<>();
+        claims.put(Constants.COMPANY_LOGIN_USER_KEY, token);
+        return createToken(claims);
+    }
+
+    /**
+     * 验证令牌有效期,相差不足20分钟,自动刷新缓存
+     *
+     * @param loginUser
+     * @return 令牌
+     */
+    public void verifyToken(LoginUser loginUser)
+    {
+        long expireTime = loginUser.getExpireTime();
+        long currentTime = System.currentTimeMillis();
+        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
+        {
+            refreshToken(loginUser);
+        }
+    }
+
+    /**
+     * 刷新令牌有效期
+     *
+     * @param loginUser 登录信息
+     */
+    public void refreshToken(LoginUser loginUser)
+    {
+        loginUser.setLoginTime(System.currentTimeMillis());
+        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
+        // 根据uuid将loginUser缓存
+        String userKey = getTokenKey(loginUser.getToken());
+        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
+    }
+
+    /**
+     * 设置用户代理信息
+     *
+     * @param loginUser 登录信息
+     */
+    public void setUserAgent(LoginUser loginUser)
+    {
+        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        loginUser.setIpaddr(ip);
+        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
+        loginUser.setBrowser(userAgent.getBrowser().getName());
+        loginUser.setOs(userAgent.getOperatingSystem().getName());
+    }
+
+    /**
+     * 从数据声明生成令牌
+     *
+     * @param claims 数据声明
+     * @return 令牌
+     */
+    private String createToken(Map<String, Object> claims)
+    {
+        String token = Jwts.builder()
+                .setClaims(claims)
+                .signWith(SignatureAlgorithm.HS512, secret).compact();
+        return token;
+    }
+
+    /**
+     * 从令牌中获取数据声明
+     *
+     * @param token 令牌
+     * @return 数据声明
+     */
+    private Claims parseToken(String token)
+    {
+        return Jwts.parser()
+                .setSigningKey(secret)
+                .parseClaimsJws(token)
+                .getBody();
+    }
+
+    /**
+     * 从令牌中获取用户名
+     *
+     * @param token 令牌
+     * @return 用户名
+     */
+    public String getUsernameFromToken(String token)
+    {
+        Claims claims = parseToken(token);
+        return claims.getSubject();
+    }
+
+    /**
+     * 获取请求token
+     *
+     * @param request
+     * @return token
+     */
+    private String getToken(HttpServletRequest request)
+    {
+        String token = request.getHeader(header);
+        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
+        {
+            token = token.replace(Constants.TOKEN_PREFIX, "");
+        }
+        return token;
+    }
+    private String getUrlToken(HttpServletRequest request)
+    {
+        String token = request.getParameter("token");
+        return token;
+    }
+
+    private String getTokenKey(String uuid)
+    {
+        return Constants.COMPANY_LOGIN_TOKEN_KEY + uuid;
+    }
+}

+ 75 - 0
fs-app-task-course/src/main/java/com/fs/framework/service/UserDetailsServiceImpl.java

@@ -0,0 +1,75 @@
+package com.fs.framework.service;
+
+
+import com.fs.common.enums.UserStatus;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.security.LoginUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 用户验证处理
+ *
+
+ */
+@Service
+public class UserDetailsServiceImpl implements UserDetailsService
+{
+    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
+
+    @Autowired
+    private ICompanyUserService userService;
+
+    @Autowired
+    private CompanyPermissionService permissionService;
+
+    @Autowired
+    private ICompanyService companyService;
+
+
+
+
+    @Override
+    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
+    {
+
+
+        CompanyUser user = userService.selectUserByUserName(username);
+        if (StringUtils.isNull(user))
+        {
+            log.info("登录用户:{} 不存在.", username);
+            throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
+        }
+        Company company=companyService.selectCompanyById(user.getCompanyId()) ;
+        if(company==null||company.getStatus()==0||company.getIsDel()==1){
+            throw new CustomException("此用户所属公司不存在或已停用");
+        }
+        if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
+        {
+            log.info("登录用户:{} 已被删除.", username);
+            throw new CustomException("对不起,您的账号:" + username + " 已被删除");
+        }
+        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
+        {
+            log.info("登录用户:{} 已被停用.", username);
+            throw new CustomException("对不起,您的账号:" + username + " 已停用");
+        }
+
+        return createLoginUser(user);
+    }
+
+    public UserDetails createLoginUser(CompanyUser user)
+    {
+        return new LoginUser(user, permissionService.getMenuPermission(user),companyService.selectCompanyById(user.getCompanyId()));
+    }
+}

+ 1 - 0
fs-app-task-course/src/main/resources/META-INF/spring-devtools.properties

@@ -0,0 +1 @@
+restart.include.json=/com.alibaba.fastjson.*.jar

+ 10 - 0
fs-app-task-course/src/main/resources/application.yml

@@ -0,0 +1,10 @@
+server:
+  # 服务器的HTTP端口,默认为8080
+  port: 8889
+# Spring配置
+spring:
+  profiles:
+    #    active: druid-bjzm-test
+    #    active: druid-hdt
+    #    active: dev
+    active: druid-hst-test

+ 2 - 0
fs-app-task-course/src/main/resources/banner.txt

@@ -0,0 +1,2 @@
+Application Version: ${fs.version}
+Spring Boot Version: ${spring-boot.version}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff