1
0

3 Commits 6e6db35fe6 ... 7e93566644

Autor SHA1 Nachricht Datum
  wangxy 7e93566644 app登录,bug修复,app统计 vor 3 Wochen
  wangxy a6b6a03a29 Merge remote-tracking branch 'origin/红德堂' into 红德堂 vor 3 Wochen
  wangxy 8f8a503974 app登录,bug修复 vor 3 Wochen
31 geänderte Dateien mit 2529 neuen und 39 gelöschten Zeilen
  1. 25 0
      fs-admin/src/main/java/com/fs/company/controller/CompanyStatisticsController.java
  2. 9 0
      fs-admin/src/main/java/com/fs/his/controller/FsUserController.java
  3. 1 0
      fs-admin/src/main/java/com/fs/his/task/Task.java
  4. 13 0
      fs-admin/src/main/java/com/fs/web/controller/tool/TestController.java
  5. 58 0
      fs-company/src/main/java/com/fs/company/controller/course/FsCourseWatchLogController.java
  6. 66 1
      fs-company/src/main/java/com/fs/hisStore/controller/FsStoreStatisticsScrmController.java
  7. 13 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  8. 71 3
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  9. 10 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyLogMapper.java
  10. 2 0
      fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogStatisticsListParam.java
  11. 22 3
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  12. 449 3
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  13. 23 0
      fs-service/src/main/java/com/fs/his/dto/AppUserCompanyDTO.java
  14. 50 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  15. 6 0
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  16. 111 0
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  17. 80 0
      fs-service/src/main/java/com/fs/his/vo/AppCourseReportVO.java
  18. 89 0
      fs-service/src/main/java/com/fs/his/vo/AppSalesWatchLogReportVO.java
  19. 12 0
      fs-service/src/main/java/com/fs/his/vo/AppUserCountVO.java
  20. 146 0
      fs-service/src/main/java/com/fs/his/vo/AppWatchLogReportVO.java
  21. 8 0
      fs-service/src/main/java/com/fs/store/vo/h5/FsUserPageListExportVO.java
  22. 4 0
      fs-service/src/main/java/com/fs/store/vo/h5/FsUserPageListVO.java
  23. 2 1
      fs-service/src/main/resources/mapper/MerchantAppConfigMapper.xml
  24. 1 1
      fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml
  25. 278 2
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  26. 17 0
      fs-service/src/main/resources/mapper/course/FsUserCourseStudyLogMapper.xml
  27. 5 1
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  28. 59 17
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  29. 784 0
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController2.java
  30. 113 4
      fs-user-app/src/main/java/com/fs/app/controller/CompanyUserController.java
  31. 2 3
      fs-user-app/src/main/java/com/fs/app/param/FsUserEditParam.java

+ 25 - 0
fs-admin/src/main/java/com/fs/company/controller/CompanyStatisticsController.java

@@ -841,6 +841,31 @@ public class CompanyStatisticsController extends BaseController {
         return getDataTable(fsCourseReportVOS);
     }
 
+
+    /**
+     * app看课统计报表
+      * @param param
+     * @return
+     */
+    @GetMapping("/appCourseReport")
+    public TableDataInfo selectFsAppCourseReportVO(FsCourseWatchLogStatisticsListParam param) {
+        startPage();
+        List<AppCourseReportVO> list = fsCourseWatchLogService.selectAppCourseReportVO(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出app看课统计报表
+     * @param param
+     * @return
+     */
+        @GetMapping("/exportAppCourseReport")
+    public AjaxResult exportAppCourseReport(FsCourseWatchLogStatisticsListParam param) {
+        List<AppCourseReportVO> list = fsCourseWatchLogService.selectAppCourseReportVO(param);
+        ExcelUtil<AppCourseReportVO> util = new ExcelUtil<AppCourseReportVO>(AppCourseReportVO.class);
+        return util.exportExcel(list, "APP看课统计报表");
+    }
+
     @GetMapping("/exportFsCourseReportVO")
     public AjaxResult exportFsCourseReportVO(FsCourseWatchLogStatisticsListParam param) {
         List<FsCourseReportVO> list = fsCourseWatchLogService.selectFsCourseReportVO(param);

+ 9 - 0
fs-admin/src/main/java/com/fs/his/controller/FsUserController.java

@@ -513,4 +513,13 @@ public class FsUserController extends BaseController
         return fsCourseWatchLogService.clearUserWatchLog(param.getUserId(), param.getProjectId());
     }
 
+    /**
+     * 查询 APP 用户统计数据
+     */
+    @GetMapping("/appUserCount")
+    public AjaxResult getAppUserCount()
+    {
+        return AjaxResult.success(fsUserService.getAppUserCount());
+    }
+
 }

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

@@ -1749,6 +1749,7 @@ public class Task {
         // 等待所有任务完成
         waitForAllTasksToComplete(orderFutures);
         waitForAllTasksToComplete(packageOrderFutures);
+        waitForAllTasksToComplete(integralOrderFutures);
     }
 
     /**

+ 13 - 0
fs-admin/src/main/java/com/fs/web/controller/tool/TestController.java

@@ -6,6 +6,7 @@ import java.util.*;
 import com.fs.common.core.domain.R;
 import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.service.IFsStoreOrderService;
+import com.fs.his.service.IFsUserService;
 import com.fs.his.task.Task;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.ResponseEntity;
@@ -48,6 +49,9 @@ public class TestController extends BaseController {
     @Autowired
     FsStoreOrderMapper fsStoreOrderMapper;
 
+    @Autowired
+    IFsUserService  iFsUserService;
+
     @Autowired
     private Task task;
 
@@ -119,6 +123,15 @@ public class TestController extends BaseController {
         return AjaxResult.success(users.put(user.getUserId(), user));
     }
 
+    /**
+     * 合并用户
+     */
+    @ApiOperation("合并用户")
+    @PostMapping("/mergeUser")
+    public void mergeUser() {
+        iFsUserService.mergeUser();
+    }
+
     @ApiOperation("删除用户信息")
     @ApiImplicitParam(name = "userId", value = "用户ID", required = true, dataType = "int", paramType = "path")
     @DeleteMapping("/{userId}")

+ 58 - 0
fs-company/src/main/java/com/fs/company/controller/course/FsCourseWatchLogController.java

@@ -18,6 +18,7 @@ import com.fs.course.vo.FsCourseOverVO;
 import com.fs.course.vo.FsCourseUserStatisticsListVO;
 import com.fs.course.vo.FsCourseWatchLogListVO;
 import com.fs.course.vo.FsCourseWatchLogStatisticsListVO;
+import com.fs.his.vo.AppSalesWatchLogReportVO;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
 import com.fs.qw.param.QwWatchLogStatisticsListParam;
@@ -30,6 +31,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 
 /**
@@ -376,6 +378,62 @@ public class FsCourseWatchLogController extends BaseController
         ExcelUtil<FsCourseWatchLogListVO> util = new ExcelUtil<FsCourseWatchLogListVO>(FsCourseWatchLogListVO.class);
         return util.exportExcel(list, "短链课程看课记录数据");
     }
+
+    /**
+     * APP端销售维度看课统计报表
+     * 注意:必须放在 /{logId} 之前,避免路径冲突
+     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:appSalesReport')")
+    @GetMapping("/appSalesWatchLogReport")
+    public TableDataInfo appSalesWatchLogReport(FsCourseWatchLogStatisticsListParam param)
+    {
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppSalesWatchLogReportVO> list = fsCourseWatchLogService.selectAppSalesWatchLogReportVO(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出APP端销售维度看课统计报表
+     */
+    @Log(title = "APP销售维度看课统计", businessType = BusinessType.EXPORT)
+    @GetMapping("/appSalesWatchLogReportExport")
+    public AjaxResult appSalesWatchLogReportExport(FsCourseWatchLogStatisticsListParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppSalesWatchLogReportVO> list = fsCourseWatchLogService.selectAppSalesWatchLogReportVO(param);
+        // 根据维度获取需要导出的字段
+        List<String> selectedFields = getAppSalesWatchLogReportFields(param.getDimension());
+        ExcelUtil<AppSalesWatchLogReportVO> util = new ExcelUtil<AppSalesWatchLogReportVO>(AppSalesWatchLogReportVO.class);
+        return util.exportExcelSelectedColumns(list, "APP销售维度看课统计报表", selectedFields);
+    }
+
+    /**
+     * 获取AppSalesWatchLogReportVO需要导出的字段
+     * @param dimension 维度:sales-销售维度, dept-销售部门维度
+     */
+    private List<String> getAppSalesWatchLogReportFields(String dimension) {
+        // 销售维度字段
+        List<String> salesFields = Arrays.asList(
+                "salesName", "appUserCount", "newAppUserCount", "salesDept",
+                "salesCompany", "trainingCampName", "periodName", "videoTitle",
+                "finishedCount", "unfinishedCount", "completionRate",
+                "notWatchedCount", "notAnsweredCount", "redPacketAmount", "historyOrderCount"
+        );
+
+        // 销售部门维度字段(去掉销售列,销售数放在销售部门后面)
+        List<String> deptFields = Arrays.asList(
+                "salesDept", "salesCount", "appUserCount", "newAppUserCount",
+                "salesCompany", "trainingCampName", "periodName", "videoTitle",
+                "finishedCount", "unfinishedCount", "completionRate",
+                "notWatchedCount", "notAnsweredCount", "redPacketAmount", "historyOrderCount"
+        );
+
+        return "dept".equals(dimension) ? deptFields : salesFields;
+    }
+
     /**
      * 获取短链课程看课记录详细信息
      */

+ 66 - 1
fs-company/src/main/java/com/fs/hisStore/controller/FsStoreStatisticsScrmController.java

@@ -16,6 +16,7 @@ import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
 import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.his.vo.AppWatchLogReportVO;
 import com.fs.his.vo.FsUserReportVO;
 import com.fs.his.vo.WatchLogReportVO;
 import com.fs.hisStore.param.FsStoreStatisticsParam;
@@ -219,6 +220,69 @@ public class FsStoreStatisticsScrmController extends BaseController {
         return getDataTable(courseWatchLogService.selectWatchLogReportVO(param));
     }
 
+    /**
+     * 销售后台app看课统计 会员维度
+     * @param param
+     * @return
+     */
+    @GetMapping("/appWatchLogReportSales")
+    public TableDataInfo aPPWatchLogReportSales(FsCourseWatchLogStatisticsListParam param) {
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        return getDataTable(courseWatchLogService.selectUserAppWatchLogReportVO(param));
+    }
+
+    /**
+     * 销售后台app看课统计 会员维度导出
+     * @param param
+     * @return
+     */
+    @GetMapping("/appWatchLogReportSalesExport")
+    public AjaxResult appWatchLogReportSalesExport(FsCourseWatchLogStatisticsListParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppWatchLogReportVO> list = courseWatchLogService.selectUserAppWatchLogReportVO(param);
+        // 转换登录渠道和答题状态
+        list.forEach(this::convertAppWatchLogReportVO);
+        // 获取所有字段,排除会员维度的三个字段
+        List<String> selectedFields = getAppWatchLogReportFields();
+        ExcelUtil<AppWatchLogReportVO> util = new ExcelUtil<AppWatchLogReportVO>(AppWatchLogReportVO.class);
+        return util.exportExcelSelectedColumns(list, "APP看课统计报表", selectedFields);
+    }
+
+    /**
+     * 转换AppWatchLogReportVO字段
+     * - 登录渠道:有值显示"app",无值显示"小程序"
+     * - 答题状态:无值显示"未答题"
+     */
+    private void convertAppWatchLogReportVO(AppWatchLogReportVO vo) {
+        // 登录渠道转换
+        if (StringUtils.isNotEmpty(vo.getLoginChannel())) {
+            vo.setLoginChannel("app");
+        } else {
+            vo.setLoginChannel("小程序");
+        }
+        // 答题状态转换
+        if (StringUtils.isEmpty(vo.getAnswerStatus())) {
+            vo.setAnswerStatus("未答题");
+        }
+    }
+
+    /**
+     * 获取AppWatchLogReportVO需要导出的字段(排除特定字段)
+     */
+    private List<String> getAppWatchLogReportFields() {
+        // 需要排除的字段:app会员数、销售数、新注册app会员数
+        List<String> excludeFields = Arrays.asList("AppUserCount", "salesCount", "AppNewUser");
+        
+        return Arrays.stream(AppWatchLogReportVO.class.getDeclaredFields())
+                .filter(field -> field.isAnnotationPresent(Excel.class))
+                .map(Field::getName)
+                .filter(fieldName -> !excludeFields.contains(fieldName))
+                .collect(Collectors.toList());
+    }
+
     @GetMapping("/watchLogReportExport")
     public AjaxResult watchLogReportExport(FsCourseWatchLogStatisticsListParam param) {
         startPage();
@@ -315,7 +379,8 @@ public class FsStoreStatisticsScrmController extends BaseController {
     private boolean isEmpty(Object value) {
         if (value == null) return true;
         if (value instanceof String) return ((String) value).trim().isEmpty();
-        if (value instanceof Number) return ((Number) value).doubleValue() == 0;
+        // 数字类型不为空(包括0),确保历史订单数等字段能正常导出
+        if (value instanceof Number) return false;
         return false;
     }
 

+ 13 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java

@@ -11,6 +11,7 @@ import com.fs.company.param.CompanyParam;
 import com.fs.company.vo.CompanyCrmVO;
 import com.fs.company.vo.CompanyNameVO;
 import com.fs.company.vo.CompanyVO;
+import com.fs.his.vo.AppCourseReportVO;
 import com.fs.his.vo.OptionsVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -264,4 +265,16 @@ public interface CompanyMapper
             " `status` != 0   " +
             " and is_del != 1 ")
     List<CompanyVO> getCompanyDropList();
+
+    @Select({"<script> " +
+            "SELECT company_id as companyId, company_name as companyName " +
+            "FROM company " +
+            "WHERE status = 1 " +
+            "<if test=\"companyId != null\"> " +
+            "  AND company_id = #{companyId} " +
+            "</if> " +
+            "GROUP BY company_id " +
+            "ORDER BY company_id DESC " +
+            "</script>"})
+    List<AppCourseReportVO> selectAllCompanies(@Param("companyId") Long companyId);
 }

+ 71 - 3
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java

@@ -7,9 +7,7 @@ import com.fs.course.dto.WatchLogDTO;
 import com.fs.course.param.*;
 import com.fs.course.vo.*;
 import com.fs.his.dto.UserConditionDTO;
-import com.fs.his.vo.FsCourseReportVO;
-import com.fs.his.vo.FsUserReportVO;
-import com.fs.his.vo.WatchLogReportVO;
+import com.fs.his.vo.*;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.sop.vo.QwRatingVO;
@@ -658,8 +656,29 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
      */
     List<FsCourseReportVO> selectWatchStatistics(FsCourseWatchLogStatisticsListParam param);
 
+    /**
+     * app看课统计信息
+     * @param param
+     * @return
+     */
+    List<AppCourseReportVO> selectAppWatchStatistics(FsCourseWatchLogStatisticsListParam param);
+
     List<FsCourseReportVO> selectAnswerStatistics(FsCourseWatchLogStatisticsListParam param);
 
+    /**
+     * app 答题统计信息
+     * @param param
+     * @return
+     */
+    List<AppCourseReportVO> selectAppAnswerStatistics(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * app红包统计信息
+     * @param param
+     * @return
+     */
+    List<AppCourseReportVO> selectAppRedPacketStatistics(FsCourseWatchLogStatisticsListParam param);
+
     List<FsCourseReportVO> selectRedPacketStatistics(FsCourseWatchLogStatisticsListParam param);
 
     List<FsCourseReportVO> selectCampPeriodInfo(@Param("periodIds") List<Long> periodIds);
@@ -722,6 +741,13 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
      */
     List<WatchLogReportVO> selectUserBaseData(FsCourseWatchLogStatisticsListParam param);
 
+    /**
+     *app用户维度看课报表
+     *
+     */
+    List<AppWatchLogReportVO>  selectAppUserBaseData(FsCourseWatchLogStatisticsListParam param);
+
+
     /**
      * 销售端看课报表 销售维度基础数据
      * @param param
@@ -832,4 +858,46 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
      */
      @Select("select * from fs_course_watch_log where user_id = #{userId} and project = #{projectId} and create_time >= #{startTime}")
      FsCourseWatchLog selectByUserIdAndProjectId(@Param("userId") Long userId, @Param("projectId") Long projectId, @Param("startTime") Date startTime);
+
+    /**
+     * 查询有看课记录的活跃用户 ID 列表
+     * @param userIds 用户 ID 列表
+     * @return 活跃用户 ID 列表
+     */
+    List<Long> selectActiveUserIds(@Param("userIds") List<Long> userIds);
+
+    /**
+     * 销售维度APP会员数统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesUserStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售维度基础数据+看课统计(合并查询)
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesWatchStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售维度订单统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesOrderStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售维度营期信息
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesCampPeriod(@Param("periodIds") List<Long> periodIds);
+
+    /**
+     * 销售部门维度APP会员数统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppDeptUserStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售部门维度基础数据+看课统计(合并查询)
+     */
+    List<AppSalesWatchLogReportVO> selectAppDeptWatchStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售部门维度订单统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppDeptOrderStats(FsCourseWatchLogStatisticsListParam param);
 }

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

@@ -2,6 +2,7 @@ package com.fs.course.mapper;
 
 import java.util.List;
 import com.fs.course.domain.FsUserCourseStudyLog;
+import com.fs.his.vo.AppWatchLogReportVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -76,4 +77,13 @@ public interface FsUserCourseStudyLogMapper
 
     @Select("select count(0) from fs_user_course_study_log where user_id = #{userId} and course_id = #{courseId} and status = 1 ")
     Long selectStudyLogFinishCount(@Param("courseId") Long courseId,@Param("userId") Long userId);
+
+    /**
+     * 批量查询用户学习时长
+     * @param userIds 用户ID列表
+     * @param sTime 开始时间
+     * @param eTime 结束时间
+     * @return 学习时长统计列表
+     */
+    List<AppWatchLogReportVO> selectStudyDurationByUserIds(@Param("userIds") List<Long> userIds, @Param("sTime") String sTime, @Param("eTime") String eTime);
 }

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

@@ -101,6 +101,8 @@ public class FsCourseWatchLogStatisticsListParam extends BaseEntity {
      */
     private  Integer watchType;
 
+    private  List<Long> logIds;
+
 
 
 }

+ 22 - 3
fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java

@@ -5,9 +5,7 @@ import com.fs.common.core.domain.R;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.param.*;
 import com.fs.course.vo.*;
-import com.fs.his.vo.FsCourseReportVO;
-import com.fs.his.vo.FsUserReportVO;
-import com.fs.his.vo.WatchLogReportVO;
+import com.fs.his.vo.*;
 import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.qw.vo.QwWatchLogStatisticsListVO;
 
@@ -151,6 +149,13 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
      */
     List<FsCourseReportVO> selectFsCourseReportVO(FsCourseWatchLogStatisticsListParam param);
 
+    /**
+     * app端看课统计报表
+     * @param param
+     * @return
+     */
+    List<AppCourseReportVO> selectAppCourseReportVO(FsCourseWatchLogStatisticsListParam param);
+
     /**
      * 会员积分统计报表
      * @param param
@@ -165,6 +170,20 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
      */
     List<WatchLogReportVO> selectWatchLogReportVO(FsCourseWatchLogStatisticsListParam param);
 
+    /**
+     * app端销售端用户看课统计报表
+     * @param param
+     * @return
+     */
+    List<AppWatchLogReportVO> selectUserAppWatchLogReportVO(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * app端销售维度看课统计报表
+     * @param param
+     * @return
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesWatchLogReportVO(FsCourseWatchLogStatisticsListParam param);
+
     /**
      * 根据看课记录id获取所有的外部联系人ids
      * @param watchLogIds

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

@@ -19,6 +19,7 @@ import com.fs.company.cache.ICompanyCacheService;
 import com.fs.company.cache.ICompanyUserCacheService;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyMapper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
@@ -31,13 +32,13 @@ import com.fs.course.service.cache.IFsUserCourseVideoCacheService;
 import com.fs.course.vo.*;
 import com.fs.his.config.FsSysConfig;
 import com.fs.his.domain.FsUser;
+import com.fs.his.dto.AppUserCompanyDTO;
 import com.fs.his.dto.UserConditionDTO;
+import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsUserService;
 import com.fs.his.utils.ConfigUtil;
 import com.fs.his.utils.PhoneUtil;
-import com.fs.his.vo.FsCourseReportVO;
-import com.fs.his.vo.FsUserReportVO;
-import com.fs.his.vo.WatchLogReportVO;
+import com.fs.his.vo.*;
 import com.fs.qw.Bean.MsgBean;
 import com.fs.qw.cache.IQwExternalContactCacheService;
 import com.fs.qw.cache.IQwUserCacheService;
@@ -155,6 +156,15 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Autowired
     private SysDictDataMapper dictDataMapper;
 
+    @Autowired
+    private FsUserMapper userMapper;
+
+    @Autowired
+    private CompanyMapper companyMapper;
+
+    @Autowired
+    private FsUserCourseStudyLogMapper fsUserCourseStudyLogMapper;
+
     /**
      * 查询短链课程看课记录
      *
@@ -1346,6 +1356,102 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 redPacketStatistics, param);
     }
 
+    @Override
+    public List<AppCourseReportVO> selectAppCourseReportVO(FsCourseWatchLogStatisticsListParam param) {
+        // 时间转字符串
+        if (param.getSTime() != null && param.getETime() != null) {
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
+            param.setStartDate(simpleDateFormat.format(param.getSTime()));
+            param.setEndDate(simpleDateFormat.format(param.getETime()));
+        }
+
+        // 1. 查询所有公司列表
+        List<AppCourseReportVO> allCompanies = companyMapper.selectAllCompanies(param.getCompanyId());
+        if (CollectionUtils.isEmpty(allCompanies)) {
+            return Collections.emptyList();
+        }
+
+        // 2. 查询指定公司和时间范围内的 APP 会员
+        List<AppUserCompanyDTO> appUserList = userMapper.selectAppUserListForActiveCount(param);
+        
+        // 3. 如果有APP会员数据,则统计活跃用户数
+        if (!CollectionUtils.isEmpty(appUserList)) {
+            // 提取所有唯一的 userId 并查询活跃用户
+            Set<Long> allUserIds = appUserList.stream()
+                    .map(AppUserCompanyDTO::getUserId)
+                    .collect(Collectors.toSet());
+
+            Set<Long> activeUserSet = new HashSet<>(
+                    fsCourseWatchLogMapper.selectActiveUserIds(new ArrayList<>(allUserIds))
+            );
+
+            // 按公司分组统计 APP 会员数据 [总数, 活跃数]
+            Map<Long, int[]> companyStatsMap = new HashMap<>();
+            for (AppUserCompanyDTO dto : appUserList) {
+                Long cid = dto.getCompanyId();
+                int[] stats = companyStatsMap.computeIfAbsent(cid, k -> new int[]{0, 0});
+                stats[0]++;
+                if (activeUserSet.contains(dto.getUserId())) {
+                    stats[1]++;
+                }
+            }
+
+            // 组装 APP 会员统计数据
+            for (AppCourseReportVO vo : allCompanies) {
+                int[] stats = companyStatsMap.getOrDefault(vo.getCompanyId(), new int[]{0, 0});
+                vo.setAppUserCount(stats[0]);
+                vo.setActiveAppUserCount(stats[1]);
+            }
+        }
+
+        // 6. 批量查询看课、答题、红包统计数据
+        List<Long> companyIds = allCompanies.stream()
+                .map(AppCourseReportVO::getCompanyId)
+                .collect(Collectors.toList());
+        param.setCompanyIds(companyIds);
+
+        // 并行查询三个统计数据源
+        List<AppCourseReportVO> watchStatsList = fsCourseWatchLogMapper.selectAppWatchStatistics(param);
+        List<AppCourseReportVO> answerList = fsCourseWatchLogMapper.selectAppAnswerStatistics(param);
+        List<AppCourseReportVO> redpackList = fsCourseWatchLogMapper.selectAppRedPacketStatistics(param);
+
+        // 7. 转换为 Map 便于查找
+        Map<Long, AppCourseReportVO> watchStatsMap = watchStatsList.stream()
+                .collect(Collectors.toMap(AppCourseReportVO::getCompanyId, Function.identity()));
+        Map<Long, AppCourseReportVO> answerStatsMap = answerList.stream()
+                .collect(Collectors.toMap(AppCourseReportVO::getCompanyId, Function.identity(), (e, r) -> e));
+        Map<Long, AppCourseReportVO> redPacketStatsMap = redpackList.stream()
+                .collect(Collectors.toMap(AppCourseReportVO::getCompanyId, Function.identity(), (e, r) -> e));
+
+        // 8. 组装最终数据
+        for (AppCourseReportVO vo : allCompanies) {
+            Long companyId = vo.getCompanyId();
+
+            // 看课统计
+            AppCourseReportVO watchStats = watchStatsMap.get(companyId);
+            if (watchStats != null) {
+                vo.setPendingCount(watchStats.getPendingCount());
+                vo.setWatchingCount(watchStats.getWatchingCount());
+                vo.setFinishedCount(watchStats.getFinishedCount());
+                vo.setWatchRate(calculateWatchingRate(
+                        watchStats.getWatchingCount(),
+                        watchStats.getFinishedCount(),
+                        watchStats.getAccessCount()));
+            }
+
+            // 答题统计
+            AppCourseReportVO answerStats = answerStatsMap.getOrDefault(companyId, new AppCourseReportVO());
+            vo.setAnswerUserCount(answerStats.getAnswerUserCount());
+
+            // 红包统计
+            AppCourseReportVO redPacketStats = redPacketStatsMap.getOrDefault(companyId, new AppCourseReportVO());
+            vo.setPacketUserCount(redPacketStats.getPacketUserCount());
+            vo.setPacketAmount(redPacketStats.getPacketAmount());
+        }
+
+        return allCompanies;
+    }
+
     private List<FsCourseReportVO> assembleStatisticsResult(List<FsCourseReportVO> companyList, Map<String, FsCourseReportVO> watchStatistics, Map<String, FsCourseReportVO> answerStatistics, Map<String, FsCourseReportVO> redPacketStatistics, FsCourseWatchLogStatisticsListParam param) {
         for (FsCourseReportVO company : companyList) {
             FsCourseReportVO watchStats;
@@ -1399,6 +1505,36 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 .setScale(2, RoundingMode.HALF_UP);
     }
 
+    /**
+     * app 看课率(私域看课中人次/私域课待看课人次)
+     */
+
+
+// ... existing code ...
+    /**
+     * app 看课率((看课中人次+完课人次)/ 私域课总人次)
+     * @param watchingCount 私域看课中人次
+     * @param finishedCount 私域完课人次
+     * @param totalCount 私域课总人次
+     * @return 看课率(百分比,保留 2 位小数)
+     */
+    private BigDecimal calculateWatchingRate(Integer watchingCount, Integer finishedCount, Integer totalCount) {
+        // 防止除以 0
+        if (totalCount == null || totalCount == 0) {
+            return BigDecimal.ZERO;
+        }
+
+        // 防止空指针
+        int watching = watchingCount != null ? watchingCount : 0;
+        int finished = finishedCount != null ? finishedCount : 0;
+
+        // 看课率 = (看课中人次 + 完课人次) / 总人次 * 100%
+        return BigDecimal.valueOf(watching + finished)
+                .divide(BigDecimal.valueOf(totalCount), 4, RoundingMode.HALF_UP)
+                .multiply(BigDecimal.valueOf(100))
+                .setScale(2, RoundingMode.HALF_UP);
+    }
+// ... existing code ...
     /**
      * 计算完成率
      */
@@ -1568,6 +1704,316 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         return assembleStatisticsData(baseData, param);
     }
 
+    @Override
+    public List<AppWatchLogReportVO> selectUserAppWatchLogReportVO(FsCourseWatchLogStatisticsListParam param) {
+        if (StringUtils.isNotEmpty(param.getUserPhone())) {
+            //加密手机号
+            param.setUserPhone(PhoneUtil.encryptPhone(param.getUserPhone()));
+        }
+        // 时间转字符串
+        if (param.getSTime() != null && param.getETime() != null) {
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
+            param.setStartDate(simpleDateFormat.format(param.getSTime()));
+            param.setEndDate(simpleDateFormat.format(param.getETime()));
+        }
+        // 获取基础数据
+        List<AppWatchLogReportVO> baseData = fsCourseWatchLogMapper.selectAppUserBaseData(param);
+        if (CollectionUtils.isEmpty(baseData)) {
+            return Collections.emptyList();
+        }
+        // 获取统计数据和组装结果
+        return assembleAppStatisticsData(baseData, param);
+    }
+
+    /**
+     * 组装APP统计数据
+     */
+    private List<AppWatchLogReportVO> assembleAppStatisticsData(List<AppWatchLogReportVO> baseData, FsCourseWatchLogStatisticsListParam param) {
+        // 准备查询条件
+        List<Long> periods = baseData.stream().map(AppWatchLogReportVO::getPeriodId).collect(Collectors.toList());
+        List<Long> logIds = baseData.stream().map(AppWatchLogReportVO::getLogId).collect(Collectors.toList());
+        List<Long> userIds = baseData.stream().map(AppWatchLogReportVO::getUserId).collect(Collectors.toList());
+
+        // 批量查询统计数据
+        // 营期数据
+        Map<Long, WatchLogReportVO> perMap = convertCampPeriodToMap(fsCourseWatchLogMapper.selectCampPeriodByPeriod(periods));
+
+        // 红包数据
+        Map<Long, WatchLogReportVO> redPacketMap = convertRedPacketToMap(
+                fsCourseWatchLogMapper.selectRedPacketStats(logIds)
+        );
+
+        // 订单数据
+        Map<Long, WatchLogReportVO> orderMap = convertOrderToMap(
+                fsCourseWatchLogMapper.selectOrderStats(userIds, param)
+        );
+
+        // 答题数据
+        Map<Long, WatchLogReportVO> answerMap = convertAnswerToMap(
+                fsCourseWatchLogMapper.selectAnswerStats(logIds)
+        );
+
+        // 学习时长数据(来自fs_user_course_study_log表)- 使用字符串时间
+        Map<String, AppWatchLogReportVO> studyDurationMap = fsUserCourseStudyLogMapper.selectStudyDurationByUserIds(userIds, param.getStartDate(), param.getEndDate())
+                .stream()
+                .collect(Collectors.toMap(
+                        item -> item.getUserId() + "_" + item.getVideoId(),
+                        Function.identity()
+                ));
+
+        // 组装数据
+        for (AppWatchLogReportVO item : baseData) {
+            // 营期数据
+            WatchLogReportVO watchStats = perMap.getOrDefault(item.getPeriodId(), null);
+            if (watchStats != null) {
+                item.setPeriodName(watchStats.getPeriodName());
+                item.setTrainingCampName(watchStats.getTrainingCampName());
+            }
+
+            // 红包数据
+            WatchLogReportVO redPacketStats = redPacketMap.getOrDefault(item.getLogId(), null);
+            if (redPacketStats != null) {
+                item.setRedPacketAmount(redPacketStats.getRedPacketAmount());
+            }
+
+            // 订单数据
+            WatchLogReportVO order = orderMap.getOrDefault(item.getUserId(), null);
+            if (order != null) {
+                item.setHistoryOrderCount(order.getHistoryOrderCount());
+            }
+
+            // 答题数据
+            WatchLogReportVO answer = answerMap.getOrDefault(item.getLogId(), null);
+            if (answer != null) {
+                item.setAnswerStatus(answer.getAnswerStatus());
+            }
+
+            // 学习时长数据
+            AppWatchLogReportVO studyDuration = studyDurationMap.get(item.getUserId() + "_" + item.getVideoId());
+            if (studyDuration != null && studyDuration.getPublicCourseDuration() != null) {
+                // 将秒转换为时分秒格式
+                item.setPublicCourseDuration(formatDuration(Long.valueOf(studyDuration.getPublicCourseDuration())));
+            }
+        }
+        return baseData;
+    }
+
+    /**
+     * 将秒数转换为时分秒格式
+     */
+    private String formatDuration(Long totalSeconds) {
+        if (totalSeconds == null || totalSeconds <= 0) {
+            return "0秒";
+        }
+        long hours = totalSeconds / 3600;
+        long minutes = (totalSeconds % 3600) / 60;
+        long seconds = totalSeconds % 60;
+        if (hours > 0) {
+            return String.format("%d小时%d分%d秒", hours, minutes, seconds);
+        } else if (minutes > 0) {
+            return String.format("%d分%d秒", minutes, seconds);
+        } else {
+            return String.format("%d秒", seconds);
+        }
+    }
+
+
+    @Override
+    public List<AppSalesWatchLogReportVO> selectAppSalesWatchLogReportVO(FsCourseWatchLogStatisticsListParam param) {
+        // 时间转字符串
+        if (param.getSTime() != null && param.getETime() != null) {
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
+            param.setStartDate(simpleDateFormat.format(param.getSTime()));
+            param.setEndDate(simpleDateFormat.format(param.getETime()));
+        }
+
+        // 根据维度选择查询方式
+        String dimension = param.getDimension();
+        if ("dept".equals(dimension)) {
+            return selectAppDeptWatchLogReportVO(param);
+        } else {
+            return selectAppSalesWatchLogReportVOBySales(param);
+        }
+    }
+
+    /**
+     * 销售维度APP看课统计报表
+     */
+    private List<AppSalesWatchLogReportVO> selectAppSalesWatchLogReportVOBySales(FsCourseWatchLogStatisticsListParam param) {
+        // 1. 批量查询统计数据
+        // APP会员数统计(直接查fs_user表,按销售ID分组)
+        List<AppSalesWatchLogReportVO> userStatsList = fsCourseWatchLogMapper.selectAppSalesUserStats(param);
+        // 基础数据+看课统计+答题统计+红包统计(合并查询,按销售ID+营期ID+视频ID分组)
+        List<AppSalesWatchLogReportVO> watchStatsList = fsCourseWatchLogMapper.selectAppSalesWatchStats(param);
+        if (CollectionUtils.isEmpty(watchStatsList)) {
+            return Collections.emptyList();
+        }
+        // 订单统计
+        List<AppSalesWatchLogReportVO> orderStatsList = fsCourseWatchLogMapper.selectAppSalesOrderStats(param);
+
+        // 2. 查询营期信息
+        List<Long> periodIds = watchStatsList.stream()
+                .map(AppSalesWatchLogReportVO::getPeriodId)
+                .distinct()
+                .collect(Collectors.toList());
+        List<AppSalesWatchLogReportVO> campPeriodList = fsCourseWatchLogMapper.selectAppSalesCampPeriod(periodIds);
+
+        // 3. 转换为Map便于查找
+        // APP会员数统计按销售ID分组
+        Map<Long, AppSalesWatchLogReportVO> userStatsMap = userStatsList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getSalesId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 看课统计(已包含基础数据、答题、红包)按销售ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> watchStatsMap = watchStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getSalesId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 订单统计按销售ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> orderStatsMap = orderStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getSalesId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        Map<Long, AppSalesWatchLogReportVO> campPeriodMap = campPeriodList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getPeriodId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+
+        // 4. 组装数据
+        for (AppSalesWatchLogReportVO vo : watchStatsList) {
+            String key = vo.getSalesId() + "_" + vo.getPeriodId() + "_" + vo.getVideoId();
+
+            // APP会员数统计(按销售ID)
+            AppSalesWatchLogReportVO userStats = userStatsMap.get(vo.getSalesId());
+            if (userStats != null) {
+                vo.setAppUserCount(userStats.getAppUserCount());
+                vo.setNewAppUserCount(userStats.getNewAppUserCount());
+            }
+
+            // 计算完课率 = 完课数 / (完课数 + 未完课数 + 未看数) * 100%
+            int total = vo.getFinishedCount() + vo.getUnfinishedCount() + vo.getNotWatchedCount();
+            if (total > 0) {
+                vo.setCompletionRate(BigDecimal.valueOf(vo.getFinishedCount())
+                        .multiply(BigDecimal.valueOf(100))
+                        .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP));
+            } else {
+                vo.setCompletionRate(BigDecimal.ZERO);
+            }
+
+            // 订单统计
+            AppSalesWatchLogReportVO orderStats = orderStatsMap.get(key);
+            if (orderStats != null) {
+                vo.setHistoryOrderCount(orderStats.getHistoryOrderCount());
+            }
+
+            // 营期信息
+            AppSalesWatchLogReportVO campPeriod = campPeriodMap.get(vo.getPeriodId());
+            if (campPeriod != null) {
+                vo.setPeriodName(campPeriod.getPeriodName());
+                vo.setTrainingCampName(campPeriod.getTrainingCampName());
+            }
+        }
+
+        return watchStatsList;
+    }
+
+    /**
+     * 销售部门维度APP看课统计报表
+     */
+    private List<AppSalesWatchLogReportVO> selectAppDeptWatchLogReportVO(FsCourseWatchLogStatisticsListParam param) {
+        // 1. 批量查询统计数据
+        // APP会员数统计(直接查fs_user表,按部门ID分组)
+        List<AppSalesWatchLogReportVO> userStatsList = fsCourseWatchLogMapper.selectAppDeptUserStats(param);
+        // 基础数据+看课统计+答题统计+红包统计(合并查询,按部门ID+营期ID+视频ID分组)
+        List<AppSalesWatchLogReportVO> watchStatsList = fsCourseWatchLogMapper.selectAppDeptWatchStats(param);
+        if (CollectionUtils.isEmpty(watchStatsList)) {
+            return Collections.emptyList();
+        }
+        // 订单统计
+        List<AppSalesWatchLogReportVO> orderStatsList = fsCourseWatchLogMapper.selectAppDeptOrderStats(param);
+
+        // 2. 查询营期信息
+        List<Long> periodIds = watchStatsList.stream()
+                .map(AppSalesWatchLogReportVO::getPeriodId)
+                .distinct()
+                .collect(Collectors.toList());
+        List<AppSalesWatchLogReportVO> campPeriodList = fsCourseWatchLogMapper.selectAppSalesCampPeriod(periodIds);
+
+        // 3. 转换为Map便于查找
+        // APP会员数统计按部门ID分组
+        Map<Long, AppSalesWatchLogReportVO> userStatsMap = userStatsList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getDeptId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 看课统计(已包含基础数据、答题、红包)按部门ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> watchStatsMap = watchStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getDeptId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 订单统计按部门ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> orderStatsMap = orderStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getDeptId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        Map<Long, AppSalesWatchLogReportVO> campPeriodMap = campPeriodList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getPeriodId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+
+        // 4. 组装数据
+        for (AppSalesWatchLogReportVO vo : watchStatsList) {
+            String key = vo.getDeptId() + "_" + vo.getPeriodId() + "_" + vo.getVideoId();
+
+            // APP会员数统计(按部门ID)
+            AppSalesWatchLogReportVO userStats = userStatsMap.get(vo.getDeptId());
+            if (userStats != null) {
+                vo.setAppUserCount(userStats.getAppUserCount());
+                vo.setNewAppUserCount(userStats.getNewAppUserCount());
+                vo.setSalesCount(userStats.getSalesCount()); // 销售数(部门维度特有)
+            }
+
+            // 计算完课率 = 完课数 / (完课数 + 未完课数 + 未看数) * 100%
+            int total = vo.getFinishedCount() + vo.getUnfinishedCount() + vo.getNotWatchedCount();
+            if (total > 0) {
+                vo.setCompletionRate(BigDecimal.valueOf(vo.getFinishedCount())
+                        .multiply(BigDecimal.valueOf(100))
+                        .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP));
+            } else {
+                vo.setCompletionRate(BigDecimal.ZERO);
+            }
+
+            // 订单统计
+            AppSalesWatchLogReportVO orderStats = orderStatsMap.get(key);
+            if (orderStats != null) {
+                vo.setHistoryOrderCount(orderStats.getHistoryOrderCount());
+            }
+
+            // 营期信息
+            AppSalesWatchLogReportVO campPeriod = campPeriodMap.get(vo.getPeriodId());
+            if (campPeriod != null) {
+                vo.setPeriodName(campPeriod.getPeriodName());
+                vo.setTrainingCampName(campPeriod.getTrainingCampName());
+            }
+        }
+
+        return watchStatsList;
+    }
 
     /**
      * 根据维度获取基础数据

+ 23 - 0
fs-service/src/main/java/com/fs/his/dto/AppUserCompanyDTO.java

@@ -0,0 +1,23 @@
+package com.fs.his.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class AppUserCompanyDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 公司 ID */
+    private Long companyId;
+
+    /** 公司名称 */
+    private String companyName;
+
+    /** 用户 ID */
+    private Long userId;
+
+    /** 销售 ID */
+    private Long companyUserId;
+}

+ 50 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -7,9 +7,11 @@ import java.util.Map;
 import com.fs.course.domain.FsUserWatchCourseStatistics;
 import com.fs.course.domain.FsUserWatchStatistics;
 import com.fs.course.param.CourseAnalysisParam;
+import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
 import com.fs.course.vo.FsPeriodCountVO;
 import com.fs.course.vo.newfs.FsCourseAnalysisCountVO;
 import com.fs.his.domain.FsUser;
+import com.fs.his.dto.AppUserCompanyDTO;
 import com.fs.his.dto.FindUsersByDTO;
 import com.fs.his.param.FindUserByParam;
 import com.fs.his.param.FsUserParam;
@@ -82,6 +84,27 @@ public interface FsUserMapper
      */
     public int deleteFsUserByUserId(Long userId);
 
+    @Select("SELECT * \n" +
+            "FROM fs_user\n" +
+            "WHERE phone IS NOT NULL AND phone != ''\n" +
+            "GROUP BY phone \n" +
+            "HAVING COUNT(*) > 1")
+    List<FsUser> findDuplicatePhonesWithCount();
+
+    /**
+     * 查询 APP 总用户数(source 不为空的用户)
+     * @return APP 总用户数
+     */
+    @Select("SELECT COUNT(user_id) FROM fs_user WHERE source IS NOT NULL AND is_del = 0")
+    Long selectAppUserCount();
+
+    @Select("SELECT COUNT(user_id) \n" +
+            "FROM fs_user \n" +
+            "WHERE source IS NOT NULL \n" +
+            "  AND is_del = 0 \n" +
+            "  AND DATE(create_time) = CURDATE()")
+    Long selectAppNewUser();
+
     /**
      * 批量删除用户
      *
@@ -507,4 +530,31 @@ public interface FsUserMapper
     List<FsUserPageListExportVO> FsUserPageListExportVO(FsUserPageListExportParam param);
 
     void updatePasswordByPhone(@Param("password")String password, @Param("encryptPhone")String encryptPhone);
+
+    /**
+     * 查询公司维度的 APP 会员列表(按 user_id + company_user_id 去重)
+     * 用于后续计算活跃人数
+     * @return APP 会员列表
+     */
+    @Select({"<script> " +
+            "SELECT DISTINCT " +
+            "    c.company_id, " +
+            "    c.company_name, " +
+            "    ucu.user_id, " +
+            "    ucu.company_user_id " +
+            "FROM fs_user_company_user ucu " +
+            "INNER JOIN fs_user u ON ucu.user_id = u.user_id " +
+            "INNER JOIN company c ON ucu.company_id = c.company_id " +
+            "WHERE u.source IS NOT NULL " +
+            "  AND u.source != '' " +
+            "  AND u.status = 1 " +
+            "  AND ucu.status IN (0, 1) " +
+            "<if test=\"param.companyId != null\"> " +
+            "  AND c.company_id = #{param.companyId} " +
+            "</if> " +
+            "<if test=\"param.startDate != null and param.startDate != '' and param.endDate != null and param.endDate != ''\"> " +
+            "  AND DATE(u.create_time) &gt;= #{param.startDate} AND DATE(u.create_time) &lt;= #{param.endDate} " +
+            "</if> " +
+            "</script>"})
+    List<AppUserCompanyDTO> selectAppUserListForActiveCount(@Param("param")FsCourseWatchLogStatisticsListParam param);
 }

+ 6 - 0
fs-service/src/main/java/com/fs/his/service/IFsUserService.java

@@ -271,4 +271,10 @@ public interface IFsUserService
     R updatePasswordByPhone(String password, String encryptPhone);
 
     int realDeleteFsUserByUserId(Long userId);
+
+    //合并用户
+    void mergeUser();
+
+    //首页app用户统计
+    AppUserCountVO getAppUserCount();
 }

+ 111 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -1942,4 +1942,115 @@ public class FsUserServiceImpl implements IFsUserService {
         return fsUserMapper.deleteFsUserByUserId(userId);
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void mergeUser() {
+        List<FsUser> phonesWithCount = fsUserMapper.findDuplicatePhonesWithCount();
+        for (FsUser phoneWithCount : phonesWithCount) {
+            List<FsUser> usersWithSamePhone = fsUserMapper.selectFsUsersByPhoneLimitOne(phoneWithCount.getPhone());
+            if (usersWithSamePhone.size() > 1) {
+                this.mergeUser(usersWithSamePhone);
+            }
+        }
+    }
+
+    @Override
+    public AppUserCountVO getAppUserCount() {
+        AppUserCountVO result = new AppUserCountVO();
+        // 查询 APP 总用户数(source 不为空的用户)
+        Long appTotalUser = fsUserMapper.selectAppUserCount();
+        result.setAppTotalUser(appTotalUser != null ? appTotalUser : 0L);
+        //app新注册用户(当天的app用户)
+        Long appNewUser = fsUserMapper.selectAppNewUser();
+        result.setAppNewUser(appNewUser != null ? appNewUser : 0L);
+        return result;
+    }
+
+
+    /**
+     * 合并用户
+     * 1.合并逻辑是需要保留其中有union_id的用户 如果都有union_id 则去根据fs_course_watch_log 是否有看课记录去判断 有看课记录的就是需要保留的 如果都有看课记录则保留
+     * 2.积分总和合并
+     * 3.要是有union_id的用户 没有source字段 就要给他合并上去
+     * 4.合并后删除被合并的
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public  void  mergeUser(List<FsUser> userList){
+        if (userList == null || userList.isEmpty()) {
+            return;
+        }
+
+        // 按是否有union_id分组
+        FsUser userWithUnionId = null;  // 有union_id的用户(保留的用户)
+        List<FsUser> usersWithoutUnionId = new ArrayList<>();  // 没有union_id的用户(将被合并的用户)
+
+        for (FsUser user : userList) {
+            if (user.getUnionId() != null && !user.getUnionId().isEmpty()) {
+                userWithUnionId = user;
+            } else {
+                usersWithoutUnionId.add(user);
+            }
+        }
+
+        // 如果没有有union_id的用户,则选择第一个用户作为保留用户
+        if (userWithUnionId == null && !userList.isEmpty()) {
+            userWithUnionId = userList.get(0);
+            if (userList.size() > 1) {
+                usersWithoutUnionId = userList.subList(1, userList.size());
+            }
+        }
+
+        // 如果没有需要合并的用户,直接返回
+        if (usersWithoutUnionId.isEmpty()) {
+            logger.info("没有需要合并的用户");
+            return;
+        }
+
+        try {
+            // 计算积分总和
+            Long totalIntegral = userWithUnionId.getIntegral() != null ? userWithUnionId.getIntegral() : 0L;
+            String sourceToMerge = userWithUnionId.getSource();
+
+            // 累加需要合并用户的积分和其他需要合并的字段
+            for (FsUser user : usersWithoutUnionId) {
+                // 累加积分
+                if (user.getIntegral() != null) {
+                    totalIntegral += user.getIntegral();
+                }
+
+                // 如果保留用户没有source,而被合并用户有source,则使用被合并用户的source
+                if ((sourceToMerge == null || sourceToMerge.isEmpty()) &&
+                        user.getSource() != null && !user.getSource().isEmpty()) {
+                    sourceToMerge = user.getSource();
+                }
+            }
+
+            // 更新保留用户的积分和source
+            FsUser updateUser = new FsUser();
+            updateUser.setUserId(userWithUnionId.getUserId());
+            updateUser.setIntegral(totalIntegral);
+
+            // 只有当原用户没有source且找到有效的source时才更新
+            if ((userWithUnionId.getSource() == null || userWithUnionId.getSource().isEmpty())
+                    && sourceToMerge != null && !sourceToMerge.isEmpty()) {
+                updateUser.setSource(sourceToMerge);
+            }
+
+            // 执行更新
+            updateFsUser(updateUser);
+
+            // 删除被合并的用户
+            for (FsUser user : usersWithoutUnionId) {
+                fsUserMapper.deleteFsUserByUserId(user.getUserId());
+            }
+
+            logger.info("用户合并完成:保留用户ID={}, 合并用户数={}, 总积分={}",
+                    userWithUnionId.getUserId(), usersWithoutUnionId.size(), totalIntegral);
+
+        } catch (Exception e) {
+            logger.error("用户合并失败:{}", e.getMessage(), e);
+            throw new RuntimeException("用户合并过程中发生错误:" + e.getMessage());
+        }
+    }
+
 }

+ 80 - 0
fs-service/src/main/java/com/fs/his/vo/AppCourseReportVO.java

@@ -0,0 +1,80 @@
+package com.fs.his.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class AppCourseReportVO {
+    /** 公司id */
+    private  Long companyId;
+
+    /** 公司名称 */
+    @Excel(name = "销售公司")
+    private String companyName;
+
+    /**
+     * app新增注册人数
+     */
+    @Excel(name = "新增注册人数")
+    private  Integer appUserCount;
+
+    /** 活跃 APP 会员数 */
+    @Excel(name = "APP活跃人数")
+    private Integer activeAppUserCount;
+
+
+    /**
+     * 待看课人数
+     */
+    @Excel(name = "私域课待看课人次")
+    private  Integer pendingCount;
+
+    /**
+     * 看课中人数
+     */
+    @Excel(name = "私域课看课中人次")
+    private  Integer watchingCount;
+
+    /**
+     * 完课人数
+     */
+    @Excel(name = "私域课完课人次")
+    private  Integer finishedCount;
+
+
+    /**
+     * 看课率
+     */
+    @Excel(name = "看课率")
+    private BigDecimal watchRate;
+
+    /**
+     * 答题人数
+     */
+    @Excel(name = "答题人次")
+    private  Integer answerUserCount;
+
+    /**
+     * 红包领取数
+     */
+    @Excel(name = "红包领取人次")
+    private  Integer packetUserCount;
+
+    /**
+     * 红包金额
+     */
+    @Excel(name = "红包金额")
+    private  BigDecimal packetAmount;
+
+    /**
+     * 总人数
+     */
+    private  Integer  accessCount;
+
+    /**
+     * 日志id
+     */
+    private  Long logId;
+}

+ 89 - 0
fs-service/src/main/java/com/fs/his/vo/AppSalesWatchLogReportVO.java

@@ -0,0 +1,89 @@
+package com.fs.his.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class AppSalesWatchLogReportVO {
+
+    /** 销售ID */
+    private Long salesId;
+
+    /** 销售名称 */
+    @Excel(name = "销售")
+    private String salesName;
+
+    /** APP会员数 */
+    @Excel(name = "APP会员数")
+    private Integer appUserCount;
+
+    /** 新注册APP会员数 */
+    @Excel(name = "新注册APP会员数")
+    private Integer newAppUserCount;
+
+    /** 所属销售部门 */
+    @Excel(name = "所属销售部门")
+    private String salesDept;
+
+    /** 所属销售公司 */
+    @Excel(name = "所属销售公司")
+    private String salesCompany;
+
+    /** 训练营 */
+    @Excel(name = "训练营")
+    private String trainingCampName;
+
+    /** 营期 */
+    @Excel(name = "营期")
+    private String periodName;
+
+    /** 课程小节 */
+    @Excel(name = "课程小节")
+    private String videoTitle;
+
+    /** 完课数 */
+    @Excel(name = "完课数")
+    private Integer finishedCount;
+
+    /** 未完课数 */
+    @Excel(name = "未完课数")
+    private Integer unfinishedCount;
+
+    /** 完课率 */
+    @Excel(name = "完课率")
+    private BigDecimal completionRate;
+
+    /** 未看数 */
+    @Excel(name = "未看数")
+    private Integer notWatchedCount;
+
+    /** 未答题数 */
+    @Excel(name = "未答题数")
+    private Integer notAnsweredCount;
+
+    /** 红包金额 */
+    @Excel(name = "红包金额")
+    private BigDecimal redPacketAmount;
+
+    /** 历史疗法订单数 */
+    @Excel(name = "历史疗法订单数")
+    private Integer historyOrderCount;
+
+    /** 销售数(部门维度特有) */
+    @Excel(name = "销售数")
+    private Integer salesCount;
+
+    /** 营期ID */
+    private Long periodId;
+
+    /** 视频ID */
+    private Long videoId;
+
+    /** 部门ID */
+    private Long deptId;
+
+    /** 公司ID */
+    private Long companyId;
+}

+ 12 - 0
fs-service/src/main/java/com/fs/his/vo/AppUserCountVO.java

@@ -0,0 +1,12 @@
+package com.fs.his.vo;
+
+import lombok.Data;
+
+@Data
+public class AppUserCountVO {
+
+    private Long appTotalUser;
+
+    private  Long appNewUser;
+
+}

+ 146 - 0
fs-service/src/main/java/com/fs/his/vo/AppWatchLogReportVO.java

@@ -0,0 +1,146 @@
+package com.fs.his.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+public class AppWatchLogReportVO {
+
+    @Excel(name = "会员id")
+    private Long userId;
+    /**
+     * 昵称
+     */
+    @Excel(name = "会员昵称")
+    private String nickName;
+
+
+    /**
+     * app会员数
+     */
+    @Excel(name = "app会员数")
+    private  Integer AppUserCount;
+
+    /**
+     * 新注册app会员数
+     */
+    @Excel(name = "新注册app会员数")
+    private  Integer AppNewUser;
+
+    /**
+     * 登录渠道
+     */
+    @Excel(name = "登录渠道")
+    private  String loginChannel;
+
+    /**
+     * 销售数
+     */
+    @Excel(name = "销售数")
+    private  Integer salesCount;
+
+
+    /**
+     * 所属销售数
+     */
+    @Excel(name = "所属销售")
+    private  String salesName;
+
+    /**
+     * 所属销售部门
+     */
+    @Excel(name = "销售部门")
+    private  String salesDept;
+
+    /**
+     * 所属销售公司
+     */
+    @Excel(name = "所属销售公司")
+    private  String salesCompany;
+
+    /**
+     * 培训营名称
+     */
+    @Excel(name = "训练营")
+    private String trainingCampName;
+
+    /**
+     * 营期
+     */
+    @Excel(name = "营期")
+    private  String periodName;
+
+    /**
+     * 视频名称
+     */
+    @Excel(name = "小节名称")
+    private  String videoTitle;
+
+
+    /**
+     * 公开课播放时长
+     */
+    @Excel(name = "公开课播放时长")
+    private  String publicCourseDuration;
+
+    /**
+     * 私欲看课状态
+     */
+    @Excel(name = "私域课看课状态")
+    private  String privateWatchStatus;
+
+    /**
+     * 私欲课播放时长
+      */
+    @Excel(name = "私域课播放时长")
+    private  String privateWatchDuration;
+
+    /**
+     * 观看完成时间
+     */
+    @Excel(name = "完课时间",dateFormat = "yyyy-MM-dd")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date finishTime;
+
+    /**
+     * 回答状态
+     */
+    @Excel(name = "答题状态")
+    private  String answerStatus;
+
+    /**
+     * 领取红包金额
+     */
+    @Excel(name = "红包金额")
+    private BigDecimal redPacketAmount;
+
+    /**
+     * 历史订单数
+     */
+    @Excel(name = "历史疗法订单数")
+    private  Integer historyOrderCount;
+
+
+    /**
+     * 营期id
+     */
+    private  Long periodId;
+
+    /**
+     * 视频id
+     */
+    private  Long videoId;
+
+    /**
+     * 观看记录id
+     */
+    private  Long logId;
+
+    private  Long deptId;
+
+
+}

+ 8 - 0
fs-service/src/main/java/com/fs/store/vo/h5/FsUserPageListExportVO.java

@@ -128,4 +128,12 @@ public class FsUserPageListExportVO {
     @ApiModelProperty(value = "用户头像")
     private String avatar;
 
+    @ApiModelProperty(value = "APP来源")
+    @Excel(name = "APP来源")
+    private String source;
+
+    @ApiModelProperty(value = "登录设备")
+    @Excel(name = "登录设备")
+    private String loginDevice;
+
 }

+ 4 - 0
fs-service/src/main/java/com/fs/store/vo/h5/FsUserPageListVO.java

@@ -109,4 +109,8 @@ public class FsUserPageListVO {
     // 项目会员 主键
     private Long id;
 
+    private String loginDevice;//当前登录设备
+
+    private String source;//app来源
+
 }

+ 2 - 1
fs-service/src/main/resources/mapper/MerchantAppConfigMapper.xml

@@ -56,9 +56,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <where>
             <if test="merchantId != null and merchantId != ''"> and merchant_id = #{merchantId}</if>
             <if test="merchantType != null  and merchantType != ''"> and merchant_type = #{merchantType}</if>
-            <if test="appId != null  and appId != ''"> and app_id = #{appId}</if>
+            <if test="appId != null  and appId != ''"> and  FIND_IN_SET(#{appId},app_id)  </if>
             <if test="params.beginCreatedTime != null and params.beginCreatedTime != '' and params.endCreatedTime != null and params.endCreatedTime != ''"> and created_time between #{params.beginCreatedTime} and #{params.endCreatedTime}</if>
             <if test="isDeleted != null "> and is_deleted = #{isDeleted}</if>
+             <if test="id != null"> and id = #{id}</if>
         </where>
     </select>
 

+ 1 - 1
fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml

@@ -147,9 +147,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <if test="deptId != null and deptId != 0">
             AND (u.dept_id = #{deptId} OR u.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{deptId}, ancestors) ))
         </if>
-        GROUP BY u.user_id
         <!-- 数据范围过滤 -->
         ${params.dataScope}
+        GROUP BY u.user_id
     </select>
 
     <!-- 角色子查询 -->

+ 278 - 2
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -1994,6 +1994,102 @@ FROM
         </where>
         ORDER BY period.create_time DESC
     </select>
+    <select id="selectActiveUserIds" resultType="java.lang.Long">
+        SELECT DISTINCT user_id
+        FROM fs_course_watch_log
+        WHERE user_id IN
+        <foreach item="userId" collection="userIds" open="(" separator="," close=")">
+            #{userId}
+        </foreach>
+        and send_type = 1
+        and watch_type = 1
+    </select>
+    <select id="selectAppWatchStatistics" resultType="com.fs.his.vo.AppCourseReportVO">
+        SELECT
+            log_id logId,
+            company_id AS companyId,
+                COUNT(CASE WHEN log_type = 3 THEN log_id END) AS pendingCount,
+                COUNT(CASE WHEN log_type = 1 THEN log_id END) AS watchingCount,
+                COUNT(CASE WHEN log_type = 2 THEN log_id END) AS finishedCount,
+                count(log_id) as accessCount
+        FROM fs_course_watch_log
+        <where>
+            watch_type =1 and send_type = 1
+            <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+                AND create_time &gt;= #{startDate} AND create_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
+            </if>
+            <if test="companyIds != null and companyIds.size() > 0">
+                AND company_id IN
+                <foreach collection="companyIds" item="companyId" open="(" separator="," close=")">
+                    #{companyId}
+                </foreach>
+            </if>
+        </where>
+        GROUP BY company_id
+    </select>
+    <select id="selectAppAnswerStatistics" resultType="com.fs.his.vo.AppCourseReportVO">
+        SELECT
+            l.company_id AS companyId,
+            COUNT( l.log_id) AS answerUserCount
+        FROM fs_course_answer_logs l
+        LEFT JOIN fs_course_watch_log a on l.watch_log_id=a.log_id
+        WHERE l.company_id IN
+        <foreach collection="companyIds" item="companyId" open="(" separator="," close=")">
+            #{companyId}
+        </foreach>
+        and a.watch_type=1 and a.send_type=1
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            AND l.create_time &gt;= #{startDate} AND l.create_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
+        </if>
+        GROUP BY l.company_id
+    </select>
+    <select id="selectAppRedPacketStatistics" resultType="com.fs.his.vo.AppCourseReportVO">
+        SELECT
+        rpl.company_id AS companyId,
+        COUNT( rpl.log_id) AS packetUserCount,
+        COALESCE(SUM(rpl.amount), 0) AS packetAmount
+        FROM fs_course_red_packet_log rpl
+        LEFT JOIN fs_course_watch_log a on rpl.watch_log_id=a.log_id
+        WHERE rpl.company_id IN
+        <foreach collection="companyIds" item="companyId" open="(" separator="," close=")">
+            #{companyId}
+        </foreach>
+        and a.watch_type=1 and a.send_type=1
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            AND rpl.create_time &gt;= #{startDate} AND rpl.create_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
+        </if>
+        GROUP BY rpl.company_id
+    </select>
+    <select id="selectAppUserBaseData" resultType="com.fs.his.vo.AppWatchLogReportVO">
+        SELECT
+        log.user_id userId,
+        u.nick_name AS nickName,
+        u.source loginChannel,
+        cu.nick_name AS salesName,
+        c.company_name AS salesCompany,
+        cd.dept_name AS salesDept,
+        log.period_id periodId,
+        log.video_id videoId,
+        log.log_id logId,
+        log.create_time courseTime,
+        log.finish_time finishTime,
+        log.duration privateWatchDuration,
+        log.log_type privateWatchStatus,
+        cv.title AS videoTitle
+        FROM
+        fs_course_watch_log log
+        LEFT JOIN fs_user u ON u.user_id = log.user_id
+        LEFT JOIN fs_user_company_user cuu ON cuu.user_id = u.user_id
+        LEFT JOIN company_user cu ON cuu.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_user_course_video cv ON log.video_id = cv.video_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        group by log.user_id
+        ORDER BY u.register_date DESC
+    </select>
     <sql id="commonConditions">
         <!-- 销售公司 -->
         <if test="companyId != null and companyId != ''">
@@ -2015,8 +2111,8 @@ FROM
             AND cuu.project_id = #{project}
         </if>
         <!-- 时间范围 -->
-        <if test="sTime != null and eTime != null">
-            AND DATE(log.create_time)  BETWEEN #{sTime} AND #{eTime}
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            AND log.create_time &gt;= #{startDate} AND log.create_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
         </if>
         <!-- 训练营 -->
         <if test="trainingCampId != null and trainingCampId != ''">
@@ -2042,4 +2138,184 @@ FROM
             AND u.nick_name LIKE CONCAT('%', #{nickName}, '%')
         </if>
     </sql>
+
+    <!-- 销售维度APP会员数统计(直接查fs_user表) -->
+    <select id="selectAppSalesUserStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+            cu.user_id AS salesId,
+            COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END) AS appUserCount,
+            <choose>
+                <when test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+                    COUNT(DISTINCT CASE WHEN u.source IS NOT NULL AND u.register_date &gt;= #{startDate} AND u.register_date &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY) THEN u.user_id END)
+                </when>
+                <otherwise>
+                    COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END)
+                </otherwise>
+            </choose> AS newAppUserCount
+        FROM fs_user u
+        LEFT JOIN fs_user_company_user cuu ON cuu.user_id = u.user_id
+        LEFT JOIN company_user cu ON cuu.company_user_id = cu.user_id
+        LEFT JOIN company c ON cuu.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        WHERE u.source IS NOT NULL
+        <if test="companyId != null and companyId != ''">
+            AND cuu.company_id = #{companyId}
+        </if>
+        <if test="deptId != null and deptId != ''">
+            AND cu.dept_id = #{deptId}
+        </if>
+        <if test="salesId != null and salesId != ''">
+            AND cu.user_id = #{salesId}
+        </if>
+        <if test="project != null and project != ''">
+            AND cuu.project_id = #{project}
+        </if>
+        GROUP BY cu.user_id
+    </select>
+
+    <!-- 销售维度基础数据+看课统计(合并查询) -->
+    <select id="selectAppSalesWatchStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+            cu.user_id AS salesId,
+            cu.nick_name AS salesName,
+            cd.dept_name AS salesDept,
+            c.company_name AS salesCompany,
+            log.period_id AS periodId,
+            log.video_id AS videoId,
+            cv.title AS videoTitle,
+            cd.dept_id AS deptId,
+            c.company_id AS companyId,
+            COUNT(DISTINCT CASE WHEN log.log_type = '2' THEN log.log_id END) AS finishedCount,
+            COUNT(DISTINCT CASE WHEN log.log_type = '1' THEN log.log_id END) AS unfinishedCount,
+            COUNT(DISTINCT CASE WHEN log.log_type = '3' THEN log.log_id END) AS notWatchedCount,
+            COUNT(DISTINCT CASE WHEN a.log_id IS NULL THEN log.log_id END) AS notAnsweredCount,
+            COALESCE(SUM(rpl.amount), 0) AS redPacketAmount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_user_course_video cv ON log.video_id = cv.video_id
+        LEFT JOIN fs_course_answer_logs a ON a.watch_log_id = log.log_id
+        LEFT JOIN fs_course_red_packet_log rpl ON rpl.watch_log_id = log.log_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cu.user_id, log.period_id, log.video_id
+        ORDER BY cu.user_id
+    </select>
+
+    <!-- 销售维度订单统计 -->
+    <select id="selectAppSalesOrderStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+            cu.user_id AS salesId,
+            log.period_id AS periodId,
+            log.video_id AS videoId,
+            COUNT(DISTINCT CASE WHEN po.status = 3 THEN po.order_id END) AS historyOrderCount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_package_order po ON po.user_id = log.user_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cu.user_id, log.period_id, log.video_id
+    </select>
+
+    <!-- 销售维度营期信息 -->
+    <select id="selectAppSalesCampPeriod" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cp.period_id periodId,
+        cp.period_name periodName,
+        camp.training_camp_name trainingCampName
+        FROM
+        fs_user_course_period cp
+        LEFT JOIN fs_user_course_training_camp camp ON camp.training_camp_id = cp.training_camp_id
+        WHERE cp.period_id in
+        <foreach collection="periodIds" item="periodId" open="(" separator="," close=")">
+            #{periodId}
+        </foreach>
+    </select>
+
+    <!-- 销售部门维度APP会员数统计 -->
+    <select id="selectAppDeptUserStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+            cd.dept_id AS deptId,
+            COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END) AS appUserCount,
+            <choose>
+                <when test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+                    COUNT(DISTINCT CASE WHEN u.source IS NOT NULL AND u.register_date &gt;= #{startDate} AND u.register_date &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY) THEN u.user_id END)
+                </when>
+                <otherwise>
+                    COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END)
+                </otherwise>
+            </choose> AS newAppUserCount,
+            COUNT(DISTINCT cu.user_id) AS salesCount
+        FROM fs_user u
+        LEFT JOIN fs_user_company_user cuu ON cuu.user_id = u.user_id
+        LEFT JOIN company_user cu ON cuu.company_user_id = cu.user_id
+        LEFT JOIN company c ON cuu.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        WHERE u.source IS NOT NULL
+        <if test="companyId != null and companyId != ''">
+            AND cuu.company_id = #{companyId}
+        </if>
+        <if test="deptId != null and deptId != ''">
+            AND cu.dept_id = #{deptId}
+        </if>
+        <if test="salesId != null and salesId != ''">
+            AND cu.user_id = #{salesId}
+        </if>
+        <if test="project != null and project != ''">
+            AND cuu.project_id = #{project}
+        </if>
+        GROUP BY cd.dept_id
+    </select>
+
+    <!-- 销售部门维度基础数据+看课统计(合并查询) -->
+    <select id="selectAppDeptWatchStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+            cd.dept_id AS deptId,
+            cd.dept_name AS salesDept,
+            c.company_name AS salesCompany,
+            log.period_id AS periodId,
+            log.video_id AS videoId,
+            cv.title AS videoTitle,
+            c.company_id AS companyId,
+            COUNT(DISTINCT CASE WHEN log.log_type = '2' THEN log.log_id END) AS finishedCount,
+            COUNT(DISTINCT CASE WHEN log.log_type = '1' THEN log.log_id END) AS unfinishedCount,
+            COUNT(DISTINCT CASE WHEN log.log_type = '3' THEN log.log_id END) AS notWatchedCount,
+            COUNT(DISTINCT CASE WHEN a.log_id IS NULL THEN log.log_id END) AS notAnsweredCount,
+            COALESCE(SUM(rpl.amount), 0) AS redPacketAmount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_user_course_video cv ON log.video_id = cv.video_id
+        LEFT JOIN fs_course_answer_logs a ON a.watch_log_id = log.log_id
+        LEFT JOIN fs_course_red_packet_log rpl ON rpl.watch_log_id = log.log_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cd.dept_id, log.period_id, log.video_id
+        ORDER BY cd.dept_id
+    </select>
+
+    <!-- 销售部门维度订单统计 -->
+    <select id="selectAppDeptOrderStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+            cd.dept_id AS deptId,
+            log.period_id AS periodId,
+            log.video_id AS videoId,
+            COUNT(DISTINCT CASE WHEN po.status = 3 THEN po.order_id END) AS historyOrderCount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_package_order po ON po.user_id = log.user_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cd.dept_id, log.period_id, log.video_id
+    </select>
 </mapper>

+ 17 - 0
fs-service/src/main/resources/mapper/course/FsUserCourseStudyLogMapper.xml

@@ -109,4 +109,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{logId}
         </foreach>
     </delete>
+
+    <!-- 批量查询用户学习时长 -->
+    <select id="selectStudyDurationByUserIds" resultType="com.fs.his.vo.AppWatchLogReportVO">
+        SELECT
+            user_id AS userId,
+            video_id AS videoId,
+            CAST(SUM(total_times) AS SIGNED) AS publicCourseDuration
+        FROM fs_user_course_study_log
+        WHERE user_id IN
+        <foreach collection="userIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+        </foreach>
+        <if test="sTime != null and eTime != null">
+            AND create_time &gt;= #{sTime} AND create_time &lt; DATE_ADD(#{eTime}, INTERVAL 1 DAY)
+        </if>
+        GROUP BY user_id, video_id
+    </select>
 </mapper>

+ 5 - 1
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -370,6 +370,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         fs_user.nick_name as nickname,
         fs_user.avatar,
         fs_user.phone,
+        fs_user.source,
+        fs_user.login_device,
         ucu.update_time createTime,
         fs_user.remark,
         ucu.id,
@@ -2496,7 +2498,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             ucu.company_id,
             ucu.project_id,
             fs_user.integral,
-            if(fs_user.pay_count>0,1,0) as isBuy
+            if(fs_user.pay_count>0,1,0) as isBuy,
+            fs_user.source,
+            fs_user.login_device as loginDevice
         FROM fs_user
         left join fs_user_company_user ucu on ucu.user_id = fs_user.user_id
         <where>

+ 59 - 17
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -231,7 +231,7 @@ public class AppLoginController extends AppBaseController{
         if (user==null){
             return R.error("用户数据不存在");
         }
-        //验证码绑定,需要校验验证码
+//        //验证码绑定,需要校验验证码
         if (param.getBindType()!=null){
             String redisCode = redisCache.getCacheObject("sms:code:" + param.getPhone());
             if (StringUtils.isEmpty(redisCode)){
@@ -257,15 +257,27 @@ public class AppLoginController extends AppBaseController{
             if (StringUtils.isNotEmpty(userMap.getUnionId())&&!userMap.getUnionId().equals(user.getUnionId())){
                 return R.error("该手机号已绑定其他微信");
             }
-            //如果存在手机号也有用户,微信也有用户,保留创建时间比较久的用户
+            //合并规则修改 保留手机号存在的用户 合并掉unionid存在但手机号不存在的用户
             FsUser keepUser;
             FsUser deleteUser;
-            if (userMap.getCreateTime().before(user.getCreateTime())){
-                keepUser = userMap;
-                deleteUser = user;
-            }else {
+            // 判断哪个用户有手机号,优先保留有手机号的用户
+            if (StringUtils.isNotEmpty(user.getPhone()) && StringUtils.isEmpty(userMap.getPhone())) {
+                // 当前用户有手机号,保留当前用户
                 keepUser = user;
                 deleteUser = userMap;
+            } else if (StringUtils.isNotEmpty(userMap.getPhone()) && StringUtils.isEmpty(user.getPhone())) {
+                // userMap 用户有手机号,保留 userMap 用户
+                keepUser = userMap;
+                deleteUser = user;
+            } else {
+                // 如果两个用户都有或都没有手机号,则按创建时间判断,保留较早创建的用户
+                if (userMap.getCreateTime().before(user.getCreateTime())) {
+                    keepUser = userMap;
+                    deleteUser = user;
+                } else {
+                    keepUser = user;
+                    deleteUser = userMap;
+                }
             }
             keepUser.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
             keepUser.setSource(param.getSource());
@@ -330,19 +342,47 @@ public class AppLoginController extends AppBaseController{
                         userService.updateFsUser(user);
                         return generateTokenAndReturn(user);
                     }
+                    //改一下合并规则 将unionid存在的用户保留 合并掉手机号存在但unionid不存在的用户,而且如果被合并的用户如果有source 就转移过去用
+                    // 合并用户逻辑:优先保留有 union_id 的用户
                     FsUser keepUser;
                     FsUser deleteUser;
-                    if (user.getCreateTime().before(userByUnionId.getCreateTime())){
+
+                    // 判断哪个用户有 union_id,优先保留有 union_id 的用户
+                    if (StringUtils.isNotEmpty(user.getUnionId()) && StringUtils.isEmpty(userByUnionId.getUnionId())) {
+                        // 当前用户有 union_id,保留当前用户
                         keepUser = user;
                         deleteUser = userByUnionId;
-                    } else {
+                    } else if (StringUtils.isNotEmpty(userByUnionId.getUnionId()) && StringUtils.isEmpty(user.getUnionId())) {
+                        // union_id 用户有 union_id,保留 union_id 用户
                         keepUser = userByUnionId;
                         deleteUser = user;
+                    } else {
+                        // 如果两个用户都有或都没有 union_id,则按创建时间判断,保留较早创建的用户
+                        if (user.getCreateTime().before(userByUnionId.getCreateTime())) {
+                            keepUser = user;
+                            deleteUser = userByUnionId;
+                        } else {
+                            keepUser = userByUnionId;
+                            deleteUser = user;
+                        }
                     }
+
+                    // 更新保留用户的信息
                     keepUser.setUnionId(unionid);
                     keepUser.setPhone(param.getPhone());
-                    keepUser.setSource(param.getSource() != null ? param.getSource() : null );
-                    keepUser.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
+                    // 如果保留用户没有 source,而被删除用户有 source,则转移 source
+                    if (StringUtils.isEmpty(keepUser.getSource()) && StringUtils.isNotEmpty(deleteUser.getSource())) {
+                        keepUser.setSource(deleteUser.getSource());
+                    } else if (param.getSource() != null) {
+                        keepUser.setSource(param.getSource());
+                    }
+                    // 如果保留用户没有 loginDevice,而被删除用户有 loginDevice,则转移 loginDevice
+                    if (StringUtils.isEmpty(keepUser.getLoginDevice()) && StringUtils.isNotEmpty(deleteUser.getLoginDevice())) {
+                        keepUser.setLoginDevice(deleteUser.getLoginDevice());
+                    } else if (param.getLoginDevice() != null) {
+                        keepUser.setLoginDevice(param.getLoginDevice());
+                    }
+
                     keepUser.setNickName(nickname);
                     keepUser.setAvatar(avatar);
                     keepUser.setSex(sex);
@@ -408,18 +448,15 @@ public class AppLoginController extends AppBaseController{
         if (StringUtils.isEmpty(param.getPhone()) || StringUtils.isEmpty(param.getPassword())) {
             return R.error("账号或密码不能为空");
         }
-
         FsUser user = findUserByPhone(param.getPhone());
-
         // 校验用户是否存在及账号状态
         if (user == null) {
-            return R.error("账号不存在,请先注册账号");
+            return R.error("该手机账户不存在");
         } else if (user.getStatus() == 0) {
             return R.error("账号已停用");
-        } else if (StringUtils.isEmpty(user.getPassword())) {
-            return R.error("账号不存在,请先注册账号");
+        } else if (StringUtils.isEmpty(user.getUnionId())) {
+            return R.ok().put("isNew",true).put("phone",encryptPhone(param.getPhone()));
         }
-
         if (StringUtils.isNotEmpty(param.getJpushId())) {
             updateExistingUserJpushId(user, param.getJpushId());
         }
@@ -450,7 +487,7 @@ public class AppLoginController extends AppBaseController{
         }
         if (usersByPhone.size()==1){
             user = usersByPhone.get(0);
-            if (user.getUnionId()==null){
+            if (StringUtils.isEmpty(user.getUnionId())){
                 if (user.getPhone().length()<=11){
                     FsUser fsUser = new FsUser();
                     fsUser.setUserId(user.getUserId());
@@ -641,6 +678,11 @@ public class AppLoginController extends AppBaseController{
             //如果出现了一个手机号多个用户的情况,找出登陆过app的那个用户
             user.removeIf(fsUser -> StringUtils.isEmpty(fsUser.getHistoryApp()));
         }
+        // 检查用户是否存在 unionId,如果不存在就去绑定微信
+        FsUser currentUser = user.get(0);
+        if (StringUtils.isEmpty(currentUser.getUnionId())){
+            return R.ok().put("isNew",true).put("phone",encryptPhone(phone));
+        }
         String redisCode = redisCache.getCacheObject("sms:code:" + phone);
         if (StringUtils.isEmpty(redisCode)){
             return R.error("验证码已过期,请重新发送");

+ 784 - 0
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController2.java

@@ -0,0 +1,784 @@
+package com.fs.app.controller;
+
+
+import cn.hutool.core.collection.CollectionUtil;
+import cn.hutool.core.date.DateTime;
+import com.fs.app.annotation.Login;
+import com.fs.app.param.*;
+import com.fs.app.utils.WxUtil;
+import com.fs.common.VerifyCodeUtil;
+import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.service.ISmsService;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.sign.Md5Utils;
+import com.fs.core.config.WxOpenProperties;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.service.IFsUserNewTaskService;
+import com.fs.his.service.IFsUserService;
+import com.fs.his.vo.FsUserRegisterParam;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.ibatis.annotations.Param;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import static com.fs.his.utils.PhoneUtil.encryptPhone;
+
+
+@Api("app登录接口2")
+@RestController
+@RequestMapping(value="/app2/app")
+@Slf4j
+public class AppLoginController2 extends AppBaseController{
+    private final Logger logger = LoggerFactory.getLogger(this.getClass());
+    @Autowired
+    private IFsUserService userService;
+    @Autowired
+    private FsUserMapper userMapper;
+    @Autowired
+    private IFsUserNewTaskService userNewTaskService;
+    @Autowired
+    private WxOpenProperties openProperties;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private ISmsService smsService;
+    @ApiOperation("注册app用户")
+    @PostMapping("/register")
+    @RepeatSubmit
+    public R registerDoctor(@Validated @RequestBody FsUserRegisterParam param){
+        FsUser fsUser = findUserByPhone(param.getPhone());
+
+//        if (fsUser == null) {
+//            // 尝试使用加密后的手机号查询
+//            fsUser = userService.selectFsUserByPhone(encryptPhone(param.getPhone()));
+//        }
+
+        if (fsUser != null && StringUtils.isNotEmpty(fsUser.getPassword())) {
+            return R.error("此账号已经注册");
+        }
+
+        FsUser user = new FsUser();
+        if (fsUser != null) {
+            // 更新已有用户的密码
+            user.setUserId(fsUser.getUserId());
+            user.setPassword(Md5Utils.hash(param.getPassword()));
+            user.setUpdateTime(new DateTime());
+            userService.updateFsUser(user);
+            return R.ok("注册成功");
+        } else {
+            // 创建新用户
+            user.setPhone(param.getPhone());
+            user.setNickName("app用户" + param.getPhone().substring(param.getPhone().length() - 4));
+            user.setStatus(1);
+            user.setAvatar("https://cos.his.cdwjyyh.com/fs/20240926/420728ee06e54575ba82665dedb4756b.png");
+            user.setPassword(Md5Utils.hash(param.getPassword()));
+            user.setCreateTime(new Date());
+
+            if (userService.insertFsUser(user) > 0) {
+                return R.ok("注册成功");
+            } else {
+                return R.error("注册失败");
+            }
+        }
+
+    }
+
+    @ApiOperation("登录")
+    @PostMapping("/login")
+    @Transactional
+    public R login(@Validated @RequestBody FsUserLoginParam param) {
+        int loginType = param.getLoginType();
+        switch (loginType) {
+            case 1:
+                return handleLoginType1(param);
+            case 3:
+                return handleLoginType3(param);
+            default:
+                return R.error("请选择正确的登陆类型!");
+        }
+    }
+
+
+
+
+
+    @ApiOperation("找回密码")
+    @PostMapping("/editPwd")
+    public R login(@Validated @RequestBody FsUserEditPwdParam param) {
+        FsUser user = userService.selectFsUserByPhone(encryptPhone(param.getPhone()));
+        if (user==null){
+            return R.error("用户不存在");
+        }
+        String code = redisCache.getCacheObject(user.getPhone());
+        if(StringUtils.isEmpty(code)){
+            return R.error("验证码已过期");
+        }
+        if(!code.equals(param.getCode())){
+            return R.error("验证码不正确");
+        }
+        FsUser userMap=new FsUser();
+        userMap.setUserId(user.getUserId());
+        userMap.setPassword(Md5Utils.hash(param.getPassword()));
+        if (userService.updateFsUser(userMap)>0){
+            return R.ok("新密码设置成功");
+        }
+        return R.error("新密码设置失败,请稍后再试!");
+    }
+
+
+    @ApiOperation("微信登录")
+    @PostMapping("/loginByWeChat")
+    @Transactional
+    public R loginByWeChat(@Validated @RequestBody FsUserLoginByWeChatParam param) {
+        try {
+            if (StringUtils.isBlank(param.getCode())) {
+                return R.error("code不存在");
+            }
+            logger.info("zyp app微信登录,param:{},输出appid,{},secret:{}", param, openProperties.getAppId(), openProperties.getSecret());
+            Map result = WxUtil.getAccessToken(param.getCode(), openProperties.getAppId(), openProperties.getSecret());
+            String accessToken = result.get("access_token").toString();
+            String unionid = result.get("unionid").toString();
+
+            String openid = result.get("openid").toString();
+
+            Map userInfo = WxUtil.getUserInfo(accessToken, openid);
+
+            String nickname = userInfo.get("nickname").toString();
+            Integer sex = (Integer) userInfo.get("sex");
+            String avatar = userInfo.get("headimgurl").toString();
+            FsUser user = userService.selectFsUserByUnionid(unionid);
+
+            Map<String, Object> map = new HashMap<>();
+            if (user == null) {
+                user = new FsUser();
+                user.setLoginDevice(param.getLoginDevice()!=null ? param.getLoginDevice() : null);
+                user.setSource(param.getSource()!=null ? param.getSource() : null);
+                user.setNickName(nickname);
+                user.setAvatar(avatar);
+                if (sex!=0){
+                    user.setSex(sex);
+                }
+                user.setUnionId(unionid);
+                user.setCreateTime(new Date());
+                user.setAppId(openProperties.getAppId());
+                user.setStatus(1);
+                if (StringUtils.isNotEmpty(param.getJpushId())) {
+                    user.setJpushId(param.getJpushId());
+                }
+                String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+                user.setLastIp(ipAddr);
+                userService.insertFsUser(user);
+                map.put("isNew", true);
+                map.put("unionid",unionid);
+                return R.ok(map);
+            } else {
+                if (StringUtils.isNotEmpty(param.getJpushId())) {
+                    updateExistingUserJpushId(user, param.getJpushId());
+                }
+                if (StringUtils.isEmpty(user.getPhone())) {
+                    map.put("isNew", true);
+                    map.put("unionid",user.getUnionId());
+                    return R.ok(map);
+                }
+            }
+            int isFirstLogin = userNewTaskService.performTaskOne(user.getUserId());
+            String token = jwtUtils.generateToken(user.getUserId());
+            redisCache.setCacheObject("userToken:" + user.getUserId(), token, 604800, TimeUnit.SECONDS);
+            map.put("token", token);
+            map.put("user", user);
+            map.put("isFirst",isFirstLogin);
+            return R.ok(map);
+        }catch (Exception e){
+            logger.error("zyp 登录失败:{}", e.getMessage());
+            return R.error("登录失败");
+        }
+
+    }
+
+    @ApiOperation("绑定手机号")
+    @PostMapping("/setPhone")
+    @Transactional(rollbackFor = Exception.class)
+    public R setPhone(@Validated @RequestBody FsUserEditPhoneParam param) {
+        logger.info("fcj 绑定手机号入参:{}", param);
+        FsUser user = userService.selectFsUserByUnionid(param.getUnionId());
+        if (user==null){
+            return R.error("用户数据不存在");
+        }
+//        //验证码绑定,需要校验验证码
+        if (param.getBindType()!=null){
+            String redisCode = redisCache.getCacheObject("sms:code:" + param.getPhone());
+            if (StringUtils.isEmpty(redisCode)){
+                return R.error("验证码已过期,请重新发送");
+            }
+            if (!redisCode.equals(param.getCode())) {
+                return R.error("验证码错误");
+            }
+            redisCache.deleteObject("sms:code:" + param.getPhone());
+        }
+        FsUser userMap = findUserByPhone(param.getPhone());
+        //绑定的手机号已存在用户的情况,将微信登录的时候创建的新号的UnionId移动到老号中,删除新号(将两个号合并)
+        if (userMap!=null){
+            if (userMap.getUserId().equals(user.getUserId())) {
+                user.setPhone(param.getPhone());
+                user.setLoginDevice(param.getLoginDevice());
+                user.setSource(param.getSource());
+                String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+                user.setLastIp(ipAddr);
+                userService.updateFsUser(user);
+                return generateTokenAndReturn(user);
+            }
+            if (StringUtils.isNotEmpty(userMap.getUnionId())&&!userMap.getUnionId().equals(user.getUnionId())){
+                return R.error("该手机号已绑定其他微信");
+            }
+            //合并规则修改 保留手机号存在的用户 合并掉unionid存在但手机号不存在的用户
+            FsUser keepUser;
+            FsUser deleteUser;
+            // 判断哪个用户有手机号,优先保留有手机号的用户
+            if (StringUtils.isNotEmpty(user.getPhone()) && StringUtils.isEmpty(userMap.getPhone())) {
+                // 当前用户有手机号,保留当前用户
+                keepUser = user;
+                deleteUser = userMap;
+            } else if (StringUtils.isNotEmpty(userMap.getPhone()) && StringUtils.isEmpty(user.getPhone())) {
+                // userMap 用户有手机号,保留 userMap 用户
+                keepUser = userMap;
+                deleteUser = user;
+            } else {
+                // 如果两个用户都有或都没有手机号,则按创建时间判断,保留较早创建的用户
+                if (userMap.getCreateTime().before(user.getCreateTime())) {
+                    keepUser = userMap;
+                    deleteUser = user;
+                } else {
+                    keepUser = user;
+                    deleteUser = userMap;
+                }
+            }
+            keepUser.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
+            keepUser.setSource(param.getSource());
+            keepUser.setUnionId(user.getUnionId());
+            keepUser.setPhone(param.getPhone());
+            String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+            keepUser.setLastIp(ipAddr);
+            if (userService.updateFsUser(keepUser)>0){
+                userService.realDeleteFsUserByUserId(deleteUser.getUserId());
+                return generateTokenAndReturn(keepUser);
+            }
+            return R.error("绑定手机号失败");
+        }
+        user.setPhone(param.getPhone());
+        user.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null );
+        user.setSource(param.getSource()!= null ? param.getSource() : null);
+        userMap = new FsUser();
+        userMap.setLoginDevice(param.getLoginDevice());
+        userMap.setSource(param.getSource());
+        userMap.setUserId(user.getUserId());
+        userMap.setPhone(param.getPhone());
+        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+        userMap.setLastIp(ipAddr);
+        if (userService.updateFsUser(userMap)>0){
+            return generateTokenAndReturn(user);
+        }
+        return R.error("绑定手机号失败");
+    }
+
+    @ApiOperation("绑定微信")
+    @PostMapping("/bindWeChat")
+    @Transactional(rollbackFor = Exception.class)
+    public R bindWeChat(@Validated @RequestBody FsUserEditUnionidParam param) {
+        try {
+            logger.info("zyp app绑定微信,param:{}", param);
+            Map result = WxUtil.getAccessToken(param.getCode(), openProperties.getAppId(), openProperties.getSecret());
+            if (!result.containsKey("access_token")) {
+                return R.error("微信授权失败");
+            }
+            String accessToken = result.get("access_token").toString();
+            String unionid = result.get("unionid").toString();
+            String openid = result.get("openid").toString();
+            Map userInfo = WxUtil.getUserInfo(accessToken, openid);
+            String nickname = userInfo.get("nickname").toString();
+            Integer sex = (Integer) userInfo.get("sex");
+            String avatar = userInfo.get("headimgurl").toString();
+            FsUser user = findUserByPhone(param.getPhone());
+            if (user!=null && StringUtils.isEmpty(user.getUnionId())){
+                FsUser userByUnionId = userMapper.selectFsUserByUnionid(unionid);
+                //绑定的微信已存在用户的情况,将手机号一键登录的时候创建的新号的手机号移动到老号中,删除新号(将两个号合并)
+                if (userByUnionId!=null){
+                    if (StringUtils.isNotEmpty(userByUnionId.getPhone())&&!user.getPhone().equals(userByUnionId.getPhone())){
+                        return R.error("该微信已绑定其他手机号");
+                    }
+                    if (userByUnionId.getUserId().equals(user.getUserId())) {
+                        user.setPhone(param.getPhone());
+                        user.setLoginDevice(param.getLoginDevice());
+                        user.setSource(param.getSource());
+                        user.setUnionId(unionid);
+                        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+                        user.setLastIp(ipAddr);
+                        userService.updateFsUser(user);
+                        return generateTokenAndReturn(user);
+                    }
+                    //改一下合并规则 将unionid存在的用户保留 合并掉手机号存在但unionid不存在的用户,而且如果被合并的用户如果有source 就转移过去用
+                    // 合并用户逻辑:优先保留有 union_id 的用户
+                    FsUser keepUser;
+                    FsUser deleteUser;
+
+                    // 判断哪个用户有 union_id,优先保留有 union_id 的用户
+                    if (StringUtils.isNotEmpty(user.getUnionId()) && StringUtils.isEmpty(userByUnionId.getUnionId())) {
+                        // 当前用户有 union_id,保留当前用户
+                        keepUser = user;
+                        deleteUser = userByUnionId;
+                    } else if (StringUtils.isNotEmpty(userByUnionId.getUnionId()) && StringUtils.isEmpty(user.getUnionId())) {
+                        // union_id 用户有 union_id,保留 union_id 用户
+                        keepUser = userByUnionId;
+                        deleteUser = user;
+                    } else {
+                        // 如果两个用户都有或都没有 union_id,则按创建时间判断,保留较早创建的用户
+                        if (user.getCreateTime().before(userByUnionId.getCreateTime())) {
+                            keepUser = user;
+                            deleteUser = userByUnionId;
+                        } else {
+                            keepUser = userByUnionId;
+                            deleteUser = user;
+                        }
+                    }
+
+                    // 更新保留用户的信息
+                    keepUser.setUnionId(unionid);
+                    keepUser.setPhone(param.getPhone());
+                    // 如果保留用户没有 source,而被删除用户有 source,则转移 source
+                    if (StringUtils.isEmpty(keepUser.getSource()) && StringUtils.isNotEmpty(deleteUser.getSource())) {
+                        keepUser.setSource(deleteUser.getSource());
+                    } else if (param.getSource() != null) {
+                        keepUser.setSource(param.getSource());
+                    }
+                    // 如果保留用户没有 loginDevice,而被删除用户有 loginDevice,则转移 loginDevice
+                    if (StringUtils.isEmpty(keepUser.getLoginDevice()) && StringUtils.isNotEmpty(deleteUser.getLoginDevice())) {
+                        keepUser.setLoginDevice(deleteUser.getLoginDevice());
+                    } else if (param.getLoginDevice() != null) {
+                        keepUser.setLoginDevice(param.getLoginDevice());
+                    }
+
+                    keepUser.setNickName(nickname);
+                    keepUser.setAvatar(avatar);
+                    keepUser.setSex(sex);
+                    String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+                    keepUser.setLastIp(ipAddr);
+                    if (userService.updateFsUser(keepUser)>0){
+                        userService.realDeleteFsUserByUserId(deleteUser.getUserId());
+                        return generateTokenAndReturn(keepUser);
+                    }
+                    else {
+                        return R.error("绑定微信失败");
+                    }
+                }else {
+                    user.setSource(param.getSource() != null ? param.getSource() : null );
+                    user.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
+                    user.setNickName(nickname);
+                    user.setAvatar(avatar);
+                    user.setSex(sex);
+                    user.setUnionId(unionid);
+                    user.setAppOpenId(openid);
+                    String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+                    user.setLastIp(ipAddr);
+                    if (userService.updateFsUser(user)>0){
+                        return generateTokenAndReturn(user);
+                    }
+                    else {
+                        return R.error("绑定微信失败");
+                    }
+                }
+            }
+            return R.error("数据参数异常!");
+        }catch (Exception e){
+            logger.error("zyp 登录失败:{}", e.getMessage());
+            return R.error("登录失败");
+        }
+    }
+
+
+    private void updateExistingUserJpushId(FsUser user, String jpushId) {
+        FsUser userMap = new FsUser();
+        userMap.setUserId(user.getUserId());
+        userMap.setJpushId(jpushId);
+        if (StringUtils.isNotEmpty(user.getAppOpenId())) {
+            userMap.setAppOpenId(user.getAppOpenId());
+        }
+        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+        userMap.setLastIp(ipAddr);
+        userService.updateFsUser(userMap);
+    }
+
+    private R generateTokenAndReturn(FsUser user) {
+        String token = jwtUtils.generateToken(user.getUserId());
+        redisCache.setCacheObject("userToken:" + user.getUserId(), token, 604800, TimeUnit.SECONDS);
+        int isFirstLogin = userNewTaskService.performTaskOne(user.getUserId());
+        Map<String, Object> map = new HashMap<>();
+        map.put("token", token);
+        map.put("user", user);
+        map.put("isFirst",isFirstLogin);
+        return R.ok(map);
+    }
+
+    private R handleLoginType1(FsUserLoginParam param) {
+        if (StringUtils.isEmpty(param.getPhone()) || StringUtils.isEmpty(param.getPassword())) {
+            return R.error("账号或密码不能为空");
+        }
+        FsUser user = findUserByPhone(param.getPhone());
+        // 校验用户是否存在及账号状态
+        if (user == null) {
+            return R.error("该手机账户不存在");
+        } else if (user.getStatus() == 0) {
+            return R.error("账号已停用");
+        }
+        if (StringUtils.isNotEmpty(param.getJpushId())) {
+            updateExistingUserJpushId(user, param.getJpushId());
+        }
+
+        if (!Md5Utils.hash(param.getPassword()).equals(user.getPassword())) {
+            return R.error("密码不正确");
+        }
+
+        return generateTokenAndReturn(user);
+
+    }
+
+    private R handleLoginType3(FsUserLoginParam param) {
+        // 根据手机号查询用户
+        FsUser user = null;
+        List<FsUser> usersByPhone = findUsersByPhone(param.getPhone());
+        if (CollectionUtil.isEmpty(usersByPhone)){
+            FsUser newUser = createNewUser(param);
+            if (StringUtils.isNotEmpty(newUser.getJpushId())) {
+//                try {
+//                    //发送注册优惠券
+//                    fsUserCouponService.sendRegisterCoupon(newUser);
+//                } catch (Exception e) {
+//                    logger.error("发送注册优惠券失败:{}",e.getMessage());
+//                }
+            }
+            return R.ok().put("isNew",true).put("phone",encryptPhone(param.getPhone()));
+        }
+        if (usersByPhone.size()==1){
+            user = usersByPhone.get(0);
+            if (StringUtils.isEmpty(user.getUnionId())){
+                if (user.getPhone().length()<=11){
+                    FsUser fsUser = new FsUser();
+                    fsUser.setUserId(user.getUserId());
+                    fsUser.setPhone(encryptPhone(param.getPhone()));
+                    String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
+                    fsUser.setLastIp(ipAddr);
+                    userMapper.updateFsUser(fsUser);
+                    logger.info("zyp \n【手机加密】:{}",encryptPhone(param.getPhone()));
+                }
+                return R.ok().put("isNew",true).put("phone",encryptPhone(param.getPhone()));
+            }
+            if (StringUtils.isNotEmpty(param.getJpushId())) {
+                updateExistingUserJpushId(user, param.getJpushId());
+//                try {
+//                    //发送注册优惠券
+//                    fsUserCouponService.sendRegisterCoupon(user);
+//                } catch (Exception e) {
+//                    logger.error("发送注册优惠券失败:{}",e.getMessage());
+//                }
+            }
+        }else {
+            return R.ok().put("users",usersByPhone);
+        }
+        return generateTokenAndReturn(user);
+    }
+
+    private List<FsUser> findUsersByPhone(String phone) {
+        // 先根据加密手机号查询用户
+        String jiami = (encryptPhone(phone));
+        List<FsUser> fsUsers = userMapper.selectFsUsersByPhoneLimitOne(jiami);
+        if (CollectionUtil.isEmpty(fsUsers)) {
+            fsUsers = userMapper.selectFsUsersByPhoneLimitOne(encryptPhoneOldKey(phone));
+        }
+        // 如果没有找到用户,再根据手机号查询
+        if (CollectionUtil.isEmpty(fsUsers)) {
+            fsUsers = userMapper.selectFsUsersByPhoneLimitOne(phone);
+
+        }
+        return fsUsers;
+    }
+    private FsUser createNewUser(FsUserLoginParam param) {
+        FsUser newUser = new FsUser();
+        newUser.setLoginDevice(param.getLoginDevice() != null ? param.getLoginDevice() : null);
+        newUser.setSource(param.getSource() != null ? param.getSource() : null );
+        newUser.setNickName("匿名用户**");
+        newUser.setPhone(param.getPhone());
+        newUser.setCreateTime(new Date());
+        newUser.setStatus(1);
+        newUser.setAvatar("https://cos.his.cdwjyyh.com/fs/20240926/420728ee06e54575ba82665dedb4756b.png");
+        if (StringUtils.isNotEmpty(param.getJpushId())) {
+            newUser.setJpushId(param.getJpushId());
+        }
+        userService.insertFsUser(newUser);
+        return newUser;
+    }
+
+    private FsUser findUserByPhone(String phone) {
+        // 先根据加密手机号查询用户
+        String jiami = (encryptPhone(phone));
+        FsUser user = userMapper.selectFsUserByPhoneLimitOne(jiami);
+
+        // 如果没有找到用户,再根据手机号查询
+        if (user == null) {
+            user = userMapper.selectFsUserByPhoneLimitOne(phone);
+
+        }
+        return user;
+    }
+
+    @PostMapping("/loginTest")
+    public R loginTest(@RequestBody String userId) {
+        FsUser user = userMapper.selectFsUserByUserId(Long.parseLong(userId));
+        if (user!=null){
+            String token = jwtUtils.generateToken(user.getUserId());
+            redisCache.setCacheObject("userToken:" + user.getUserId(), token, 604800, TimeUnit.SECONDS);
+            Map<String, Object> map = new HashMap<>();
+            map.put("token", token);
+            map.put("user", user);
+            return R.ok(map);
+        }
+        return R.error("用户不存在!");
+    }
+
+    /**
+     * 退出清除jpushId
+     */
+    @Login
+    @GetMapping("/logout")
+    public R logout() {
+        String userId = getUserId();
+        FsUser fsUser = new FsUser();
+        fsUser.setUserId(Long.parseLong(userId));
+        fsUser.setJpushId("");
+        if (userMapper.updateFsUser(fsUser)>0) {
+            return R.ok();
+        } else {
+            return R.error("用户不存在!");
+        }
+    }
+
+    /**
+     * 更新jpushId
+     */
+    @Login
+    @GetMapping("/updatePushId")
+    public R updatePushId(@Param("pushId") String pushId) {
+        String userId = getUserId();
+        FsUser fsUser = new FsUser();
+        fsUser.setUserId(Long.parseLong(userId));
+        fsUser.setJpushId(pushId);
+        if (userMapper.updateFsUser(fsUser)>0) {
+            return R.ok();
+        } else {
+            return R.error("用户不存在!");
+        }
+    }
+
+    @PostMapping("/sendCode")
+    public R sendCode(@RequestBody Map<String, String> body){
+        String phone = body.get("phone");
+        String encryptPhone = encryptPhone(phone);
+//        List<FsUser> user = userService.selectFsUserListByPhone(encryptPhone);
+//        if(CollectionUtil.isEmpty(user)){
+//            user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
+//        }
+//        if (CollectionUtil.isEmpty(user)){
+//            return R.error("此电话号码未绑定用户");
+//        }
+
+        // 验证码 key(3分钟有效)
+        String smsCodeKey = "sms:code:" + phone;
+        // 冷却 key(60秒内不能重复发送)
+        String smsCooldownKey = "sms:cooldown:" + phone;
+
+        // 判断是否在 60 秒冷却期
+        if (redisCache.getCacheObject(smsCooldownKey) != null) {
+            return R.error("验证码已发送,请稍后再试");
+        }
+
+        // 生成新验证码
+        String smsCode = VerifyCodeUtil.generateCode();
+
+        // 发送短信
+        smsService.sendCaptcha(phone, smsCode, "验证码");
+
+        // 缓存验证码(3分钟有效)
+        redisCache.setCacheObject(smsCodeKey, smsCode, 180, TimeUnit.SECONDS);
+        // 设置冷却时间(60秒内不能再发)
+        redisCache.setCacheObject(smsCooldownKey, "1", 60, TimeUnit.SECONDS);
+
+        return R.ok("验证码已发送");
+    }
+
+    /**
+     * 用于查询 使用老的数据加密
+     * @param text
+     * @return
+     */
+    private static String OLD_KEY = "2c8d1a7f4e9b3c6ae6d5c4b3a291f8c9";
+    public static String encryptPhoneOldKey(String text) {
+        String encryptedText=null;
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec(OLD_KEY.getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            // Encryption
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
+            byte[] encryptedBytes = cipher.doFinal(text.getBytes());
+            encryptedText = Base64.getEncoder().encodeToString(encryptedBytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return encryptedText;
+    }
+
+    @PostMapping("/loginByPhone")
+    public R loginByPhone(@RequestBody Map<String,String> map){
+        String phone = map.get("phone");
+        String code = map.get("code");
+        String encryptPhone = encryptPhone(phone);
+        List<FsUser> user = userService.selectFsUserListByPhone(encryptPhone);
+        if (CollectionUtil.isEmpty(user)){
+            user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
+        }
+        if (CollectionUtil.isEmpty(user)){
+            return R.error("此电话号码未绑定用户");
+        }
+        if (user.size()>1){
+            //如果出现了一个手机号多个用户的情况,找出登陆过app的那个用户
+            user.removeIf(fsUser -> StringUtils.isEmpty(fsUser.getHistoryApp()));
+        }
+        String redisCode = redisCache.getCacheObject("sms:code:" + phone);
+        if (StringUtils.isEmpty(redisCode)){
+            return R.error("验证码已过期,请重新发送");
+        }
+        if (!redisCode.equals(code)) {
+            return R.error("验证码错误");
+        }
+        updateExistingUserJpushId(user.get(0), map.get("jpushId"));
+        return generateTokenAndReturn(user.get(0));
+    }
+
+    @PostMapping("/registerSendCode")
+    public R registerSendCode(@RequestBody Map<String, String> body){
+        String phone = body.get("phone");
+        String encryptPhone = encryptPhone(phone);
+        List<FsUser> user = userService.selectFsUserListByPhone(encryptPhone);
+        if(CollectionUtil.isEmpty(user)){
+            user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
+        }
+        if (!CollectionUtil.isEmpty(user)){
+            return R.error("此电话号码已注册");
+        }
+
+        // 验证码 key(存验证码,3分钟有效)
+        String smsCodeKey = "sms:code:" + phone;
+        // 发送冷却 key(限制60秒内不能再次发送)
+        String smsCooldownKey = "sms:cooldown:" + phone;
+
+        // 判断是否在60秒冷却时间内
+        if (redisCache.getCacheObject(smsCooldownKey) != null) {
+            return R.error("验证码已发送,请稍后再试");
+        }
+
+        // 生成新的验证码
+        String smsCode = VerifyCodeUtil.generateCode();
+
+        // 发送短信
+        smsService.sendCaptcha(phone, smsCode, "验证码");
+
+        // 缓存验证码(3分钟有效)
+        redisCache.setCacheObject(smsCodeKey, smsCode, 180, TimeUnit.SECONDS);
+        // 设置冷却时间(60秒内不能再发)
+        redisCache.setCacheObject(smsCooldownKey, "1", 60, TimeUnit.SECONDS);
+
+        return R.ok("验证码已发送");
+    }
+
+    @PostMapping("/registerByPhone")
+    public R registerByPhone(@RequestBody Map<String,String> map){
+        String phone = map.get("phone");
+        String code = map.get("code");
+        String password = map.get("password");
+        String encryptPhone = encryptPhone(phone);
+        List<FsUser> users = userService.selectFsUserListByPhone(encryptPhone);
+        if (users == null || CollectionUtil.isEmpty(users)){
+            String s = encryptPhoneOldKey(phone);
+            users = userService.selectFsUserListByPhone(s);
+        }
+        if (!CollectionUtil.isEmpty(users)){
+            return R.error("此账号已经注册");
+        }
+        String redisCode = redisCache.getCacheObject("sms:code:" + phone);
+        if (StringUtils.isEmpty(redisCode)){
+            return R.error("验证码已过期,请重新发送");
+        }
+        if (!redisCode.equals(code)) {
+            return R.error("验证码错误");
+        }
+        FsUser user = new FsUser();
+        // 创建新用户
+        user.setPhone(phone);
+        user.setJpushId(map.get("jpushId"));
+        user.setSource(map.get("source"));
+        user.setNickName("app用户" + phone.substring(phone.length() - 4));
+        user.setStatus(1);
+        user.setAvatar("https://cos.his.cdwjyyh.com/fs/20240926/420728ee06e54575ba82665dedb4756b.png");
+        user.setPassword(Md5Utils.hash(password));
+        user.setCreateTime(new Date());
+        if (userService.insertFsUser(user) > 0) {
+            return R.ok("注册成功");
+        } else {
+            return R.error("注册失败");
+        }
+    }
+
+    @PostMapping("/resetPassword")
+    public R resetPassword(@RequestBody Map<String, String> body){
+        String phone = body.get("phone");
+        String code = body.get("code");
+        String newPassword = body.get("newPassword");
+        String confirmPassword = body.get("confirmPassword");
+        if (!newPassword.equals(confirmPassword)){
+            throw new ServiceException("两次输入密码不一致,请检查");
+        }
+        String encryptPhone = encryptPhone(phone);
+        List<FsUser> user = userService.selectFsUserListByPhone(encryptPhone);
+        if (CollectionUtil.isEmpty(user)){
+            user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
+        }
+        if (CollectionUtil.isEmpty(user)){
+            return R.error("此电话号码未绑定用户");
+        }
+        String redisCode = redisCache.getCacheObject("sms:code:" + phone);
+        if (StringUtils.isEmpty(redisCode)){
+            return R.error("验证码已过期,请重新发送");
+        }
+        if (!redisCode.equals(code)) {
+            return R.error("验证码错误");
+        }
+        String password = Md5Utils.hash(newPassword);
+        return userService.updatePasswordByPhone(password,encryptPhone);
+    }
+
+}

+ 113 - 4
fs-user-app/src/main/java/com/fs/app/controller/CompanyUserController.java

@@ -1,6 +1,7 @@
 package com.fs.app.controller;
 
 
+import cn.hutool.core.collection.CollectionUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.app.annotation.Login;
@@ -28,6 +29,7 @@ import com.fs.fastGpt.mapper.FastgptChatVoiceHomoMapper;
 import com.fs.fastgptApi.util.AudioUtils;
 import com.fs.fastgptApi.vo.AudioVO;
 import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.param.*;
 import com.fs.his.service.IFsPrescribeService;
 import com.fs.his.service.IFsUserService;
@@ -53,9 +55,12 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
 import javax.servlet.http.HttpServletRequest;
 import javax.validation.Valid;
 import java.io.*;
+import java.util.Base64;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -89,6 +94,9 @@ public class CompanyUserController extends AppBaseController {
     @Autowired
     private IFsUserService fsUserService;
 
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
 
     public static final String SOP_TEMP_VOICE_KEY = "sop:tempVoice";
 
@@ -138,13 +146,78 @@ public class CompanyUserController extends AppBaseController {
     @PostMapping("/bindCompanyUser")
     @Transactional
     public R bindCompanyUser(@Validated @RequestBody FsBindCompanyUserParam param, HttpServletRequest request) {
+        Long currentUserId = Long.parseLong(getUserId());
         CompanyUserUser map = new CompanyUserUser();
         map.setCompanyUserId(param.getCompanyUserId());
-        map.setUserId(Long.parseLong(getUserId()));
-        FsUser user = fsUserService.selectFsUserByUserId(Long.parseLong(getUserId()));
-        //设置用户手机号
+        map.setUserId(currentUserId);
+        FsUser user = fsUserService.selectFsUserByUserId(currentUserId);
         if(user != null && StringUtils.isNotEmpty(param.getPhone())){
-            //加密手机号
+            List<FsUser> usersByPhone = findUsersByPhone(param.getPhone());
+            //如果有多个相同手机号的用户 就合并用户保留unionid 存在的  只有一个用户就正常绑定手机号就可以了
+            if(CollectionUtil.isNotEmpty(usersByPhone)){
+                //和绑定的用户id比较 如果相同给出限制
+                for (FsUser fsUser : usersByPhone){
+                    if(fsUser.getUserId().equals(user.getUserId())){
+                        return R.error("您已注册康享银龄APP,如需修改手机号,请联系您的专属客服");
+                    }
+                }
+                // 有多个相同手机号的用户,需要合并用户
+//                if (usersByPhone.size() > 1) {
+//                    // 合并规则:优先保留有 union_id 的用户
+//                    FsUser keepUser = null;
+//                    FsUser deleteUser = null;
+//                    FsUser userByUnionId = usersByPhone.stream()
+//                            .filter(u -> StringUtils.isNotEmpty(u.getUnionId()))
+//                            .findFirst()
+//                            .orElse(null);
+//
+//                    if (userByUnionId != null) {
+//                        // 有 union_id 的用户保留
+//                        keepUser = userByUnionId;
+//                        // 找出需要删除的其他用户(第一个非保留用户)
+//                        FsUser finalKeepUser = keepUser;
+//                        deleteUser = usersByPhone.stream()
+//                                .filter(u -> !u.getUserId().equals(finalKeepUser.getUserId()))
+//                                .findFirst()
+//                                .orElse(null);
+//                    } else {
+//                        // 都没有 union_id,按创建时间保留较早的用户
+//                        keepUser = usersByPhone.get(0);
+//                        for (FsUser u : usersByPhone) {
+//                            if (u.getCreateTime().before(keepUser.getCreateTime())) {
+//                                keepUser = u;
+//                            }
+//                        }
+//                        FsUser finalKeepUser1 = keepUser;
+//                        deleteUser = usersByPhone.stream()
+//                                .filter(u -> !u.getUserId().equals(finalKeepUser1.getUserId()))
+//                                .findFirst()
+//                                .orElse(null);
+//                    }
+//
+//                    if (deleteUser != null && keepUser != null) {
+//                        // 更新保留用户的信息
+//                        keepUser.setPhone(param.getPhone());
+//                        // 如果保留用户没有 source,而被删除用户有 source,则转移 source
+//                        if (StringUtils.isEmpty(keepUser.getSource()) && StringUtils.isNotEmpty(deleteUser.getSource())) {
+//                            keepUser.setSource(deleteUser.getSource());
+//                        }
+//                        // 如果保留用户没有 loginDevice,而被删除用户有 loginDevice,则转移 loginDevice
+//                        if (StringUtils.isEmpty(keepUser.getLoginDevice()) && StringUtils.isNotEmpty(deleteUser.getLoginDevice())) {
+//                            keepUser.setLoginDevice(deleteUser.getLoginDevice());
+//                        }
+//                        // 删除被合并的用户
+//                        fsUserService.realDeleteFsUserByUserId(deleteUser.getUserId());
+//
+//                        // 如果当前用户不是保留用户,需要更新当前用户
+//                        if (!user.getUserId().equals(keepUser.getUserId())) {
+//                            currentUserId = keepUser.getUserId();
+//                            map.setUserId(currentUserId);
+//                            user = keepUser;
+//                        }
+//                    }
+//                }
+            }
             user.setPhone(param.getPhone());
             fsUserService.updateFsUser(user);
         }
@@ -159,6 +232,42 @@ public class CompanyUserController extends AppBaseController {
         return R.ok();
     }
 
+    private List<FsUser> findUsersByPhone(String phone) {
+        // 先根据加密手机号查询用户
+        String jiami = (encryptPhone(phone));
+        List<FsUser> fsUsers = fsUserMapper.selectFsUsersByPhoneLimitOne(jiami);
+        if (CollectionUtil.isEmpty(fsUsers)) {
+            fsUsers = fsUserMapper.selectFsUsersByPhoneLimitOne(encryptPhoneOldKey(phone));
+        }
+        // 如果没有找到用户,再根据手机号查询
+        if (CollectionUtil.isEmpty(fsUsers)) {
+            fsUsers = fsUserMapper.selectFsUsersByPhoneLimitOne(phone);
+
+        }
+        return fsUsers;
+    }
+
+    /**
+     * 用于查询 使用老的数据加密
+     * @param text
+     * @return
+     */
+    private static String OLD_KEY = "2c8d1a7f4e9b3c6ae6d5c4b3a291f8c9";
+    public static String encryptPhoneOldKey(String text) {
+        String encryptedText=null;
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec(OLD_KEY.getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            // Encryption
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
+            byte[] encryptedBytes = cipher.doFinal(text.getBytes());
+            encryptedText = Base64.getEncoder().encodeToString(encryptedBytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return encryptedText;
+    }
+
 
     @Login
     @ApiOperation("上传声纹")

+ 2 - 3
fs-user-app/src/main/java/com/fs/app/param/FsUserEditParam.java

@@ -12,7 +12,6 @@ import java.io.Serializable;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class FsUserEditParam implements Serializable {
-    @NotNull(message = "用户昵称不能为空!")
     @JsonAlias("nickname")
     private String nickname;
 
@@ -25,8 +24,8 @@ public class FsUserEditParam implements Serializable {
     private Long userId;
     private Integer isWeixinAuth;
 
-
-    public @NotNull(message = "用户昵称不能为空!") String getNickname() {
+//    @NotNull(message = "用户昵称不能为空!")
+    public String getNickname() {
         return nickname;
     }