Переглянути джерело

医健宝积分相关、APP、销售端等功能提交

yjwang 20 годин тому
батько
коміт
1885394efa
63 змінених файлів з 2841 додано та 95 видалено
  1. 129 0
      fs-admin/src/main/java/com/fs/his/controller/FsUserBindWxController.java
  2. 15 14
      fs-admin/src/main/java/com/fs/his/task/Task.java
  3. 11 8
      fs-common/src/main/java/com/fs/common/utils/txocr/TxOcrClient.java
  4. 33 1
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  5. 171 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyUserBindWxController.java
  6. 2 0
      fs-company/src/main/java/com/fs/company/controller/his/FsImFriendshipController.java
  7. 19 6
      fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java
  8. 32 0
      fs-service/src/main/java/com/fs/course/dto/SendProjectMsgDTO.java
  9. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseLinkService.java
  10. 138 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java
  11. 10 2
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyUserServiceImpl.java
  12. 1 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseOrderServiceImpl.java
  13. 2 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  14. 40 8
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  15. 42 0
      fs-service/src/main/java/com/fs/his/config/PointsGrantRuleConfig.java
  16. 57 0
      fs-service/src/main/java/com/fs/his/domain/FsUserBindWx.java
  17. 22 0
      fs-service/src/main/java/com/fs/his/domain/vo/AssociatedUserQuery.java
  18. 20 0
      fs-service/src/main/java/com/fs/his/domain/vo/AssociatedUserVO.java
  19. 21 0
      fs-service/src/main/java/com/fs/his/domain/vo/BatchAssignParam.java
  20. 23 0
      fs-service/src/main/java/com/fs/his/domain/vo/BatchBindParam.java
  21. 56 0
      fs-service/src/main/java/com/fs/his/domain/vo/BindListVO.java
  22. 30 0
      fs-service/src/main/java/com/fs/his/domain/vo/BindWxUserParam.java
  23. 34 0
      fs-service/src/main/java/com/fs/his/domain/vo/CompanyBindUserQuery.java
  24. 56 0
      fs-service/src/main/java/com/fs/his/domain/vo/CompanyBindUserVO.java
  25. 18 0
      fs-service/src/main/java/com/fs/his/domain/vo/DecryptPhoneParam.java
  26. 23 0
      fs-service/src/main/java/com/fs/his/domain/vo/ExternalContactQuery.java
  27. 26 0
      fs-service/src/main/java/com/fs/his/domain/vo/ExternalContactVO.java
  28. 22 0
      fs-service/src/main/java/com/fs/his/domain/vo/FsUserWxInfoVO.java
  29. 26 0
      fs-service/src/main/java/com/fs/his/domain/vo/UnassignedUserQuery.java
  30. 31 0
      fs-service/src/main/java/com/fs/his/domain/vo/UnassignedUserVO.java
  31. 18 0
      fs-service/src/main/java/com/fs/his/domain/vo/UnbindParam.java
  32. 19 0
      fs-service/src/main/java/com/fs/his/domain/vo/UserBindDetailVO.java
  33. 29 0
      fs-service/src/main/java/com/fs/his/domain/vo/UserDetailVO.java
  34. 1 0
      fs-service/src/main/java/com/fs/his/dto/PayloadDTO.java
  35. 104 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserBindWxMapper.java
  36. 22 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralLogsMapper.java
  37. 4 1
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  38. 5 0
      fs-service/src/main/java/com/fs/his/param/FsAddUserParam.java
  39. 63 0
      fs-service/src/main/java/com/fs/his/param/FsUserGrantPointsParam.java
  40. 96 0
      fs-service/src/main/java/com/fs/his/service/IFsUserBindWxService.java
  41. 20 0
      fs-service/src/main/java/com/fs/his/service/IFsUserIntegralLogsService.java
  42. 1 1
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  43. 1 1
      fs-service/src/main/java/com/fs/his/service/impl/FsImFriendshipServiceImpl.java
  44. 1 1
      fs-service/src/main/java/com/fs/his/service/impl/FsInquiryOrderServiceImpl.java
  45. 2 2
      fs-service/src/main/java/com/fs/his/service/impl/FsIntegralOrderServiceImpl.java
  46. 287 0
      fs-service/src/main/java/com/fs/his/service/impl/FsUserBindWxServiceImpl.java
  47. 280 1
      fs-service/src/main/java/com/fs/his/service/impl/FsUserIntegralLogsServiceImpl.java
  48. 8 8
      fs-service/src/main/java/com/fs/his/service/impl/FsUserInvitedServiceImpl.java
  49. 1 1
      fs-service/src/main/java/com/fs/his/service/impl/FsUserNewTaskServiceImpl.java
  50. 18 6
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  51. 1 1
      fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java
  52. 3 1
      fs-service/src/main/java/com/fs/hisStore/enums/SysConfigEnum.java
  53. 120 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  54. 9 0
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  55. 172 0
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  56. 1 1
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  57. 391 0
      fs-service/src/main/resources/mapper/his/FsUserBindWxMapper.xml
  58. 30 21
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  59. 2 1
      fs-user-app/src/main/java/com/fs/app/controller/IntegralController.java
  60. 1 1
      fs-user-app/src/main/java/com/fs/app/controller/PatientController.java
  61. 5 5
      fs-user-app/src/main/java/com/fs/app/controller/UserAddressController.java
  62. 11 1
      fs-user-app/src/main/java/com/fs/app/controller/store/StoreInfoCheckController.java
  63. 3 0
      fs-user-app/src/main/java/com/fs/app/param/FsUserLoginParam.java

+ 129 - 0
fs-admin/src/main/java/com/fs/his/controller/FsUserBindWxController.java

@@ -0,0 +1,129 @@
+package com.fs.his.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsUserBindWx;
+import com.fs.his.domain.vo.*;
+import com.fs.his.service.IFsUserBindWxService;
+import com.fs.his.utils.PhoneUtil;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 用户微信绑定中间表 Controller
+ */
+@Api("用户微信绑定管理")
+@RestController
+@RequestMapping("/his/userBindWx")
+public class FsUserBindWxController extends BaseController {
+
+    @Autowired
+    private IFsUserBindWxService fsUserBindWxService;
+
+    @ApiOperation("查询用户微信绑定详情")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:query')")
+    @GetMapping("/{id}")
+    public R getInfo(@PathVariable Long id) {
+        return R.ok(fsUserBindWxService.selectFsUserBindWxById(id));
+    }
+
+    @ApiOperation("查询用户微信绑定列表")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:query')")
+    @GetMapping("/list")
+    public R list(FsUserBindWx fsUserBindWx) {
+        return R.ok(fsUserBindWxService.selectFsUserBindWxList(fsUserBindWx));
+    }
+
+    @ApiOperation("查询未分配用户列表")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:query')")
+    @GetMapping("/unassignedList")
+    public TableDataInfo unassignedList(UnassignedUserQuery query) {
+        // 用户输入明文手机号,转加密后查询
+        String phone = query.getPhone();
+        if (StringUtils.isNotEmpty(phone)) {
+            query.setPhoneMk(PhoneUtil.encryptPhone(phone));
+            query.setPhone(null);
+        }
+        startPage();
+        List<UnassignedUserVO> list = fsUserBindWxService.selectUnassignedUserList(query);
+        return getDataTable(list);
+    }
+
+    @ApiOperation("新增用户微信绑定")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:add')")
+    @PostMapping
+    public R add(@Validated @RequestBody FsUserBindWx fsUserBindWx) {
+        fsUserBindWx.setCreateTime(new Date());
+        return fsUserBindWxService.insertFsUserBindWx(fsUserBindWx) > 0 ? R.ok() : R.error();
+    }
+
+    @ApiOperation("修改用户微信绑定")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:edit')")
+    @PutMapping
+    public R edit(@Validated @RequestBody FsUserBindWx fsUserBindWx) {
+        fsUserBindWx.setUpdateTime(new Date());
+        return fsUserBindWxService.updateFsUserBindWx(fsUserBindWx) > 0 ? R.ok() : R.error();
+    }
+
+    @ApiOperation("删除用户微信绑定")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:remove')")
+    @DeleteMapping("/{ids}")
+    public R remove(@PathVariable Long[] ids) {
+        return fsUserBindWxService.deleteFsUserBindWxByIds(ids) > 0 ? R.ok() : R.error();
+    }
+
+    @ApiOperation("查询绑定微信用户详情")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:query')")
+    @GetMapping("/boundUserInfo/{userId}")
+    public R boundUserInfo(@PathVariable Long userId) {
+        return R.ok(fsUserBindWxService.selectBoundUserInfo(userId));
+    }
+
+    @ApiOperation("批量绑定用户到销售公司")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:add')")
+    @PostMapping("/batchBind")
+    public R batchBind(@Valid @RequestBody BatchBindParam param) {
+        int count = fsUserBindWxService.batchBindUsers(param.getUserIds(), param.getCompanyId());
+        return R.ok("成功绑定 " + count + " 条,跳过 " + (param.getUserIds().size() - count) + " 条重复数据");
+    }
+
+    @ApiOperation("查询用户详情及绑定列表")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:query')")
+    @GetMapping("/detail/{fsUserId}")
+    public R detail(@PathVariable Long fsUserId) {
+        UserDetailVO userInfo = fsUserBindWxService.selectUserDetailByFsUserId(fsUserId);
+        if (userInfo == null) {
+            return R.error("用户不存在");
+        }
+        List<BindListVO> bindList = fsUserBindWxService.selectBindListByFsUserId(fsUserId);
+        R r = new R();
+        r.put("userInfo", userInfo);
+        r.put("bindList", bindList);
+        return r;
+    }
+
+    @ApiOperation("解绑指定绑定记录")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:remove')")
+    @DeleteMapping("/unbind/{id}")
+    public R unbind(@PathVariable Long id) {
+        return fsUserBindWxService.unbindWxUser(id) > 0 ? R.ok("解绑成功") : R.error("解绑失败");
+    }
+
+    @ApiOperation("解码手机号")
+    @PreAuthorize("@ss.hasPermi('his:userBindWx:query')")
+    @PostMapping("/decryptPhone")
+    public R decryptPhone(@Valid @RequestBody DecryptPhoneParam param) {
+        String decryptPhone = PhoneUtil.decryptPhone(param.getPhone());
+        return StringUtils.isNotEmpty(decryptPhone) ? R.ok().put("data", decryptPhone) : R.error("解码失败");
+    }
+}

+ 15 - 14
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -1603,6 +1603,8 @@ public class Task {
      * 生成销售看课链接
      * **/
     public void generateSalesViewClassLink(){
+
+
         //获取执行中的营期信息和营期课程
         List<FsUserCoursePeriodDays> periodDaysList = fsUserCoursePeriodDaysMapper.getExecuteInformation();
         if(!periodDaysList.isEmpty()){
@@ -1640,25 +1642,24 @@ public class Task {
                                 jsonObject.put("companyId",u.getCompanyId());
                                 jsonObject.put("companyUserId",u.getUserId());
                                 System.out.println("打印对应生成链接信息-----------------》:"+jsonObject.toJSONString());
-                                //生成看课链接
-                                fsCourseLinkService.getGotoWxAppLink("/pages_course/videovip?course="+jsonObject.toJSONString(),config.getLoginMiniAppId());
-                                try {
-                                    Thread.sleep(120);
-                                } catch (InterruptedException e) {
-                                    throw new RuntimeException(e);
-                                }
-
-                                String key = u.getUserId() + "_" + p.getProjectId();
-                                if(!checkMap.containsKey(key)){
-                                    //生成好友邀请链接
-                                    fsCourseLinkService.getGotoWxAppLink("/pages_course/becomeVIP?becomeVip="+jsonObject.toJSONString(),config.getLoginMiniAppId());
-                                    checkMap.put(key, 0L);
+                                // 生成全部3种类型的看课链接并缓存:1→urllink(wxmpurl→wxaurl), 2→urllink(wxaurl→wxmpurl), 3→generatescheme
+                                Integer[] linkTypes = {1, 2, 3};
+                                for (Integer linkType : linkTypes) {
+                                    fsCourseLinkService.getGotoWxAppLinkNew("/pages_course/videovip?course=" + jsonObject.toJSONString(), config.getLoginMiniAppId(), linkType);
                                     try {
-                                        Thread.sleep(120);
+                                        Thread.sleep(50);
                                     } catch (InterruptedException e) {
+                                        Thread.currentThread().interrupt();
                                         throw new RuntimeException(e);
                                     }
                                 }
+
+                                String key = u.getUserId() + "_" + p.getProjectId();
+                                if (!checkMap.containsKey(key)) {
+                                    // 生成全部3种类型的好友邀请链接并缓存
+                                    fsCourseLinkService.getGotoWxAppLinkNew("/pages_course/becomeVIP?becomeVip=" + jsonObject.toJSONString(), config.getLoginMiniAppId(), 1);
+                                    checkMap.put(key, 0L);
+                                }
                             });
                         }
                     }

+ 11 - 8
fs-common/src/main/java/com/fs/common/utils/txocr/TxOcrClient.java

@@ -19,8 +19,10 @@ import org.apache.commons.lang3.StringUtils;
 
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
@@ -31,7 +33,7 @@ import java.util.concurrent.atomic.AtomicReference;
 @Slf4j
 public class TxOcrClient  {
 
-    private static final String YPLS = "药品零售";
+    private static final Set<String> YPLS_KEYWORDS = new HashSet<>(Arrays.asList("药品零售", "零售药品"));
 
 
     /**
@@ -123,9 +125,10 @@ public class TxOcrClient  {
             String business = resp.getBusiness();
             if(StringUtils.isEmpty(keywords) && StringUtils.isNotEmpty(business)){
                 result.setBusinessStr(business);
-                result.setKeywords(YPLS);
-                result.setFlag(business.contains(YPLS));
-                result.setMessage(result.getFlag()?"包含"+YPLS:"不包含"+YPLS);
+                result.setKeywords("药品零售/零售药品");
+                boolean matched = YPLS_KEYWORDS.stream().anyMatch(business::contains);
+                result.setFlag(matched);
+                result.setMessage(matched ? "经营范围包含药品零售相关" : "经营范围不包含药品零售相关");
                 result.setPerson(resp.getPerson());
                 result.setRegNum(resp.getRegNum());
                 result.setAddress(resp.getAddress());
@@ -260,10 +263,10 @@ public class TxOcrClient  {
         return null;
     }
 
-//    public static void main(String [] args) {
-//        ContainsResult contains = enterpriseLicenseOCR("https://bjyjb-1362704775.cos.ap-chongqing.myqcloud.com/fs/20251119/c97e794b896f4066878913821a1e3287.jpg", "乙类非处方");
-//        System.out.println(contains.toString());
-//    }
+    public static void main(String [] args) {
+        ContainsResult contains = isContains("https://bjyjb-1362704775.cos.ap-chongqing.myqcloud.com/fs/20260709/deceff824f044862b08bf6b4f9e846fd.png",null);
+        System.out.println(contains.toString());
+    }
 
 
     /**

+ 33 - 1
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -18,6 +18,7 @@ import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsUserCoursePeriod;
 import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.dto.BatchUrgeCourseDTO;
+import com.fs.course.dto.SendProjectMsgDTO;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.param.FsCourseWatchLogListParam;
 import com.fs.course.param.FsWatchCourseTimeParam;
@@ -294,7 +295,7 @@ public class FsUserCourseVideoController extends AppBaseController {
         }else {
             return ResponseResult.fail(500,"请在后台配置主备小程序!");
         }
-        return ResponseResult.ok(courseLinkService.getGotoWxAppLink(linkStr,appid));
+        return ResponseResult.ok(courseLinkService.getGotoWxAppLinkNew(linkStr,appid,3));
     }
 
     @ApiOperation("会员批量发送课程消息")
@@ -310,6 +311,15 @@ public class FsUserCourseVideoController extends AppBaseController {
         return openIMService.batchSendCourse(batchSendCourseDTO);
     }
 
+    @Login
+    @ApiOperation("批量发送项目注册链接")
+    @PostMapping("/batchSendProjectMsg")
+    public OpenImResponseDTO batchSendProjectMsg(@RequestBody SendProjectMsgDTO dto) throws JsonProcessingException {
+        dto.setCompanyId(getCompanyId());
+        dto.setCompanyUserId(getCompanyUserId());
+        return openIMService.batchSendProjectMsg(dto);
+    }
+
     @ApiOperation("会员一键催课")
     @PostMapping("/batchUrgeCourse")
     public OpenImResponseDTO batchUrgeCourse(@RequestBody BatchUrgeCourseDTO batchUrgeCourseDTO) throws JsonProcessingException {
@@ -402,4 +412,26 @@ public class FsUserCourseVideoController extends AppBaseController {
         return ResponseResult.ok(appId);
     }
 
+    /**
+     * 获取跳转微信小程序的链接地址(新版,支持 generatescheme / generate_urllink)
+     */
+    @Login
+    @GetMapping("/getGotoWxAppLinkNew")
+    @ApiOperation("获取跳转微信小程序的链接地址(新版)")
+    public ResponseResult<String> getGotoWxAppLinkNew(String linkStr, String appid, Integer type) {
+        try {
+            String result = courseLinkService.getGotoWxAppLinkNew(linkStr, appid, type);
+            if (result == null || result.trim().isEmpty()) {
+                return ResponseResult.fail(500, "生成微信小程序链接失败");
+            }
+            if (result.contains("错误") || result.contains("失败")) {
+                return ResponseResult.fail(500, result);
+            }
+            return ResponseResult.ok(result);
+        } catch (Exception e) {
+            log.error("获取跳转微信小程序链接失败", e);
+            return ResponseResult.fail(500, "获取微信小程序链接失败:" + e.getMessage());
+        }
+    }
+
 }

+ 171 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyUserBindWxController.java

@@ -0,0 +1,171 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.annotation.Log;
+import com.fs.common.enums.BusinessType;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.his.domain.vo.*;
+import com.fs.his.service.IFsUserBindWxService;
+import com.fs.his.utils.PhoneUtil;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * 公司端用户微信绑定管理
+ */
+@Api("公司端用户微信绑定管理")
+@RestController
+@RequestMapping("/company/userBindWx")
+public class CompanyUserBindWxController extends BaseController {
+
+    @Autowired
+    private IFsUserBindWxService fsUserBindWxService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("查询用户绑定列表")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyBindUserQuery query) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        query.setCompanyId(loginUser.getCompany().getCompanyId());
+        // 非管理员仅看自己关联的数据
+        if (!loginUser.getUser().isAdmin()) {
+            query.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        // 用户输入明文手机号,转加密后查询
+        if (StringUtils.isNotEmpty(query.getPhone())) {
+            query.setPhoneMk(PhoneUtil.encryptPhone(query.getPhone()));
+            query.setPhone(null);
+        }
+        startPage();
+        List<CompanyBindUserVO> list = fsUserBindWxService.selectCompanyBindUserList(query);
+        return getDataTable(list);
+    }
+
+    @ApiOperation("解码手机号")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @PostMapping("/decryptPhone")
+    public R decryptPhone(@Valid @RequestBody DecryptPhoneParam param) {
+        String decryptPhone = PhoneUtil.decryptPhone(param.getPhone());
+        return StringUtils.isNotEmpty(decryptPhone) ? R.ok().put("data", decryptPhone) : R.error("解码失败");
+    }
+
+    @ApiOperation("搜索可绑定微信的用户")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @GetMapping("/searchBindUser")
+    public TableDataInfo searchBindUser(AssociatedUserQuery query) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        query.setCompanyId(loginUser.getCompany().getCompanyId());
+        if (!loginUser.getUser().isAdmin()) {
+            query.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        startPage();
+        List<AssociatedUserVO> list = fsUserBindWxService.selectAssociatedUserList(query);
+        return getDataTable(list);
+    }
+
+    @ApiOperation("绑定微信用户")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @Log(title = "绑定微信用户", businessType = BusinessType.UPDATE)
+    @PostMapping("/bindWxUser")
+    public R bindWxUser(@Valid @RequestBody BindWxUserParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        // 从绑定记录获取销售人员ID
+        com.fs.his.domain.FsUserBindWx bindWx = fsUserBindWxService.selectFsUserBindWxById(param.getBindId());
+        if (bindWx == null) {
+            return R.error("绑定记录不存在");
+        }
+        Long companyUserId = bindWx.getCompanyUserId() != null
+                ? bindWx.getCompanyUserId() : loginUser.getUser().getUserId();
+        fsUserBindWxService.bindWxUser(param.getBindId(), param.getBindUserId(), companyUserId,
+                param.getExternalContactId(), param.getProjectId(), param.getType());
+        return R.ok("绑定成功");
+    }
+
+    @ApiOperation("查询外部联系人列表")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @GetMapping("/externalContactList")
+    public TableDataInfo externalContactList(ExternalContactQuery query) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        query.setCompanyId(loginUser.getCompany().getCompanyId());
+        if (!loginUser.getUser().isAdmin()) {
+            query.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        startPage();
+        List<ExternalContactVO> list = fsUserBindWxService.selectExternalContactList(query);
+        return getDataTable(list);
+    }
+
+    @ApiOperation("获取公司销售人员列表")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @GetMapping("/companyUserList")
+    public R companyUserList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        CompanyUser query = new CompanyUser();
+        query.setCompanyId(loginUser.getCompany().getCompanyId());
+        if (!loginUser.getUser().isAdmin()) {
+            query.setUserId(loginUser.getUser().getUserId());
+        }
+        List<CompanyUser> list = companyUserService.selectCompanyUserList(query);
+        return R.ok().put("data", list);
+    }
+
+    @ApiOperation("解绑微信用户")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @Log(title = "解绑微信用户", businessType = BusinessType.UPDATE)
+    @PostMapping("/unbindWxUser")
+    public R unbindWxUser(@Valid @RequestBody UnbindParam param) {
+        fsUserBindWxService.unbindWxUser(param.getId());
+        return R.ok("解绑成功");
+    }
+
+    @ApiOperation("查询绑定的微信用户详情")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @GetMapping("/boundUserInfo/{userId}")
+    public R boundUserInfo(@PathVariable Long userId) {
+        AssociatedUserVO userInfo = fsUserBindWxService.selectBoundUserInfo(userId);
+        return userInfo != null ? R.ok().put("data", userInfo) : R.error("用户不存在");
+    }
+
+    @ApiOperation("批量分配销售人员")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:list')")
+    @Log(title = "批量分配销售", businessType = BusinessType.UPDATE)
+    @PostMapping("/batchAssign")
+    public R batchAssign(@Valid @RequestBody BatchAssignParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        // 校验所选销售是否属于当前公司
+        CompanyUser selected = companyUserService.selectCompanyUserById(param.getCompanyUserId());
+        if (selected == null || !loginUser.getCompany().getCompanyId().equals(selected.getCompanyId())) {
+            return R.error("所选销售不存在或不属于当前公司");
+        }
+        fsUserBindWxService.batchAssignCompanyUser(param.getIds(), param.getCompanyUserId());
+        return R.ok("分配成功");
+    }
+
+    @ApiOperation("解除分配")
+    @PreAuthorize("@ss.hasPermi('company:userBindWx:unassignCompanyUser')")
+    @Log(title = "解除分配", businessType = BusinessType.UPDATE)
+    @PostMapping("/unassignCompanyUser")
+    public R unassignCompanyUser(@Valid @RequestBody UnbindParam param) {
+        fsUserBindWxService.unassignCompanyUser(param.getId());
+        return R.ok("解除分配成功");
+    }
+}

+ 2 - 0
fs-company/src/main/java/com/fs/company/controller/his/FsImFriendshipController.java

@@ -182,6 +182,8 @@ public class FsImFriendshipController extends BaseController
             return R.error("操作失败,请传入外部用户关键信息!");
         }else if(param.getAddType() == 2 && param.getUserIds().size() > 1){
             return R.error("操作失败,自动发课添加好友只能选择一个用户!");
+        }else if(param.getAddType() == 1 && param.getProjectId() == null){
+            return R.error("操作失败,手动发课请选择看课项目!");
         }
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         if (!loginUser.getUser().isAdmin()) {

+ 19 - 6
fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java

@@ -720,6 +720,7 @@ public class CommonController {
      * **/
     @GetMapping("/generateSalesViewClassLink")
     public void generateSalesViewClassLink(){
+
         //获取执行中的营期信息和营期课程
         List<FsUserCoursePeriodDays> periodDaysList = fsUserCoursePeriodDaysMapper.getExecuteInformation();
         if(!periodDaysList.isEmpty()){
@@ -739,6 +740,7 @@ public class CommonController {
                 String json = configService.selectConfigByKey("course.config");
                 CourseConfig config = JSON.parseObject(json, CourseConfig.class);
                 Map<String,List<CompanyUser>> companyUserMap = companyUserList.stream().collect(Collectors.groupingBy(c->String.valueOf(c.getCompanyId())));
+                Map<String,Long> checkMap = new HashMap<>();
                 periodDaysList.forEach(p->{
                     String[] companyIdList= p.getCompanyId().split(",");
                     for (String companyId: companyIdList){
@@ -756,12 +758,23 @@ public class CommonController {
                                 jsonObject.put("companyId",u.getCompanyId());
                                 jsonObject.put("companyUserId",u.getUserId());
                                 System.out.println("打印对应生成链接信息-----------------》:"+jsonObject.toJSONString());
-                                //生成看课链接
-                            fsCourseLinkService.getGotoWxAppLink("/pages_course/videovip?course="+jsonObject.toJSONString(),config.getLoginMiniAppId());
-                                try {
-                                    Thread.sleep(300);
-                                } catch (InterruptedException e) {
-                                    throw new RuntimeException(e);
+                                // 生成全部3种类型的看课链接并缓存:1→urllink(wxmpurl→wxaurl), 2→urllink(wxaurl→wxmpurl), 3→generatescheme
+                                Integer[] linkTypes = {1, 2, 3};
+                                for (Integer linkType : linkTypes) {
+                                    fsCourseLinkService.getGotoWxAppLinkNew("/pages_course/videovip?course=" + jsonObject.toJSONString(), config.getLoginMiniAppId(), linkType);
+                                    try {
+                                        Thread.sleep(50);
+                                    } catch (InterruptedException e) {
+                                        Thread.currentThread().interrupt();
+                                        throw new RuntimeException(e);
+                                    }
+                                }
+
+                                String key = u.getUserId() + "_" + p.getProjectId();
+                                if (!checkMap.containsKey(key)) {
+                                    // 生成全部3种类型的好友邀请链接并缓存
+                                    fsCourseLinkService.getGotoWxAppLinkNew("/pages_course/becomeVIP?becomeVip=" + jsonObject.toJSONString(), config.getLoginMiniAppId(), 1);
+                                    checkMap.put(key, 0L);
                                 }
                             });
                         }

+ 32 - 0
fs-service/src/main/java/com/fs/course/dto/SendProjectMsgDTO.java

@@ -0,0 +1,32 @@
+package com.fs.course.dto;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 批量发送项目注册链接-入参
+ */
+@Data
+public class SendProjectMsgDTO implements Serializable {
+
+    @ApiModelProperty(value = "看课项目ID", required = true)
+    private Long projectId;
+
+    @ApiModelProperty(value = "会员ID列表(与标签二选一,直接指定接收人)")
+    private List<Long> userIds;
+
+    @ApiModelProperty(value = "标签ID列表(与会员列表二选一,按标签筛选接收人)")
+    private List<Long> tagIds;
+
+    @ApiModelProperty(value = "销售ID(发送人,由后端从登录态获取,无需前端传)", hidden = true)
+    private Long companyUserId;
+
+    @ApiModelProperty(value = "公司ID(由后端从登录态获取,无需前端传)", hidden = true)
+    private Long companyId;
+
+    @ApiModelProperty(value = "消息标题/内容", required = true)
+    private String title;
+}

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

@@ -93,6 +93,8 @@ public interface IFsCourseLinkService
      */
     String getGotoWxAppLink(String linkStr,String appid);
 
+    String getGotoWxAppLinkNew(String linkStr, String appId, Integer type);
+
     R getWxaCodeGenerateScheme(String linkStr,String appId);
 
     R getProjectCode();

+ 138 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java

@@ -138,6 +138,8 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
 
     private static final String WX_URL_REDIS_PREFIX_MEMBER = "wx:member:link";
 
+    private static final String WX_URL_REDIS_PREFIX_NEW = "wx:link:new";
+
 
     /**
      * 查询短链
@@ -870,6 +872,142 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
         return "";
     }
 
+    /**
+     * 获取跳转微信小程序的链接地址(新版,支持 generatescheme / generate_urllink)
+     * @param linkStr 链接参数字符串
+     * @param appId   小程序 appId
+     * @param type    链接类型:null/3=新链接(generatescheme),其他=老链接(generate_urllink)
+     * @return 微信小程序链接
+     */
+    @Override
+    public String getGotoWxAppLinkNew(String linkStr, String appId, Integer type) {
+        log.info("getGotoWxAppLinkNew appid:{}, type:{}", appId, type);
+        log.info("linkStr:{}", linkStr);
+        // 构建 Redis 缓存 Key(按 type 隔离,type=null 视为 3)
+        String typeKey = type == null ? "3" : String.valueOf(type);
+        String wxAppLinkKey = null;
+        String key;
+        String linkStrJson;
+        JSONObject json;
+        if (linkStr.contains("becomeVip=")) {
+            key = "becomeVip=";
+            linkStrJson = linkStr.substring(linkStr.indexOf(key) + key.length());
+            json = JSON.parseObject(linkStrJson);
+            wxAppLinkKey = String.format("%s:%s:%s:%s:%s:%s", WX_URL_REDIS_PREFIX_NEW, typeKey,
+                    json.get("companyId"), json.get("companyUserId"), json.get("projectId"), appId);
+        } else {
+            key = "course=";
+            linkStrJson = linkStr.substring(linkStr.indexOf(key) + key.length());
+            json = JSON.parseObject(linkStrJson);
+            wxAppLinkKey = String.format("%s:%s:%s:%s:%s:%s:%s:%s:%s", WX_URL_REDIS_PREFIX_NEW, typeKey,
+                    json.get("companyId"), json.get("companyUserId"), json.get("periodId"),
+                    json.get("projectId"), json.get("courseId"), json.get("videoId"), appId);
+        }
+
+        String linkCache = redisCache.getCacheObject(wxAppLinkKey);
+        if (StringUtils.isNotBlank(linkCache)) {
+            return linkCache;
+        }
+        CloseableHttpClient client = null;
+        try {
+            client = HttpClients.createDefault();
+            String[] split = linkStr.split("\\?");
+            if (split.length == 2 && !split[0].isEmpty() && !split[1].isEmpty()) {
+                String pageUrl = split[0];
+                if (pageUrl.startsWith("/")) {
+                    pageUrl = pageUrl.substring(1);
+                }
+                String query = split[1];
+                query = URLEncoder.encode(query, StandardCharsets.UTF_8.toString());
+                // 获取微信 token
+                final WxMaService wxService = WxMaConfiguration.getMaService(appId);
+                String token = wxService.getAccessToken();
+                log.info("小程序TOKEN值-------->刷新前TOKEN:{}", token);
+
+                JSONObject bodyObj = new JSONObject();
+                String apiUrl;
+                String responseField;
+                if (type == null || type == 3) {
+                    // 新链接:generatescheme
+                    apiUrl = "https://api.weixin.qq.com/wxa/generatescheme?access_token=" + token;
+                    JSONObject jumpWxa = new JSONObject();
+                    jumpWxa.put("path", pageUrl);
+                    jumpWxa.put("query", query);
+                    jumpWxa.put("env_version", "release");
+                    bodyObj.put("jump_wxa", jumpWxa);
+                    bodyObj.put("is_expire", false);
+                    bodyObj.put("expire_type", 1);
+                    bodyObj.put("expire_interval", 30);
+                    responseField = "openlink";
+                } else {
+                    // 老链接:generate_urllink
+                    apiUrl = "https://api.weixin.qq.com/wxa/generate_urllink?access_token=" + token;
+                    bodyObj.put("path", pageUrl);
+                    bodyObj.put("query", query);
+                    responseField = "url_link";
+                }
+
+                HttpPost httpPost = new HttpPost(apiUrl);
+                log.info("微信小程序请求参数打印:{}", bodyObj.toJSONString());
+                StringEntity entity = new StringEntity(bodyObj.toJSONString(), "UTF-8");
+                httpPost.setEntity(entity);
+                httpPost.setHeader("Content-type", "application/json");
+                httpPost.setHeader("cache-control", "max-age=0");
+                HttpEntity response = client.execute(httpPost).getEntity();
+                String responseString = EntityUtils.toString(response);
+                log.info("微信小程序接口响应数据:{}", responseString);
+                JSONObject jsonObject = JSONObject.parseObject(responseString);
+
+                if (TOKEN_VALID_CODE.equals(jsonObject.getString("errcode"))) {
+                    Integer curVersion = Integer.valueOf(version);
+                    synchronized (TOKEN_VALID_CODE) {
+                        if (curVersion.equals(version)) {
+                            log.info("小程序TOKEN:40001进入强制刷新-------->刷新前TOKEN:{}", token);
+                            wxService.getAccessToken(true);
+                            version = version.equals(Integer.MAX_VALUE) ? 0 : curVersion + 1;
+                            log.info("小程序TOKEN:40001进入强制刷新-------->刷新后TOKEN:{}", wxService.getAccessToken());
+                        }
+                        return getGotoWxAppLinkNew(linkStr, appId, type);
+                    }
+                }
+
+                if (null != jsonObject && !jsonObject.isEmpty() && jsonObject.containsKey(responseField)) {
+                    String link = jsonObject.getString(responseField);
+                    // 域名替换
+                    if (type != null && type == 1) {
+                        link = link.replace("wxmpurl.cn", "wxaurl.cn");
+                    }
+                    if (type != null && type == 2) {
+                        link = link.replace("wxaurl.cn", "wxmpurl.cn");
+                    }
+                    redisCache.setCacheObject(wxAppLinkKey, link, 24, TimeUnit.HOURS);
+                    return link;
+                }
+
+                // 处理微信错误响应
+                if (jsonObject != null && jsonObject.containsKey("errcode")) {
+                    Integer errcode = jsonObject.getInteger("errcode");
+                    String errmsg = jsonObject.getString("errmsg");
+                    log.error("微信小程序生成链接失败,错误码:{},错误信息:{}", errcode, errmsg);
+                    throw new RuntimeException("微信错误:" + errcode + "|" + errmsg);
+                }
+                log.error("微信小程序响应格式异常");
+                throw new RuntimeException("微信小程序响应格式异常");
+            } else {
+                return "页面链接错误,获取失败";
+            }
+        } catch (WxErrorException e) {
+            log.error("微信API调用异常", e);
+            throw new RuntimeException("微信API异常", e);
+        } catch (ClientProtocolException e) {
+            log.error("HTTP协议错误", e);
+            throw new RuntimeException("网络请求失败", e);
+        } catch (IOException e) {
+            log.error("IO异常", e);
+            throw new RuntimeException("网络连接失败", e);
+        }
+    }
+
     @Override
     public R getWxaCodeGenerateScheme(String linkStr,String appId) {
         CloseableHttpClient client = null;

+ 10 - 2
fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyUserServiceImpl.java

@@ -159,13 +159,21 @@ public class FsUserCompanyUserServiceImpl extends ServiceImpl<FsUserCompanyUserM
      */
     @Override
     public FsUserCompanyUser bindRelationship(Long userId, Long projectId, Long companyId, Long companyUserId, int status) {
+        // 查重:相同(会员-销售-项目)组合复用已有记录
+        LambdaQueryWrapper<FsUserCompanyUser> queryWrapper = Wrappers.<FsUserCompanyUser>lambdaQuery()
+                .eq(FsUserCompanyUser::getUserId, userId)
+                .eq(FsUserCompanyUser::getCompanyUserId, companyUserId)
+                .eq(FsUserCompanyUser::getProjectId, projectId)
+                .last("limit 1");
+        FsUserCompanyUser existing = getOne(queryWrapper);
+        if (existing != null) {
+            return existing;
+        }
         FsUserCompanyUser userCompanyUser = new FsUserCompanyUser();
         userCompanyUser.setUserId(userId);
         userCompanyUser.setProjectId(projectId);
         userCompanyUser.setCompanyId(companyId);
         userCompanyUser.setCompanyUserId(companyUserId);
-//        boolean hasBind = hasBind(userId, companyUserId);
-//        userCompanyUser.setIsRepeatFans(hasBind ? 1 : 0);
         userCompanyUser.setIsRepeatFans(0);
         userCompanyUser.setStatus(status);
         userCompanyUser.setCreateTime(LocalDateTime.now());

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

@@ -722,7 +722,7 @@ public class FsUserCourseOrderServiceImpl implements IFsUserCourseOrderService
             integralParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_24.getValue());
             integralParam.setBusinessId(order.getOrderId().toString());
             integralParam.setPoints(order.getPayMoney().intValue());
-            userIntegralLogsService.addIntegralTemplate(integralParam);
+//            userIntegralLogsService.addIntegralTemplate(integralParam);
 
             return R.ok();
         }catch (Exception e){

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

@@ -396,7 +396,8 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
             integralTemplateParam.setUserId(param.getUserId());
             integralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_10.getValue());
             integralTemplateParam.setBusinessId(video.getVideoId().toString());
-            return userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
+            return R.ok();
+//            return userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
 
 //            FsUser userMap = new FsUser();
 //            userMap.setIntegral(user.getIntegral()+config.getIntegralCourse());

+ 40 - 8
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -39,8 +39,10 @@ import com.fs.course.service.*;
 import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.*;
 import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserBindWx;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.domain.FsUserWx;
+import com.fs.his.mapper.FsUserBindWxMapper;
 import com.fs.his.mapper.FsUserIntegralLogsMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.param.WxSendRedPacketParam;
@@ -256,6 +258,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
     @Autowired
     private IFsCourseLinkService linkService;
 
+    @Autowired
+    private FsUserBindWxMapper fsUserBindWxMapper;
+
 
     /**
      * 查询课堂视频
@@ -1195,9 +1200,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
                 return R.error("会员被停用,无权限,请联系客服!");
             }
 
-            if (StringUtil.strIsNullOrEmpty(user.getMpOpenId())){
-                return R.error(401,"授权后可继续!");
-            }
+//            if (StringUtil.strIsNullOrEmpty(user.getMpOpenId())){
+//                return R.error(401,"授权后可继续!");
+//            }
 
             FsCourseWatchLog watchLog = new FsCourseWatchLog();
 
@@ -1432,9 +1437,22 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
 
         // 准备发送红包参数
         WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
-
-        if (user.getMpOpenId()!=null&&!isNewWxMerchant){
-            packetParam.setOpenId(user.getMpOpenId());
+        String openId = null;
+        if (!isNewWxMerchant){
+            if(user.getMpOpenId()!=null){
+                openId = user.getMpOpenId();
+            }else {
+                //通过绑定中间表获取信息
+                FsUserBindWx fsUserBindWx = fsUserBindWxMapper.selectBindWxByUserIdAndCmpanyUserId(user.getUserId(), log.getCompanyUserId());
+                if(fsUserBindWx != null && StringUtils.isNotEmpty(fsUserBindWx.getMpOpenId())){
+                    openId = fsUserBindWx.getMpOpenId();
+                }
+            }
+            if(StringUtils.isEmpty(openId)){
+                logger.error(" 【公众号:转账openId参数错误】:{}", user.getUserId());
+                return R.error("公众号:openId参数错误,请清理缓存后重新授权!");
+            }
+            packetParam.setOpenId(openId);
         }else {
             //修复数据
             FsUserWx fsUserWx = fsUserWxService.selectByAppIdAndUserId(param.getAppId(),user.getUserId(),1);
@@ -1636,8 +1654,22 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
 //            if (company.getCourseMiniAppId()==null){
 //                return R.error("销售公司参数错误,未绑定小程序");
 //            }
-            if (user.getMpOpenId()!=null&&!isNewWxMerchant){
-                packetParam.setOpenId(user.getMpOpenId());
+            String openId = null;
+            if (!isNewWxMerchant){
+                if(user.getMpOpenId()!=null){
+                    openId = user.getMpOpenId();
+                }else {
+                    //通过绑定中间表获取信息
+                    FsUserBindWx fsUserBindWx = fsUserBindWxMapper.selectBindWxByUserIdAndCmpanyUserId(user.getUserId(), log.getCompanyUserId());
+                    if(fsUserBindWx != null && StringUtils.isNotEmpty(fsUserBindWx.getMpOpenId())){
+                        openId = fsUserBindWx.getMpOpenId();
+                    }
+                }
+                if(StringUtils.isEmpty(openId)){
+                    logger.error(" 【公众号:转账openId参数错误】:{}", user.getUserId());
+                    return R.error("公众号:openId参数错误,请清理缓存后重新授权!");
+                }
+                packetParam.setOpenId(openId);
             }else {
                 //修复数据
                 FsUserWx fsUserWx = fsUserWxService.selectByAppIdAndUserId(param.getAppId(),user.getUserId(),1);

+ 42 - 0
fs-service/src/main/java/com/fs/his/config/PointsGrantRuleConfig.java

@@ -0,0 +1,42 @@
+package com.fs.his.config;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 发放积分规则配置
+ * 对应 sys_config 表 config_key = "points.grantRule"
+ */
+@Data
+public class PointsGrantRuleConfig implements Serializable {
+
+    /** 积分发放比例:1元 = X积分(保留两位小数) */
+    private BigDecimal rate;
+
+    /** 参与范围:1=全场商品参与, 2=指定商品分类不参与, 3=指定单个商品不参与 */
+    private Integer scopeType;
+
+    /** 不送积分的商品分类ID列表 */
+    private List<Long> excludeCategoryIds;
+
+    /** 不送积分的单个商品ID列表 */
+    private List<Long> excludeProductIds;
+
+    /** 退款是否扣回积分:0=否 1=是 */
+    private Integer refundDeduct;
+
+    /** 用户每日积分获取上限(0=不限制) */
+    private Integer dailyLimit;
+
+    /** 用户每月积分获取上限(0=不限制) */
+    private Integer monthlyLimit;
+
+    /** 订单最高抵扣积分(0=不限制):用户每笔订单最多可用积分抵扣的数量 */
+    private Integer maxDeduct;
+
+    /** 是否启用:0=禁用 1=启用 */
+    private Integer enabled;
+}

+ 57 - 0
fs-service/src/main/java/com/fs/his/domain/FsUserBindWx.java

@@ -0,0 +1,57 @@
+package com.fs.his.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 用户微信绑定中间表 fs_user_bind_wx
+ */
+@Data
+public class FsUserBindWx implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    /** 用户ID */
+    private Long fsUserId;
+
+    /** 微信unionId */
+    private String unionId;
+
+    /** 微信小程序OPENID */
+    private String maOpenId;
+
+    /** 微信公众号OPENID */
+    private String mpOpenId;
+
+    /** 业务类型:1看课 2商城 */
+    private Integer bizType;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 公司用户ID */
+    private Long companyUserId;
+
+    /** 绑定微信用户ID */
+    private Long bindUserId;
+
+    /** 外部联系人ID(企微外部联系人) */
+    private Long externalContactId;
+
+    /** 类型:1=手动 2=自动(外部联系人) */
+    private Integer type;
+
+    /** fs_user_company_user 表ID */
+    private Long userCompanyUserId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/his/domain/vo/AssociatedUserQuery.java

@@ -0,0 +1,22 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 可绑定关联用户查询参数
+ */
+@Data
+public class AssociatedUserQuery implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private Long userId;
+
+    private String nickName;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/his/domain/vo/AssociatedUserVO.java

@@ -0,0 +1,20 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 可绑定的关联用户 VO(搜索绑定 + 绑定详情共用)
+ */
+@Data
+public class AssociatedUserVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+
+    private String nickName;
+
+    private String avatar;
+}

+ 21 - 0
fs-service/src/main/java/com/fs/his/domain/vo/BatchAssignParam.java

@@ -0,0 +1,21 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 批量分配销售人员请求参数
+ */
+@Data
+public class BatchAssignParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotNull(message = "请选择要分配的会员")
+    private Long[] ids;
+
+    @NotNull(message = "请选择销售人员")
+    private Long companyUserId;
+}

+ 23 - 0
fs-service/src/main/java/com/fs/his/domain/vo/BatchBindParam.java

@@ -0,0 +1,23 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 批量绑定请求参数
+ */
+@Data
+public class BatchBindParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotEmpty(message = "用户ID不能为空")
+    private List<Long> userIds;
+
+    @NotNull(message = "销售公司不能为空")
+    private Long companyId;
+}

+ 56 - 0
fs-service/src/main/java/com/fs/his/domain/vo/BindListVO.java

@@ -0,0 +1,56 @@
+package com.fs.his.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 绑定列表 VO(含公司名、销售人员名)
+ */
+@Data
+public class BindListVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long fsUserId;
+
+    private Long companyId;
+
+    private Integer bizType;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    private String companyName;
+
+    /** 销售人员ID */
+    private Long companyUserId;
+
+    /** 销售人员昵称 */
+    private String companyUserName;
+
+    /** 销售人员账号 */
+    private String companyUserAccount;
+
+    /** 类型:1=手动 2=自动 */
+    private Integer type;
+
+    /** 绑定的微信用户ID */
+    private Long bindUserId;
+
+    /** 外部联系人ID */
+    private Long externalContactId;
+
+    /** 外部联系人名称 */
+    private String externalContactName;
+
+    /** 外部联系人头像 */
+    private String externalContactAvatar;
+
+    /** 看课项目ID(字典 sys_course_project 值) */
+    private Long projectId;
+}

+ 30 - 0
fs-service/src/main/java/com/fs/his/domain/vo/BindWxUserParam.java

@@ -0,0 +1,30 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 绑定微信用户请求参数
+ */
+@Data
+public class BindWxUserParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotNull(message = "绑定记录ID不能为空")
+    private Long bindId;
+
+    /** 微信用户ID(手动选择) */
+    private Long bindUserId;
+
+    /** 外部联系人ID(自动选择) */
+    private Long externalContactId;
+
+    /** 看课项目(手动看课时必填,字典 sys_course_project 值) */
+    private Long projectId;
+
+    /** 类型:1=手动 2=自动,不传默认根据参数自动判断 */
+    private Integer type;
+}

+ 34 - 0
fs-service/src/main/java/com/fs/his/domain/vo/CompanyBindUserQuery.java

@@ -0,0 +1,34 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 公司端用户绑定列表查询参数
+ */
+@Data
+public class CompanyBindUserQuery implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private Long userId;
+
+    private String nickName;
+
+    /** 明文手机号(前端传参,Controller 加密后转存 phoneMk) */
+    private String phone;
+
+    /** 加密后的手机号(Controller 层设置) */
+    private String phoneMk;
+
+    /** 0未绑定 1已绑定 */
+    private Integer bindStatus;
+
+    /** 分配状态:0=未分配 1=已分配 */
+    private Integer assignStatus;
+}

+ 56 - 0
fs-service/src/main/java/com/fs/his/domain/vo/CompanyBindUserVO.java

@@ -0,0 +1,56 @@
+package com.fs.his.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 公司端用户绑定列表 VO
+ */
+@Data
+public class CompanyBindUserVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long userId;
+
+    private String nickName;
+
+    private String avatar;
+
+    private String phone;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 绑定的微信用户ID,null表示未绑定 */
+    private Long bindUserId;
+
+    /** 类型:1=手动 2=自动(外部联系人) */
+    private Integer type;
+
+    /** 外部联系人ID */
+    private Long externalContactId;
+
+    /** 外部联系人名称 */
+    private String externalContactName;
+
+    /** 外部联系人头像 */
+    private String externalContactAvatar;
+
+    /** 看课项目ID(字典 sys_course_project 值) */
+    private Long projectId;
+
+    /** 销售人员ID */
+    private Long companyUserId;
+
+    /** 销售人员昵称 */
+    private String companyUserName;
+
+    /** 销售人员账号 */
+    private String companyUserAccount;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/his/domain/vo/DecryptPhoneParam.java

@@ -0,0 +1,18 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import java.io.Serializable;
+
+/**
+ * 手机号解码请求参数
+ */
+@Data
+public class DecryptPhoneParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotEmpty(message = "手机号不能为空")
+    private String phone;
+}

+ 23 - 0
fs-service/src/main/java/com/fs/his/domain/vo/ExternalContactQuery.java

@@ -0,0 +1,23 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 外部联系人查询参数
+ */
+@Data
+public class ExternalContactQuery implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String name;
+
+    /** 企业微信主体名称 */
+    private String corpName;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/his/domain/vo/ExternalContactVO.java

@@ -0,0 +1,26 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 外部联系人信息 VO
+ */
+@Data
+public class ExternalContactVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    /** 用户昵称 */
+    private String name;
+
+    private String avatar;
+
+    private String externalUserId;
+
+    /** 企业微信名称 */
+    private String corpName;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/his/domain/vo/FsUserWxInfoVO.java

@@ -0,0 +1,22 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 微信用户信息 VO
+ */
+@Data
+public class FsUserWxInfoVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+
+    private String unionId;
+
+    private String maOpenId;
+
+    private String mpOpenId;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/his/domain/vo/UnassignedUserQuery.java

@@ -0,0 +1,26 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 未分配用户查询参数
+ */
+@Data
+public class UnassignedUserQuery implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+
+    private String nickName;
+
+    private String phone;
+
+    /** 加密后的手机号(Controller 层设置) */
+    private String phoneMk;
+
+    /** 0未绑定 1已绑定 */
+    private Integer bindStatus;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/his/domain/vo/UnassignedUserVO.java

@@ -0,0 +1,31 @@
+package com.fs.his.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 未分配用户列表 VO
+ */
+@Data
+public class UnassignedUserVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+
+    private String nickName;
+
+    private String avatar;
+
+    private String phone;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    private Long bindId;
+
+    private Integer bindStatus;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/his/domain/vo/UnbindParam.java

@@ -0,0 +1,18 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 解绑请求参数
+ */
+@Data
+public class UnbindParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotNull(message = "绑定记录ID不能为空")
+    private Long id;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/his/domain/vo/UserBindDetailVO.java

@@ -0,0 +1,19 @@
+package com.fs.his.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 用户详情及绑定列表 VO(admin 端接口返回)
+ */
+@Data
+public class UserBindDetailVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private UserDetailVO userInfo;
+
+    private List<BindListVO> bindList;
+}

+ 29 - 0
fs-service/src/main/java/com/fs/his/domain/vo/UserDetailVO.java

@@ -0,0 +1,29 @@
+package com.fs.his.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 用户详情 VO
+ */
+@Data
+public class UserDetailVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+
+    private String nickName;
+
+    private String avatar;
+
+    private String phone;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    private Integer sex;
+}

+ 1 - 0
fs-service/src/main/java/com/fs/his/dto/PayloadDTO.java

@@ -33,6 +33,7 @@ public class PayloadDTO implements Serializable {
         private Long companyId;
         private Long companyUserId;
         private Long doctorId;
+        private Long projectId;
 
     }
 

+ 104 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserBindWxMapper.java

@@ -0,0 +1,104 @@
+package com.fs.his.mapper;
+
+import com.fs.his.domain.FsUserBindWx;
+import com.fs.his.domain.vo.*;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 用户微信绑定中间表 Mapper
+ */
+public interface FsUserBindWxMapper {
+
+    FsUserBindWx selectFsUserBindWxById(Long id);
+
+    List<FsUserBindWx> selectFsUserBindWxList(FsUserBindWx fsUserBindWx);
+
+    int insertFsUserBindWx(FsUserBindWx fsUserBindWx);
+
+    int updateFsUserBindWx(FsUserBindWx fsUserBindWx);
+
+    int deleteFsUserBindWxById(Long id);
+
+    int deleteFsUserBindWxByIds(Long[] ids);
+
+    /**
+     * 未分配用户列表查询
+     */
+    List<UnassignedUserVO> selectUnassignedUserList(UnassignedUserQuery query);
+
+    /**
+     * 批量插入绑定记录(IGNORE 去重)
+     */
+    int batchInsertBindIgnore(@Param("list") List<FsUserBindWx> list);
+
+    /**
+     * 查询已绑定指定公司的用户ID集合(用于去重)
+     */
+    List<Long> selectBoundUserIds(@Param("userIds") List<Long> userIds, @Param("companyId") Long companyId);
+
+    /**
+     * 根据用户ID查询绑定列表(含公司名、销售人员名)
+     */
+    List<BindListVO> selectBindListByFsUserId(@Param("fsUserId") Long fsUserId);
+
+    /**
+     * 根据用户ID查询用户详情
+     */
+    UserDetailVO selectUserDetailByFsUserId(@Param("fsUserId") Long fsUserId);
+
+    /**
+     * 公司端查询用户绑定列表
+     */
+    List<CompanyBindUserVO> selectCompanyBindUserList(CompanyBindUserQuery query);
+
+    /**
+     * 查询可绑定的关联用户列表(来自 fs_user_company_user)
+     */
+    List<AssociatedUserVO> selectAssociatedUserList(AssociatedUserQuery query);
+
+    /**
+     * 查询 fs_user 的微信信息
+     */
+    FsUserWxInfoVO selectFsUserWxInfo(@Param("userId") Long userId);
+
+    /**
+     * 更新绑定记录的微信用户信息
+     */
+    int updateBindWxUser(@Param("id") Long id, @Param("bindUserId") Long bindUserId,
+                         @Param("companyUserId") Long companyUserId,
+                         @Param("unionId") String unionId, @Param("maOpenId") String maOpenId,
+                         @Param("mpOpenId") String mpOpenId, @Param("externalContactId") Long externalContactId,
+                         @Param("userCompanyUserId") Long userCompanyUserId,
+                         @Param("type") Integer type);
+
+    /**
+     * 解绑微信用户,清空绑定信息
+     */
+    int unbindWxUser(@Param("id") Long id);
+
+    /**
+     * 查询绑定的微信用户详情
+     */
+    AssociatedUserVO selectBoundUserInfo(@Param("userId") Long userId);
+
+    /**
+     * 通过用户ID和销售ID获取绑定记录
+     */
+    FsUserBindWx selectBindWxByUserIdAndCmpanyUserId(@Param("userId") Long userId, @Param("companyUserId") Long companyUserId);
+
+    List<ExternalContactVO> selectExternalContactList(ExternalContactQuery query);
+
+    ExternalContactVO selectExternalContactById(@Param("id") Long id);
+
+    /**
+     * 批量分配销售人员
+     */
+    int batchAssignCompanyUser(@Param("ids") Long[] ids, @Param("companyUserId") Long companyUserId);
+
+    /**
+     * 解除分配(清空 company_user_id)
+     */
+    int unassignCompanyUser(@Param("id") Long id);
+}

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

@@ -139,4 +139,26 @@ public interface FsUserIntegralLogsMapper
     Long selectH5VideoIntegralCount(@Param("userId") Long userId,@Param("videoId") Long videoId);
 
     List<FsUserIntegralLogs> selectFsUserIntegralLogsByUserIdAndLogType(@Param("userId") Long userId, @Param("logType") Integer logType, @Param("date") LocalDate date);
+
+    /**
+     * 查询用户当月指定类型的积分发放总额(仅统计正向积分,按月汇总)
+     *
+     * @param userId  用户ID
+     * @param logType 积分类型
+     * @return 当月累计积分总额,无记录返回0
+     */
+    @Select("SELECT IFNULL(SUM(integral), 0) FROM fs_user_integral_logs WHERE user_id = #{userId} AND log_type = #{logType} AND integral > 0 AND DATE_FORMAT(create_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')")
+    Long selectMonthlyTotalIntegral(@Param("userId") Long userId, @Param("logType") Integer logType);
+
+    /**
+     * 根据用户ID、订单号、积分类型查询原始积分发放记录
+     * 用于退款扣回场景:通过原订单号找到发放记录,按比例扣除
+     *
+     * @param userId     用户ID
+     * @param businessId 原订单号
+     * @param logType    积分类型(2=消费获得积分)
+     * @return 原始积分流水记录
+     */
+    @Select("SELECT * FROM fs_user_integral_logs WHERE user_id = #{userId} AND business_id = #{businessId} AND log_type = #{logType} ORDER BY id DESC LIMIT 1")
+    FsUserIntegralLogs selectByUserIdAndBusinessId(@Param("userId") Long userId, @Param("businessId") String businessId, @Param("logType") Integer logType);
 }

+ 4 - 1
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -135,6 +135,9 @@ public interface FsUserMapper
     @Select("select * from fs_user where phone=#{phone} limit 1")
     FsUser selectFsUserByPhoneLimitOne(String phone);
 
+    @Select("select * from fs_user where phone=#{phone} AND app_id LIKE CONCAT('%',#{appid},'%') limit 1")
+    FsUser selectFsUserByPhoneLimitOneAppId(@Param("phone") String phone,@Param("appid") String appid);
+
     @Select("select * from fs_user where phone=#{phone} and union_id = #{unionId} limit 1")
     FsUser selectFsUserByPhoneAndAndUnionIdLimitOne(@Param("phone") String phone,@Param("unionId") String unionId);
     @Select({"<script> " +
@@ -468,7 +471,7 @@ public interface FsUserMapper
             "select * from fs_user where phone = #{phone}",
             "<choose>",
             "  <when test='bizType != null and bizType == 2'> and biz_type = 2 </when>",
-            "  <otherwise> and (biz_type = 1 or biz_type is null) </otherwise>",
+            "  <otherwise> and biz_type = 1 </otherwise>",
             "</choose>",
             "</script>"})
     List<FsUser> selectFsUserListByPhoneAndBizType(@Param("phone") String phone, @Param("appid") String appid, @Param("bizType") Integer bizType);

+ 5 - 0
fs-service/src/main/java/com/fs/his/param/FsAddUserParam.java

@@ -27,4 +27,9 @@ public class FsAddUserParam implements Serializable {
      * 销售ID
      * **/
     private Long companyUserId;
+
+    /**
+     * 项目ID(addType=1手动发课时必传,用于写入fs_user_company_user)
+     * **/
+    private Long projectId;
 }

+ 63 - 0
fs-service/src/main/java/com/fs/his/param/FsUserGrantPointsParam.java

@@ -0,0 +1,63 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 通用积分发放入参
+ * 支持场景:消费送积分(logType=2)、退款扣回(logType=4)、管理员手动添加(logType=23) 等
+ *
+ * @author fs
+ * @date 2026-07-10
+ */
+@Data
+public class FsUserGrantPointsParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 用户ID(必填)
+     */
+    @NotNull(message = "用户ID不能为空")
+    private Long userId;
+
+    /**
+     * 积分场景类型(必填)
+     * 2=消费获得积分, 4=退款扣除积分, 23=管理员添加 等
+     */
+    @NotNull(message = "积分类型不能为空")
+    private Integer logType;
+
+    /**
+     * 实付金额(消费/退款场景必填,管理员添加时选填)
+     */
+    private BigDecimal amount;
+
+    /**
+     * 商品ID(范围校验用,消费/退款场景选填)
+     */
+    private Long productId;
+
+    /**
+     * 商品分类ID(范围校验用,消费/退款场景选填)
+     */
+    private Long categoryId;
+
+    /**
+     * 关联业务ID(订单号 / 退款单号等)
+     */
+    private String businessId;
+
+    /**
+     * 原订单号(退款扣回场景必填,用于找到原始积分发放记录直接扣回)
+     */
+    private String originalOrderNo;
+
+    /**
+     * 备注
+     */
+    private String remark;
+}

+ 96 - 0
fs-service/src/main/java/com/fs/his/service/IFsUserBindWxService.java

@@ -0,0 +1,96 @@
+package com.fs.his.service;
+
+import com.fs.his.domain.FsUserBindWx;
+import com.fs.his.domain.vo.*;
+
+import java.util.List;
+
+/**
+ * 用户微信绑定中间表 Service
+ */
+public interface IFsUserBindWxService {
+
+    FsUserBindWx selectFsUserBindWxById(Long id);
+
+    List<FsUserBindWx> selectFsUserBindWxList(FsUserBindWx fsUserBindWx);
+
+    int insertFsUserBindWx(FsUserBindWx fsUserBindWx);
+
+    int updateFsUserBindWx(FsUserBindWx fsUserBindWx);
+
+    int deleteFsUserBindWxById(Long id);
+
+    int deleteFsUserBindWxByIds(Long[] ids);
+
+    /**
+     * 未分配用户列表查询
+     */
+    List<UnassignedUserVO> selectUnassignedUserList(UnassignedUserQuery query);
+
+    /**
+     * 批量绑定用户到指定公司(自动去重)
+     * @return 成功插入条数
+     */
+    int batchBindUsers(List<Long> userIds, Long companyId);
+
+    /**
+     * 根据用户ID查询绑定列表(含公司名、销售人员名)
+     */
+    List<BindListVO> selectBindListByFsUserId(Long fsUserId);
+
+    /**
+     * 根据用户ID查询用户详情
+     */
+    UserDetailVO selectUserDetailByFsUserId(Long fsUserId);
+
+    /**
+     * 公司端查询用户绑定列表
+     */
+    List<CompanyBindUserVO> selectCompanyBindUserList(CompanyBindUserQuery query);
+
+    /**
+     * 查询可绑定的关联用户列表
+     */
+    List<AssociatedUserVO> selectAssociatedUserList(AssociatedUserQuery query);
+
+    /**
+     * 绑定微信用户信息
+     * @param bindId 绑定记录ID
+     * @param bindUserId 要绑定的微信用户ID(手动模式)
+     * @param companyUserId 销售ID
+     * @param externalContactId 外部联系人ID(自动模式)
+     * @param type 类型:1=手动 2=自动
+     */
+    void bindWxUser(Long bindId, Long bindUserId, Long companyUserId,
+                    Long externalContactId, Long projectId, Integer type);
+
+    /**
+     * 解绑微信用户
+     */
+    int unbindWxUser(Long id);
+
+    /**
+     * 查询绑定的微信用户详情
+     */
+    AssociatedUserVO selectBoundUserInfo(Long userId);
+
+    /**
+     * 查询外部联系人列表
+     */
+    List<ExternalContactVO> selectExternalContactList(ExternalContactQuery query);
+
+    /**
+     * 根据ID查询外部联系人
+     */
+    ExternalContactVO selectExternalContactById(Long id);
+
+    /**
+     * 批量分配销售人员
+     */
+    void batchAssignCompanyUser(Long[] ids, Long companyUserId);
+
+    /**
+     * 解除分配(校验 bind_user_id 为空才允许)
+     */
+    void unassignCompanyUser(Long id);
+}

+ 20 - 0
fs-service/src/main/java/com/fs/his/service/IFsUserIntegralLogsService.java

@@ -4,6 +4,7 @@ import com.fs.common.core.domain.R;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.param.FsUserAddIntegralParam;
 import com.fs.his.param.FsUserAddIntegralTemplateParam;
+import com.fs.his.param.FsUserGrantPointsParam;
 import com.fs.his.param.FsUserIntegralLogsListUParam;
 import com.fs.his.param.FsUserIntegralLogsParam;
 import com.fs.his.vo.FsUserIntegralLogsListUVO;
@@ -86,4 +87,23 @@ public interface IFsUserIntegralLogsService
 
     //app获取新人福利完成情况
     R getNewcomerBenefits(Long userId);
+
+    /**
+     * 通用积分发放(基于 points.grantRule 规则)
+     *
+     * <p>支持场景:</p>
+     * <ul>
+     *   <li>logType=2 — 消费送积分:根据实付金额 × 发放比例计算,校验商品/分类参与范围及日/月上限</li>
+     *   <li>logType=4 — 退款扣回积分:仅当 refundDeduct=1 时生效,按退款金额比例扣除,返回负数积分</li>
+     *   <li>logType=23 — 管理员手动添加:不校验范围和上限,直接发放</li>
+     * </ul>
+     *
+     * <p>并发安全:Redisson 分布式锁 (key = integral:grant:{userId}:{logType})</p>
+     * <p>日/月上限:Redis Hash 原子计数器 + DB 兜底查询</p>
+     * <p>幂等性:锁粒度 userId+logType,同用户同场景串行化,天然防重</p>
+     *
+     * @param param 通用积分发放入参(userId、logType、amount 等)
+     * @return R.ok("发放成功") 或 R.error("失败原因")
+     */
+    R grantPoints(FsUserGrantPointsParam param);
 }

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

@@ -275,5 +275,5 @@ public interface IFsUserService
      * @param compayUserId 销售ID
      * @return R
      * **/
-    R batchAddUserAndSaleFriend(List<FsUser> userList,Long compayUserId);
+    R batchAddUserAndSaleFriend(List<FsUser> userList, Long compayUserId, Long projectId, Long companyId);
 }

+ 1 - 1
fs-service/src/main/java/com/fs/his/service/impl/FsImFriendshipServiceImpl.java

@@ -153,7 +153,7 @@ public class FsImFriendshipServiceImpl extends ServiceImpl<FsImFriendshipMapper,
             return R.error("用户信息有误,请稍后重试!");
         }
         if (param.getAddType() == 1) {
-            return userService.batchAddUserAndSaleFriend(userList, param.getCompanyUserId());
+            return userService.batchAddUserAndSaleFriend(userList, param.getCompanyUserId(), param.getProjectId(), null);
         }
         return userService.addUserAndSaleFriend(userList.get(0), param.getCompanyUserId(), param.getExternalUserId());
     }

+ 1 - 1
fs-service/src/main/java/com/fs/his/service/impl/FsInquiryOrderServiceImpl.java

@@ -1370,7 +1370,7 @@ public class FsInquiryOrderServiceImpl implements IFsInquiryOrderService
         integralParam.setUserId(order.getUserId());
         integralParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_21.getValue());
         integralParam.setBusinessId(order.getOrderSn());
-        userIntegralLogsService.addIntegralTemplate(integralParam);
+//        userIntegralLogsService.addIntegralTemplate(integralParam);
 
         //todo 将金额写入医生佣金
         return R.ok("接诊成功");

+ 2 - 2
fs-service/src/main/java/com/fs/his/service/impl/FsIntegralOrderServiceImpl.java

@@ -319,7 +319,7 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
                 integralParam.setUserId(order.getUserId());
                 integralParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_22.getValue());
                 integralParam.setBusinessId(order.getOrderId().toString());
-                userIntegralLogsService.addIntegralTemplate(integralParam);
+//                userIntegralLogsService.addIntegralTemplate(integralParam);
 
                 return R.ok("兑换成功").put("order",order).put("isPay", 1);
             }
@@ -591,7 +591,7 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         integralParam.setUserId(order.getUserId());
         integralParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_22.getValue());
         integralParam.setBusinessId(order.getOrderId().toString());
-        userIntegralLogsService.addIntegralTemplate(integralParam);
+//        userIntegralLogsService.addIntegralTemplate(integralParam);
 
         order.setIsPay(1);
         order.setStatus(1);

+ 287 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsUserBindWxServiceImpl.java

@@ -0,0 +1,287 @@
+package com.fs.his.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fs.common.exception.ServiceException;
+import com.fs.his.domain.FsImFriendship;
+import com.fs.his.domain.FsUserBindWx;
+import com.fs.his.domain.vo.*;
+import com.fs.his.mapper.FsUserBindWxMapper;
+import com.fs.his.param.FsAddUserParam;
+import com.fs.his.service.IFsImFriendshipService;
+import com.fs.his.service.IFsUserBindWxService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.course.service.IFsUserCompanyUserService;
+import com.fs.im.service.OpenIMService;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 用户微信绑定中间表 Service 实现
+ */
+@Slf4j
+@Service
+public class FsUserBindWxServiceImpl implements IFsUserBindWxService {
+
+    private static final String USER_IM_ID_PREFIX = "U";
+    private static final String COMPANY_IM_ID_PREFIX = "C";
+
+    @Autowired
+    private FsUserBindWxMapper fsUserBindWxMapper;
+
+    @Autowired
+    private IFsImFriendshipService fsImFriendshipService;
+
+    @Autowired
+    private OpenIMService openIMService;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Autowired
+    private IFsUserCompanyUserService fsUserCompanyUserService;
+
+    @Override
+    public FsUserBindWx selectFsUserBindWxById(Long id) {
+        return fsUserBindWxMapper.selectFsUserBindWxById(id);
+    }
+
+    @Override
+    public List<FsUserBindWx> selectFsUserBindWxList(FsUserBindWx fsUserBindWx) {
+        return fsUserBindWxMapper.selectFsUserBindWxList(fsUserBindWx);
+    }
+
+    @Override
+    public int insertFsUserBindWx(FsUserBindWx fsUserBindWx) {
+        return fsUserBindWxMapper.insertFsUserBindWx(fsUserBindWx);
+    }
+
+    @Override
+    public int updateFsUserBindWx(FsUserBindWx fsUserBindWx) {
+        return fsUserBindWxMapper.updateFsUserBindWx(fsUserBindWx);
+    }
+
+    @Override
+    public int deleteFsUserBindWxById(Long id) {
+        return fsUserBindWxMapper.deleteFsUserBindWxById(id);
+    }
+
+    @Override
+    public int deleteFsUserBindWxByIds(Long[] ids) {
+        return fsUserBindWxMapper.deleteFsUserBindWxByIds(ids);
+    }
+
+    @Override
+    public List<UnassignedUserVO> selectUnassignedUserList(UnassignedUserQuery query) {
+        return fsUserBindWxMapper.selectUnassignedUserList(query);
+    }
+
+    @Override
+    public int batchBindUsers(List<Long> userIds, Long companyId) {
+        if (userIds == null || userIds.isEmpty()) {
+            return 0;
+        }
+        // 查询已绑定的用户,排除重复
+        List<Long> boundIds = fsUserBindWxMapper.selectBoundUserIds(userIds, companyId);
+        List<FsUserBindWx> toInsert = userIds.stream()
+                .filter(id -> !boundIds.contains(id))
+                .map(id -> {
+                    FsUserBindWx bind = new FsUserBindWx();
+                    bind.setFsUserId(id);
+                    bind.setCompanyId(companyId);
+                    return bind;
+                })
+                .collect(java.util.stream.Collectors.toList());
+        if (toInsert.isEmpty()) {
+            return 0;
+        }
+        return fsUserBindWxMapper.batchInsertBindIgnore(toInsert);
+    }
+
+    @Override
+    public List<BindListVO> selectBindListByFsUserId(Long fsUserId) {
+        return fsUserBindWxMapper.selectBindListByFsUserId(fsUserId);
+    }
+
+    @Override
+    public UserDetailVO selectUserDetailByFsUserId(Long fsUserId) {
+        return fsUserBindWxMapper.selectUserDetailByFsUserId(fsUserId);
+    }
+
+    @Override
+    public List<CompanyBindUserVO> selectCompanyBindUserList(CompanyBindUserQuery query) {
+        return fsUserBindWxMapper.selectCompanyBindUserList(query);
+    }
+
+    @Override
+    public List<AssociatedUserVO> selectAssociatedUserList(AssociatedUserQuery query) {
+        return fsUserBindWxMapper.selectAssociatedUserList(query);
+    }
+
+    @Override
+    public void bindWxUser(Long bindId, Long bindUserId, Long companyUserId,
+                           Long externalContactId, Long projectId, Integer type) {
+        // 验证绑定信息是否存在
+        FsUserBindWx bindWxInfo = fsUserBindWxMapper.selectFsUserBindWxById(bindId);
+        if (bindWxInfo == null) {
+            throw new ServiceException("操作失败,绑定信息不存在!");
+        }
+
+        // 自动补全 type
+        Integer bindType = type;
+        if (bindType == null) {
+            bindType = externalContactId != null ? 2 : 1;
+        }
+
+        // 手动模式:插入 fs_user_company_user 记录
+        Long userCompanyUserId = null;
+        if (externalContactId == null && projectId != null) {
+            com.fs.course.domain.FsUserCompanyUser ucUser =
+                    fsUserCompanyUserService.bindRelationship(
+                            bindWxInfo.getFsUserId(), projectId,
+                            bindWxInfo.getCompanyId(), companyUserId, 1);
+            userCompanyUserId = ucUser.getId();
+        }
+
+        // 自动模式:外部联系人校验
+        if (externalContactId != null) {
+            com.fs.qw.domain.QwExternalContact externalContact =
+                    qwExternalContactMapper.selectQwExternalContactById(externalContactId);
+            if (externalContact == null) {
+                throw new ServiceException("外部联系人不存在!");
+            }
+            if (externalContact.getFsUserId() != null) {
+                throw new ServiceException("该外部联系人已绑定小程序用户,无法重复绑定!");
+            }
+            // 获取微信用户信息
+            FsUserWxInfoVO wxInfo = null;
+            if (bindUserId != null) {
+                wxInfo = fsUserBindWxMapper.selectFsUserWxInfo(bindUserId);
+            }
+            // 更新绑定记录
+            fsUserBindWxMapper.updateBindWxUser(bindId, bindUserId, companyUserId,
+                    wxInfo != null ? wxInfo.getUnionId() : null,
+                    wxInfo != null ? wxInfo.getMaOpenId() : null,
+                    wxInfo != null ? wxInfo.getMpOpenId() : null,
+                    externalContactId, userCompanyUserId, bindType);
+            // 更新外部联系人表的 fs_user_id
+            com.fs.qw.domain.QwExternalContact updateEc = new com.fs.qw.domain.QwExternalContact();
+            updateEc.setId(externalContactId);
+            updateEc.setFsUserId(bindWxInfo.getFsUserId());
+            qwExternalContactMapper.updateQwExternalContact(updateEc);
+            // 同步IM好友关系
+            if (bindUserId != null) {
+                FsAddUserParam addUserParam = new FsAddUserParam();
+                addUserParam.setUserIds(Arrays.asList(bindWxInfo.getFsUserId()));
+                addUserParam.setAddType(1);
+                addUserParam.setCompanyUserId(companyUserId);
+                fsImFriendshipService.addUserAndSaleFriend(addUserParam);
+            }
+            return;
+        }
+
+        // 手动模式
+        if (bindUserId == null) {
+            throw new ServiceException("请选择要绑定的微信用户!");
+        }
+        FsUserWxInfoVO wxInfo = fsUserBindWxMapper.selectFsUserWxInfo(bindUserId);
+        if (wxInfo == null) {
+            return;
+        }
+        fsUserBindWxMapper.updateBindWxUser(bindId, bindUserId, companyUserId,
+                wxInfo.getUnionId(), wxInfo.getMaOpenId(), wxInfo.getMpOpenId(),
+                null, userCompanyUserId, bindType);
+
+        // 同步IM好友关系
+        FsAddUserParam addUserParam = new FsAddUserParam();
+        addUserParam.setUserIds(Arrays.asList(bindWxInfo.getFsUserId()));
+        addUserParam.setAddType(1);
+        addUserParam.setCompanyUserId(companyUserId);
+        fsImFriendshipService.addUserAndSaleFriend(addUserParam);
+    }
+
+    @Override
+    public int unbindWxUser(Long id) {
+        FsUserBindWx bindWx = fsUserBindWxMapper.selectFsUserBindWxById(id);
+        if (bindWx == null) {
+            return 0;
+        }
+        // 双向解绑IM好友关系
+        if (bindWx.getCompanyUserId() != null) {
+            String saleImId = COMPANY_IM_ID_PREFIX + bindWx.getCompanyUserId();
+            unbindImFriendship(saleImId, bindWx.getFsUserId(), bindWx.getCompanyUserId());
+            if (bindWx.getBindUserId() != null) {
+                unbindImFriendship(saleImId, bindWx.getBindUserId(), bindWx.getCompanyUserId());
+            }
+        }
+        // 清除绑定字段
+        int result = fsUserBindWxMapper.unbindWxUser(id);
+        // 删除 fs_user_company_user 记录
+        if (bindWx.getUserCompanyUserId() != null) {
+            fsUserCompanyUserService.deleteFsUserCompanyUserById(bindWx.getUserCompanyUserId());
+        }
+        return result;
+    }
+
+    /**
+     * 双向解绑IM好友关系(销售 ↔ 用户)+ 删除本地绑定记录
+     */
+    private void unbindImFriendship(String saleImId, Long userId, Long companyUserId) {
+        String userImId = USER_IM_ID_PREFIX + userId;
+        try {
+            openIMService.deleteFriend(saleImId, userImId);
+            openIMService.deleteFriend(userImId, saleImId);
+        } catch (Exception e) {
+            log.error("IM好友解绑失败, saleImId={}, userImId={}", saleImId, userImId, e);
+        }
+        // 删除本地IM绑定记录
+        try {
+            fsImFriendshipService.remove(
+                    Wrappers.<FsImFriendship>lambdaQuery()
+                            .eq(FsImFriendship::getCompanyUserId, companyUserId)
+                            .eq(FsImFriendship::getUserId, userId)
+            );
+        } catch (Exception e) {
+            log.error("删除IM绑定记录失败, companyUserId={}, userId={}", companyUserId, userId, e);
+        }
+    }
+
+    @Override
+    public AssociatedUserVO selectBoundUserInfo(Long userId) {
+        return fsUserBindWxMapper.selectBoundUserInfo(userId);
+    }
+
+    @Override
+    public List<ExternalContactVO> selectExternalContactList(ExternalContactQuery query) {
+        return fsUserBindWxMapper.selectExternalContactList(query);
+    }
+
+    @Override
+    public ExternalContactVO selectExternalContactById(Long id) {
+        return fsUserBindWxMapper.selectExternalContactById(id);
+    }
+
+    @Override
+    public void batchAssignCompanyUser(Long[] ids, Long companyUserId) {
+        if (ids == null || ids.length == 0) {
+            throw new ServiceException("请选择要分配的会员");
+        }
+        fsUserBindWxMapper.batchAssignCompanyUser(ids, companyUserId);
+    }
+
+    @Override
+    public void unassignCompanyUser(Long id) {
+        FsUserBindWx bindWx = fsUserBindWxMapper.selectFsUserBindWxById(id);
+        if (bindWx == null) {
+            throw new ServiceException("绑定记录不存在");
+        }
+        if (bindWx.getBindUserId() != null) {
+            throw new ServiceException("请先解绑绑定的微信后再操作");
+        }
+        fsUserBindWxMapper.unassignCompanyUser(id);
+    }
+}

+ 280 - 1
fs-service/src/main/java/com/fs/his/service/impl/FsUserIntegralLogsServiceImpl.java

@@ -4,6 +4,7 @@ import cn.hutool.json.JSONUtil;
 import com.fs.common.core.domain.R;
 import com.fs.common.utils.DateUtils;
 import com.fs.his.config.IntegralConfig;
+import com.fs.his.config.PointsGrantRuleConfig;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.domain.FsUserNewTask;
@@ -13,6 +14,7 @@ import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.mapper.FsUserNewTaskMapper;
 import com.fs.his.param.FsUserAddIntegralParam;
 import com.fs.his.param.FsUserAddIntegralTemplateParam;
+import com.fs.his.param.FsUserGrantPointsParam;
 import com.fs.his.param.FsUserIntegralLogsListUParam;
 import com.fs.his.param.FsUserIntegralLogsParam;
 import com.fs.his.service.IFsUserIntegralLogsService;
@@ -20,14 +22,19 @@ import com.fs.his.vo.FsUserIntegralLogsListUVO;
 import com.fs.his.vo.FsUserIntegralLogsListVO;
 import com.fs.his.vo.SubIntegralVO;
 import com.fs.system.service.ISysConfigService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDate;
+import java.math.BigDecimal;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 积分记录Service业务层处理
@@ -47,6 +54,18 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
     private ISysConfigService configService;
     @Autowired
     private FsUserNewTaskMapper fsUserNewTaskMapper;
+    @Autowired
+    private RedissonClient redissonClient;
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    /** Redis 分布式锁 Key 前缀:积分发放 */
+    private static final String INTEGRAL_GRANT_LOCK_KEY = "integral:grant:%d:%d";
+    /** Redis Key 前缀:用户每日积分累计 */
+    private static final String INTEGRAL_DAILY_KEY = "integral:grant:daily:%d:%s";
+    /** Redis Key 前缀:用户每月积分累计 */
+    private static final String INTEGRAL_MONTHLY_KEY = "integral:grant:monthly:%d:%s";
+
     /**
      * 查询积分记录
      *
@@ -174,7 +193,7 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
             if (integralLogs==null){
                 FsUser userMap=new FsUser();
                 userMap.setUserId(user.getUserId());
-                userMap.setIntegral(user.getIntegral()+1000L);
+//                userMap.setIntegral(user.getIntegral()+1000L);
                 fsUserMapper.updateFsUser(userMap);
                 integralLogs = new FsUserIntegralLogs();
                 integralLogs.setIntegral(1000L);
@@ -543,4 +562,264 @@ public class FsUserIntegralLogsServiceImpl implements IFsUserIntegralLogsService
         map.put("isFinishFirstOrderPoint", isFinishFirstOrderPoint);
         return R.ok().put("data",map).put("isNewUser",isNewUser).put("createTime",createTime);
     }
+
+    // ======================== 通用积分发放 ========================
+
+    /**
+     * 通用积分发放(基于 points.grantRule 规则)
+     *
+     * <p>处理流程:</p>
+     * <ol>
+     *   <li>加载配置,禁用则跳过</li>
+     *   <li>按场景分支处理:消费送积分 / 退款扣回 / 管理员添加</li>
+     *   <li>范围校验:检查商品/分类是否在不送积分名单中</li>
+     *   <li>Redisson 分布式锁(userId + logType 粒度)</li>
+     *   <li>日/月上限校验(Redis 原子计数器 + DB 兜底)</li>
+     *   <li>更新用户积分余额 + 写入流水</li>
+     *   <li>最终释放锁</li>
+     * </ol>
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R grantPoints(FsUserGrantPointsParam param) {
+        log.info("通用积分发放开始: userId={}, logType={}, amount={}", param.getUserId(), param.getLogType(), param.getAmount());
+
+        // ------- 1. 加载发放规则配置 -------
+        String json = configService.selectConfigByKey("points.grantRule");
+        PointsGrantRuleConfig rule;
+        try {
+            rule = JSONUtil.toBean(json, PointsGrantRuleConfig.class);
+        } catch (Exception e) {
+            log.error("积分发放规则配置解析失败: {}", json, e);
+            return R.error("积分发放规则配置异常,请联系管理员");
+        }
+
+        if (rule == null || rule.getEnabled() == null || rule.getEnabled() != 1) {
+            log.info("积分发放规则未启用,跳过发放: userId={}", param.getUserId());
+            return R.ok("积分发放规则未启用");
+        }
+
+        Long userId = param.getUserId();
+        Integer logType = param.getLogType();
+        long integralCount;
+
+        // ------- 2. 按场景分支处理 -------
+        switch (logType) {
+            case 2: // 消费获得积分
+                if (param.getAmount() == null || param.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
+                    return R.error("消费金额必须大于0");
+                }
+                // 范围校验:是否在不送积分名单中
+                if (!isInGrantScope(rule, param.getProductId(), param.getCategoryId())) {
+                    log.info("该商品/分类不在积分发放范围内,跳过: userId={}, productId={}, categoryId={}",
+                            userId, param.getProductId(), param.getCategoryId());
+                    return R.ok("该商品不参与积分活动");
+                }
+                // 计算发放积分:amount × rate,进位取整
+                integralCount = param.getAmount()
+                        .multiply(rule.getRate() != null ? rule.getRate() : BigDecimal.ZERO)
+                        .setScale(0, BigDecimal.ROUND_CEILING)
+                        .longValue();
+                if (integralCount <= 0) {
+                    log.info("计算积分 <= 0,跳过发放: userId={}, amount={}, rate={}", userId, param.getAmount(), rule.getRate());
+                    return R.ok("积分比例为0,无需发放");
+                }
+                break;
+
+            case 4: // 退款扣除积分
+                if (!Integer.valueOf(1).equals(rule.getRefundDeduct())) {
+                    return R.ok("退款扣回积分未开启");
+                }
+                // 通过原订单号倒查原始积分发放记录,直接扣除对应积分
+                if (param.getOriginalOrderNo() == null || param.getOriginalOrderNo().isEmpty()) {
+                    return R.error("退款扣回需提供原订单号(originalOrderNo)");
+                }
+                FsUserIntegralLogs originalLog = fsUserIntegralLogsMapper.selectByUserIdAndBusinessId(
+                        userId, param.getOriginalOrderNo(), 2);
+                if (originalLog == null || originalLog.getIntegral() == null || originalLog.getIntegral() <= 0) {
+                    log.info("未找到原消费积分记录,无需扣除: userId={}, originalOrderNo={}", userId, param.getOriginalOrderNo());
+                    return R.ok("未找到该订单的积分发放记录");
+                }
+                integralCount = -originalLog.getIntegral();
+                break;
+
+            case 23: // 管理员手动添加 —— 不校验范围、不校验上限
+                if (param.getAmount() == null || param.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
+                    return R.error("发放金额(amount)必须大于0");
+                }
+                // 管理员添加直接使用 amount 作为积分数
+                integralCount = param.getAmount().longValue();
+                break;
+
+            default:
+                return R.error("不支持的积分发放类型: " + logType);
+        }
+
+        // ------- 3. 分布式锁 -------
+        String lockKey = String.format(INTEGRAL_GRANT_LOCK_KEY, userId, logType);
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
+            if (!locked) {
+                log.warn("获取积分发放锁失败: userId={}, logType={}", userId, logType);
+                return R.error("系统繁忙,请稍后重试");
+            }
+
+            // ------- 4. 日/月上限校验(如有需要在此扩展特定场景) -------
+            // 消费积分(logType=2)不限制日/月上限,管理员(logType=23)和退款(logType=4)也不校验
+
+            // ------- 5. 查询用户并发放 -------
+            FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+            if (user == null) {
+                return R.error("用户不存在");
+            }
+
+            long oldIntegral = user.getIntegral() != null ? user.getIntegral() : 0L;
+            long newIntegral = oldIntegral + integralCount;
+            if (newIntegral < 0) {
+                newIntegral = 0L;
+            }
+
+            // 更新用户积分余额
+            FsUser updateUser = new FsUser();
+            updateUser.setUserId(userId);
+            updateUser.setIntegral(newIntegral);
+            fsUserMapper.updateFsUser(updateUser);
+
+            // 写入积分流水
+            FsUserIntegralLogs logRecord = new FsUserIntegralLogs();
+            logRecord.setUserId(userId);
+            logRecord.setLogType(logType);
+            logRecord.setIntegral(integralCount);
+            logRecord.setBalance(newIntegral);
+            logRecord.setBusinessId(param.getBusinessId());
+            logRecord.setRemark(param.getRemark());
+            logRecord.setCreateTime(new Date());
+            fsUserIntegralLogsMapper.insertFsUserIntegralLogs(logRecord);
+
+            // 发放成功后无需累加 Redis 计数器(购买积分不设上限)
+
+            String typeDesc = FsUserIntegralLogTypeEnum.toType(logType) != null
+                    ? FsUserIntegralLogTypeEnum.toType(logType).getDesc() : "积分变动";
+            log.info("{}成功: userId={}, logType={}, integralCount={}, balance={}",
+                    typeDesc, userId, logType, integralCount, newIntegral);
+            return R.ok(typeDesc + "成功,积分变动: " + integralCount);
+
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("获取积分发放锁被中断: userId={}, logType={}", userId, logType, e);
+            return R.error("系统异常,请稍后重试");
+        } catch (Exception e) {
+            log.error("积分发放异常: userId={}, logType={}", userId, logType, e);
+            return R.error("积分发放失败: " + e.getMessage());
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    /**
+     * 校验商品/分类是否在积分发放范围内
+     *
+     * @return true=在范围内可发放, false=在排除名单中不发放
+     */
+    private boolean isInGrantScope(PointsGrantRuleConfig rule, Long productId, Long categoryId) {
+        if (rule.getScopeType() == null || rule.getScopeType() == 1) {
+            return true; // 全场商品参与
+        }
+        if (rule.getScopeType() == 2 && categoryId != null && rule.getExcludeCategoryIds() != null) {
+            // 指定分类不参与 —— 当前分类命中则排除
+            return !rule.getExcludeCategoryIds().contains(categoryId);
+        }
+        if (rule.getScopeType() == 3 && productId != null && rule.getExcludeProductIds() != null) {
+            // 指定商品不参与 —— 当前商品命中则排除
+            return !rule.getExcludeProductIds().contains(productId);
+        }
+        return true;
+    }
+
+    /**
+     * 校验日/月发放上限
+     * Redis 原子计数器为主,DB 查询为兜底
+     *
+     * @param rule          发放规则
+     * @param userId        用户ID
+     * @param logType       积分类型
+     * @param integralCount 本次发放积分
+     * @return null=通过校验, 非null=校验失败(含错误提示)
+     */
+    private R checkGrantLimit(PointsGrantRuleConfig rule, Long userId, Integer logType, long integralCount) {
+        String today = java.time.LocalDate.now().toString();
+        String yearMonth = java.time.YearMonth.now().toString();
+
+        //日上限校验
+        if (rule.getDailyLimit() != null && rule.getDailyLimit() > 0) {
+            String dailyKey = String.format(INTEGRAL_DAILY_KEY, userId, today);
+            Long dailyTotal = getOrInitRedisCounter(dailyKey, userId, logType, true);
+            if (dailyTotal != null && dailyTotal + integralCount > rule.getDailyLimit()) {
+                log.info("日积分已达上限: userId={}, dailyTotal={}, limit={}", userId, dailyTotal, rule.getDailyLimit());
+                return R.error("今日积分获取已达上限(" + rule.getDailyLimit() + ")");
+            }
+        }
+
+        //月上限校验
+        if (rule.getMonthlyLimit() != null && rule.getMonthlyLimit() > 0) {
+            String monthlyKey = String.format(INTEGRAL_MONTHLY_KEY, userId, yearMonth);
+            Long monthlyTotal = getOrInitRedisCounter(monthlyKey, userId, logType, false);
+            if (monthlyTotal != null && monthlyTotal + integralCount > rule.getMonthlyLimit()) {
+                log.info("月积分已达上限: userId={}, monthlyTotal={}, limit={}", userId, monthlyTotal, rule.getMonthlyLimit());
+                return R.error("本月积分获取已达上限(" + rule.getMonthlyLimit() + ")");
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 获取或初始化 Redis 计数器(日/月)
+     * Redis 无数据时兜底查询 DB 并回写
+     *
+     * @param key     Redis Key
+     * @param userId  用户ID
+     * @param logType 积分类型
+     * @param isDaily true=日查询, false=月查询
+     * @return 当前累计积分
+     */
+    private Long getOrInitRedisCounter(String key, Long userId, Integer logType, boolean isDaily) {
+        String value = stringRedisTemplate.opsForValue().get(key);
+        if (value != null) {
+            return Long.parseLong(value);
+        }
+        // Redis 无缓存 → DB 兜底
+        Long dbTotal;
+        if (isDaily) {
+            Integer result = fsUserIntegralLogsMapper.selectTodayTotalIntegral(
+                    new Integer[]{logType}, userId);
+            dbTotal = result != null ? result.longValue() : 0L;
+        } else {
+            dbTotal = fsUserIntegralLogsMapper.selectMonthlyTotalIntegral(userId, logType);
+        }
+        // 回写 Redis(根据是日 or 月设置过期时间)
+        stringRedisTemplate.opsForValue().set(key, String.valueOf(dbTotal),
+                isDaily ? 24 : 31 * 24, TimeUnit.HOURS);
+        return dbTotal;
+    }
+
+    /**
+     * 发放成功后累加 Redis 日/月计数器
+     */
+    private void incrementRedisCounter(Long userId, Integer logType, long integralCount) {
+        try {
+            String today = java.time.LocalDate.now().toString();
+            String yearMonth = java.time.YearMonth.now().toString();
+            String dailyKey = String.format(INTEGRAL_DAILY_KEY, userId, today);
+            String monthlyKey = String.format(INTEGRAL_MONTHLY_KEY, userId, yearMonth);
+            stringRedisTemplate.opsForValue().increment(dailyKey, integralCount);
+            stringRedisTemplate.opsForValue().increment(monthlyKey, integralCount);
+        } catch (Exception e) {
+            log.warn("Redis计数器累加失败(不影响主流程): userId={}, logType={}", userId, logType, e);
+        }
+    }
 }

+ 8 - 8
fs-service/src/main/java/com/fs/his/service/impl/FsUserInvitedServiceImpl.java

@@ -188,20 +188,20 @@ public class FsUserInvitedServiceImpl extends ServiceImpl<FsUserInvitedMapper, F
         invitedParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_19.getValue());
         invitedParam.setUserId(invitedUserId);
         invitedParam.setBusinessId(userId.toString());
-        R invitedR = integralLogsService.addIntegralTemplate(invitedParam);
+//        R invitedR = integralLogsService.addIntegralTemplate(invitedParam);
         //2.2邀请人A获得1000芳华币
         FsUserAddIntegralTemplateParam param = new FsUserAddIntegralTemplateParam();
         param.setUserId(userId);
         param.setLogType(FsUserIntegralLogTypeEnum.TYPE_18.getValue());
         param.setBusinessId(invitedUserId.toString());
-        R r = integralLogsService.addIntegralTemplate(param);
+//        R r = integralLogsService.addIntegralTemplate(param);
 //        return AjaxResult.success(invitedR.get("msg").toString() + r.get("msg").toString());
-        if ("200".equals(invitedR.get("code").toString()) && "200".equals(r.get("code").toString())){
-            FsUser temp = new FsUser();
-            temp.setParentId(userId);
-            temp.setUserId(invitedUserId);
-            userService.updateFsUser(temp);
-        }
+//        if ("200".equals(invitedR.get("code").toString()) && "200".equals(r.get("code").toString())){
+//            FsUser temp = new FsUser();
+//            temp.setParentId(userId);
+//            temp.setUserId(invitedUserId);
+//            userService.updateFsUser(temp);
+//        }
         return AjaxResult.success();
     }
 }

+ 1 - 1
fs-service/src/main/java/com/fs/his/service/impl/FsUserNewTaskServiceImpl.java

@@ -140,7 +140,7 @@ public class FsUserNewTaskServiceImpl implements IFsUserNewTaskService
             param.setUserId(userId);
             param.setLogType(FsUserIntegralLogTypeEnum.TYPE_20.getValue());
             param.setBusinessId(userId.toString());
-            integralLogsService.addIntegralTemplate(param);
+//            integralLogsService.addIntegralTemplate(param);
 
             isFirstLogin = 1;
         }

+ 18 - 6
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -367,7 +367,7 @@ public class FsUserServiceImpl implements IFsUserService {
         integralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_2.getValue());
         integralTemplateParam.setBusinessId(orderId.toString());
         integralTemplateParam.setPoints(points);
-        userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
+//        userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
     }
 
     /**
@@ -586,12 +586,12 @@ public class FsUserServiceImpl implements IFsUserService {
 
     @Override
     public PageInfo<FsUserPageListVO> selectFsUserPageList(FsUserPageListParam param) {
-        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(param.getUserId());
-        if (companyUser != null && companyUser.isAdmin() && param.getCompanyUserId() == null) {
+//        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(param.getUserId());
+//        if (companyUser != null && companyUser.isAdmin() && param.getCompanyUserId() == null) {
 //            param.setUserId(0L);
 //            param.setCompanyId(companyUser.getCompanyId());
-            return null;
-        }
+//            return null;
+//        }
 
         //筛选问题
         if (StringUtils.isNotBlank(param.getCompanyUserId())) {
@@ -1486,7 +1486,7 @@ public class FsUserServiceImpl implements IFsUserService {
     }
 
     @Override
-    public R batchAddUserAndSaleFriend(List<FsUser> userList, Long compayUserId) {
+    public R batchAddUserAndSaleFriend(List<FsUser> userList, Long compayUserId, Long projectId, Long companyId) {
         if (StringUtils.isEmpty(openIMService.getAdminToken())) {
             logger.error("获取IMTOKEN失败------》:{}", compayUserId);
             return R.error("获取IMTOKEN失败,请联系管理员!");
@@ -1542,6 +1542,18 @@ public class FsUserServiceImpl implements IFsUserService {
         if (imUserIds.size() < usersToBind.size()) {
             return R.ok("部分用户绑定成功,部分用户IM注册失败已跳过");
         }
+        // 手动发课:写入fs_user_company_user绑定关系(userId+companyUserId+projectId查重,有则复用无则新增)
+        if (projectId != null && companyId == null) {
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserById(compayUserId);
+            if (companyUser != null) {
+                companyId = companyUser.getCompanyId();
+            }
+        }
+        if (projectId != null && companyId != null) {
+            for (FsUser user : usersToBind) {
+                userCompanyUserService.bindRelationship(user.getUserId(), projectId, companyId, compayUserId, 1);
+            }
+        }
         return R.ok("操作成功!");
     }
 

+ 1 - 1
fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java

@@ -100,6 +100,6 @@ public class PhoneUtil {
     }
 
     public static void main(String[] args) {
-        System.out.println(encryptPhone("13309215228"));
+        System.out.println(encryptPhone("13594768440"));
     }
 }

+ 3 - 1
fs-service/src/main/java/com/fs/hisStore/enums/SysConfigEnum.java

@@ -85,7 +85,9 @@ public enum SysConfigEnum {
     //红包流量,joinTime.switch.config
     JOIN_TIME_SWITCH_CONFIG("joinTime.switch.config", "红包流量"),
     //签到配置,store.sign
-    SIGN_CONFIG("store.sign", "签到配置");
+    SIGN_CONFIG("store.sign", "签到配置"),
+    //发放积分规则配置,points.grantRule
+    POINTS_GRANT_RULE_CONFIG("points.grantRule", "发放积分规则配置");
     private final String key;
     private final String name;
 

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

@@ -65,6 +65,10 @@ import com.fs.his.dto.FsPrescribeUsageDTO;
 import com.fs.his.dto.FsProdItemDTO;
 import com.fs.his.dto.FsStoreOrderAmountScrmStatsQueryDto;
 import com.fs.his.dto.FsStoreOrderItemDTO;
+import com.fs.his.param.FsUserGrantPointsParam;
+import com.fs.his.service.IFsUserIntegralLogsService;
+import com.fs.his.mapper.FsUserIntegralLogsMapper;
+import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.enums.FsStoreOrderLogEnum;
 import com.fs.his.enums.FsStoreOrderStatusEnum;
 import com.fs.his.mapper.*;
@@ -386,6 +390,12 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     @Autowired
     private FsStoreOrderItemScrmMapper fsStoreOrderItemScrmMapper;
 
+    @Autowired
+    private IFsUserIntegralLogsService fsUserIntegralLogsService;
+
+    @Autowired
+    private FsUserIntegralLogsMapper fsUserIntegralLogsMapper;
+
     @Autowired
     private FsStoreErpInfoMapper fsStoreErpInfoMapper;
 
@@ -2269,6 +2279,9 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             }
 
 
+            // 消费送积分:确认收货后按规则发放积分
+            grantPointsByOrder(order);
+
             //模板消息支付成功发布事件
             TemplateBean templateBean = TemplateBean.builder()
                     .orderId(order.getId().toString())
@@ -2285,6 +2298,113 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         }
     }
 
+    /**
+     * 确认收货后按积分规则发放消费积分(logType=2)
+     * <p>遍历订单所有商品明细,逐商品计算金额(price×num)并调用积分发放逻辑</p>
+     * <p>非核心流程,吞异常不阻断订单完成</p>
+     */
+    private void grantPointsByOrder(FsStoreOrderScrm order) {
+        try {
+            // 订单级幂等校验:同一订单不可重复发放积分
+            FsUserIntegralLogs existingLog = fsUserIntegralLogsMapper.selectByUserIdAndBusinessId(
+                    order.getUserId(), order.getId().toString(), 2);
+            if (existingLog != null) {
+                log.info("订单已发放过积分,跳过: orderId={}, orderCode={}", order.getId(), order.getOrderCode());
+                return;
+            }
+
+            List<FsStoreOrderItemScrm> items = fsStoreOrderItemScrmMapper
+                    .selectFsStoreOrderItemByOrderCode(order.getOrderCode());
+            if (items == null || items.isEmpty()) {
+                log.warn("订单无商品明细,跳过积分发放: orderCode={}", order.getOrderCode());
+                return;
+            }
+
+            int grantedCount = 0;
+            for (FsStoreOrderItemScrm item : items) {
+                try {
+                    if (grantPointsByOrderItem(order, item)) {
+                        grantedCount++;
+                    }
+                } catch (Exception e) {
+                    log.error("订单商品积分发放失败: orderCode={}, itemId={}", order.getOrderCode(), item.getItemId(), e);
+                }
+            }
+            log.info("订单积分发放完成: orderCode={}, 总商品数={}, 成功发放={}",
+                    order.getOrderCode(), items.size(), grantedCount);
+        } catch (Exception e) {
+            log.error("确认收货发放积分失败 orderId={} orderCode={}", order.getId(), order.getOrderCode(), e);
+        }
+    }
+
+    /**
+     * 单个商品明细的积分发放
+     * <p>解析 jsonInfo 提取 price/num → 计算 itemAmount → 获取商品分类 → 调用通用发放方法</p>
+     *
+     * @return true=成功发放, false=跳过(商品不参与积分类活动等)
+     */
+    private boolean grantPointsByOrderItem(FsStoreOrderScrm order, FsStoreOrderItemScrm item) {
+        // 1. 解析 jsonInfo 获取 price 和 num
+        BigDecimal itemPrice = BigDecimal.ZERO;
+        Integer itemNum = (item.getNum() != null && item.getNum() > 0) ? item.getNum() : 1;
+
+        if (StringUtils.isNotBlank(item.getJsonInfo())) {
+            try {
+                JSONObject jsonInfo = JSON.parseObject(item.getJsonInfo());
+                if (jsonInfo.get("price") != null) {
+                    itemPrice = jsonInfo.getBigDecimal("price");
+                }
+                // json_info 中的 num 优先于外层字段(更准确)
+                if (jsonInfo.get("num") != null) {
+                    itemNum = jsonInfo.getInteger("num");
+                }
+            } catch (Exception e) {
+                log.warn("解析订单商品json_info失败, 使用默认价格0: orderCode={}, itemId={}, jsonInfo={}",
+                        order.getOrderCode(), item.getItemId(), item.getJsonInfo(), e);
+            }
+        } else {
+            log.warn("订单商品json_info为空: orderCode={}, itemId={}", order.getOrderCode(), item.getItemId());
+        }
+
+        BigDecimal itemAmount = itemPrice.multiply(BigDecimal.valueOf(itemNum));
+        if (itemAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            log.info("商品金额<=0,跳过积分发放: orderCode={}, itemId={}, price={}, num={}",
+                    order.getOrderCode(), item.getItemId(), itemPrice, itemNum);
+            return false;
+        }
+
+        // 2. 查询商品分类信息(用于积分范围校验)
+        Long productId = item.getProductId();
+        Long categoryId = null;
+        if (productId != null) {
+            FsStoreProductScrm product = fsStoreProductMapper.selectFsStoreProductById(productId);
+            if (product != null) {
+                categoryId = product.getCateId();
+            }
+        }
+
+        // 3. 构建参数并调用通用积分发放
+        FsUserGrantPointsParam param = new FsUserGrantPointsParam();
+        param.setUserId(order.getUserId());
+        param.setLogType(2);
+        param.setAmount(itemAmount);
+        param.setProductId(productId);
+        param.setCategoryId(categoryId);
+        param.setBusinessId(order.getId().toString());
+        param.setRemark("订单确认收货送积分");
+
+        R result = fsUserIntegralLogsService.grantPoints(param);
+        boolean success = result != null && Integer.valueOf(200).equals(result.get("code"));
+        if (success) {
+            log.info("订单商品积分发放成功: orderCode={}, itemId={}, productId={}, amount={}",
+                    order.getOrderCode(), item.getItemId(), productId, itemAmount);
+        } else {
+            log.info("订单商品积分发放跳过: orderCode={}, itemId={}, reason={}",
+                    order.getOrderCode(), item.getItemId(), result != null ? result.get("msg") : "null");
+        }
+        return success;
+    }
+
     @Override
     public BigDecimal selectFsStoreOrderByPayPriceCount(FsStoreOrderStatisticsParam param) {
         return fsStoreOrderMapper.selectFsStoreOrderByPayPriceCount(param);

+ 9 - 0
fs-service/src/main/java/com/fs/im/service/OpenIMService.java

@@ -5,6 +5,7 @@ import com.fs.common.core.domain.R;
 import com.fs.company.domain.CompanyUser;
 import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.dto.BatchUrgeCourseDTO;
+import com.fs.course.dto.SendProjectMsgDTO;
 import com.fs.im.domain.FsImMsgSendDetail;
 import com.fs.im.domain.FsImMsgSendLog;
 import com.fs.im.dto.OpenImBatchMsgDTO;
@@ -93,6 +94,14 @@ public interface OpenIMService {
 
     OpenImResponseDTO sendInquiryUtil(String sendID, String recvID, Integer contentType, String payloadData,String inquiryName,String type,Long companyId,Long companyUserId,String doctorId);
 
+    /**
+     * 批量发送项目注册链接
+     * @param dto 入参(projectId、userIds/tagIds、companyUserId等)
+     * @return OpenImResponseDTO
+     * @throws JsonProcessingException
+     */
+    OpenImResponseDTO batchSendProjectMsg(SendProjectMsgDTO dto) throws JsonProcessingException;
+
     /**
      * 删除关系链好友信息
      * @param ownerUserID 需要删除好友的用户

+ 172 - 0
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -21,6 +21,7 @@ import com.fs.course.domain.FsUserCourse;
 import com.fs.course.dto.BatchSendCourseAllDTO;
 import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.dto.BatchUrgeCourseDTO;
+import com.fs.course.dto.SendProjectMsgDTO;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCompanyUserMapper;
 import com.fs.course.mapper.FsUserCourseMapper;
@@ -1809,6 +1810,177 @@ public class OpenIMServiceImpl implements OpenIMService {
         }
     }
 
+
+    @Override
+    @Transactional
+    public OpenImResponseDTO batchSendProjectMsg(SendProjectMsgDTO dto) throws JsonProcessingException {
+        //获取接收人列表
+        List<String> recvIds = getRecvIdsForProject(dto);
+
+        //注册账号并添加好友
+        for (String recvId : recvIds) {
+            String uid = recvId.substring(1);
+            checkAndImportFriendByDianBo(dto.getCompanyUserId(), uid, null, false);
+        }
+
+        //组装IM消息(默认实时发送,sendMode=1 APP端)
+        long planSendTimeStamp = System.currentTimeMillis();
+        ObjectMapper objectMapper = new ObjectMapper();
+        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+        OpenImBatchMsgDTO openImBatchMsgDTO = makeOpenImProjectMsgDTO(dto, recvIds, planSendTimeStamp, objectMapper);
+
+        //创建发送记录
+        String sendUnionId = UUID.randomUUID().toString();
+        List<FsImMsgSendDetail> detailList = createProjectMsgSendLog(dto, planSendTimeStamp, recvIds, sendUnionId);
+
+        //实时发送
+        OpenImResponseDTO response = openIMBatchSendMsg(openImBatchMsgDTO);
+        if (response.getErrCode() == 0) {
+            updateProjectMsgSendLog(openImBatchMsgDTO, detailList, response);
+        }
+
+        log.info("批量发送项目注册链接完成, projectId={}, 接收人数={}", dto.getProjectId(), recvIds.size());
+        return response;
+    }
+
+    /**
+     * 解析项目消息接收人:优先 userIds,否则按 tagIds + companyUserId 查询
+     */
+    private List<String> getRecvIdsForProject(SendProjectMsgDTO dto) {
+        List<String> userIds;
+        if (dto.getUserIds() != null && !dto.getUserIds().isEmpty()) {
+            userIds = dto.getUserIds().stream().map(v -> "U" + v).collect(Collectors.toList());
+        } else if (dto.getTagIds() != null && !dto.getTagIds().isEmpty()) {
+            Map<String, Object> param = new HashMap<>();
+            param.put("tagIds", dto.getTagIds());
+            param.put("companyUserId", dto.getCompanyUserId());
+            List<FsUserCompanyUser> users = fsUserCompanyUserMapper.selectFsUserCompanyUserByIds(param);
+            if (users.isEmpty()) {
+                log.error("没有消息接收人,参数:{}", dto);
+                throw new ServiceException("没有消息接收人");
+            }
+            userIds = users.stream().map(v -> "U" + v.getUserId()).collect(Collectors.toList());
+        } else {
+            throw new ServiceException("userIds 和 tagIds 至少传一个");
+        }
+        return userIds;
+    }
+
+    /**
+     * 组装项目注册链接 IM 批量消息
+     */
+    private OpenImBatchMsgDTO makeOpenImProjectMsgDTO(SendProjectMsgDTO dto, List<String> recvIds,
+                                                       long planSendTimeStamp, ObjectMapper objectMapper)
+            throws JsonProcessingException {
+        PayloadDTO.Extension extension = new PayloadDTO.Extension();
+        extension.setTitle(dto.getTitle());
+        extension.setProjectId(dto.getProjectId());
+        extension.setCompanyUserId(dto.getCompanyUserId());
+        extension.setCompanyId(dto.getCompanyId());
+        extension.setSendTime(new Date(planSendTimeStamp));
+
+        PayloadDTO payload = new PayloadDTO();
+        payload.setData("project");
+        payload.setExtension(extension);
+
+        OpenImBatchMsgDTO.ImData imData = new OpenImBatchMsgDTO.ImData();
+        imData.setPayload(payload);
+
+        OpenImBatchMsgDTO.Content content = new OpenImBatchMsgDTO.Content();
+        content.setData(objectMapper.writeValueAsString(imData));
+
+        OpenImBatchMsgDTO.OfflinePushInfo offlinePushInfo = new OpenImBatchMsgDTO.OfflinePushInfo();
+        offlinePushInfo.setTitle(dto.getTitle());
+        offlinePushInfo.setDesc(dto.getTitle());
+        offlinePushInfo.setIOSBadgeCount(true);
+        offlinePushInfo.setIOSPushSound("");
+
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(dto.getCompanyUserId());
+
+        OpenImBatchMsgDTO msgDTO = new OpenImBatchMsgDTO();
+        msgDTO.setSendID("C" + dto.getCompanyUserId());
+        msgDTO.setRecvIDs(recvIds);
+        msgDTO.setContent(content);
+        msgDTO.setContentType(110);
+        msgDTO.setSessionType(1);
+        msgDTO.setIsOnlineOnly(false);
+        msgDTO.setNotOfflinePush(false);
+        msgDTO.setSendTime(planSendTimeStamp);
+        msgDTO.setOfflinePushInfo(offlinePushInfo);
+        msgDTO.setIsSendAll(false);
+        if (companyUser != null) {
+            offlinePushInfo.setTitle(companyUser.getNickName());
+        }
+        return msgDTO;
+    }
+
+    /**
+     * 创建项目消息发送记录(主表 + 详情),默认 sendType=2 实时、sendMode=1 APP端
+     */
+    private List<FsImMsgSendDetail> createProjectMsgSendLog(SendProjectMsgDTO dto, long planSendTimeStamp,
+                                                             List<String> recvIds, String sendUnionId) {
+        FsImMsgSendLog sendLog = new FsImMsgSendLog();
+        sendLog.setCompanyUserId(dto.getCompanyUserId());
+        sendLog.setCompanyId(dto.getCompanyId());
+        sendLog.setProjectId(dto.getProjectId());
+        sendLog.setSendTitle(dto.getTitle());
+        sendLog.setSendType(2);
+        sendLog.setSendMode(1);
+        sendLog.setMsgType(3); // 3=项目注册链接
+        sendLog.setSendUnionId(sendUnionId);
+        sendLog.setPlanSendTime(new Date(planSendTimeStamp));
+        sendLog.setSendStatus(1);
+        sendLog.setCreateBy(String.valueOf(dto.getCompanyUserId()));
+        sendLog.setCreateTime(new Date());
+        if (dto.getTagIds() != null && !dto.getTagIds().isEmpty()) {
+            sendLog.setTagIds(Joiner.on(",").join(dto.getTagIds()));
+        }
+        fsImMsgSendLogMapper.insert(sendLog);
+
+        List<FsImMsgSendDetail> detailList = new LinkedList<>();
+        for (String recvId : recvIds) {
+            FsImMsgSendDetail detail = new FsImMsgSendDetail();
+            detail.setLogId(sendLog.getLogId());
+            detail.setCompanyUserId(dto.getCompanyUserId());
+            Long uid = Long.parseLong(recvId.replaceFirst("^U", ""));
+            detail.setUserId(uid);
+            detail.setPlanSendTime(new Date(planSendTimeStamp));
+            detail.setSendStatus(1);
+            detail.setCreateTime(new Date());
+            detailList.add(detail);
+        }
+        fsImMsgSendDetailServiceImpl.saveBatch(detailList);
+        return detailList;
+    }
+
+    /**
+     * 更新项目消息发送记录详情(实时发送后回写结果)
+     */
+    private void updateProjectMsgSendLog(OpenImBatchMsgDTO openImBatchMsgDTO,
+                                          List<FsImMsgSendDetail> detailList,
+                                          OpenImResponseDTO response) {
+        if (response.getErrCode() != 0) {
+            log.error("项目注册链接发送失败: {}", response);
+            return;
+        }
+        // 批量更新为已发送
+        List<FsImMsgSendDetail> updateList = detailList.stream().map(v -> {
+            v.setSendStatus(1);
+            v.setStatus(0);
+            v.setActualSendTime(new Date());
+            v.setUpdateTime(new Date());
+            return v;
+        }).collect(Collectors.toList());
+        fsImMsgSendDetailServiceImpl.updateBatchById(updateList);
+
+        // 更新主表
+        FsImMsgSendLog sendLog = new FsImMsgSendLog();
+        sendLog.setLogId(detailList.get(0).getLogId());
+        sendLog.setSendStatus(1);
+        sendLog.setUpdateTime(new Date());
+        fsImMsgSendLogMapper.updateById(sendLog);
+    }
+
     @Override
     public OpenImResponseDTO deleteFriend(String ownerUserID, String friendUserID) {
         String adminToken = getAdminToken();

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

@@ -147,7 +147,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 and l.create_time &gt;= CONCAT(#{maps.sTime},' 00:00:00')
             </if>
             <if test='maps.eTime != null '>
-                and l.create_time &lt;= CONCAT(#{maps.eTime},' 00:00:00')
+                and l.create_time &lt;= CONCAT(#{maps.eTime},' 23:59:59')
             </if>
             <if test= 'maps.scheduleStartTime != null '>
                 and DATE(l.camp_period_time) &gt;= DATE(#{maps.scheduleStartTime})

+ 391 - 0
fs-service/src/main/resources/mapper/his/FsUserBindWxMapper.xml

@@ -0,0 +1,391 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.his.mapper.FsUserBindWxMapper">
+
+    <resultMap type="FsUserBindWx" id="FsUserBindWxResult">
+        <result property="id"              column="id"              />
+        <result property="fsUserId"        column="fs_user_id"      />
+        <result property="unionId"         column="union_id"        />
+        <result property="maOpenId"        column="ma_open_id"      />
+        <result property="mpOpenId"        column="mp_open_id"      />
+        <result property="bizType"         column="biz_type"        />
+        <result property="companyId"       column="company_id"      />
+        <result property="companyUserId"   column="company_user_id" />
+        <result property="bindUserId"      column="bind_user_id"    />
+        <result property="externalContactId" column="external_contact_id" />
+        <result property="userCompanyUserId" column="user_company_user_id" />
+        <result property="type"          column="type"             />
+        <result property="createTime"      column="create_time"     />
+        <result property="updateTime"      column="update_time"     />
+    </resultMap>
+
+    <sql id="selectFsUserBindWxVo">
+        select id, fs_user_id, union_id, ma_open_id, mp_open_id, biz_type,
+               company_id, company_user_id, bind_user_id, external_contact_id, user_company_user_id, type,
+               create_time, update_time
+        from fs_user_bind_wx
+    </sql>
+
+    <select id="selectFsUserBindWxById" parameterType="Long" resultMap="FsUserBindWxResult">
+        <include refid="selectFsUserBindWxVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectFsUserBindWxList" parameterType="FsUserBindWx" resultMap="FsUserBindWxResult">
+        <include refid="selectFsUserBindWxVo"/>
+        <where>
+            <if test="fsUserId != null">and fs_user_id = #{fsUserId}</if>
+            <if test="unionId != null and unionId != ''">and union_id = #{unionId}</if>
+            <if test="maOpenId != null and maOpenId != ''">and ma_open_id = #{maOpenId}</if>
+            <if test="mpOpenId != null and mpOpenId != ''">and mp_open_id = #{mpOpenId}</if>
+            <if test="bizType != null">and biz_type = #{bizType}</if>
+            <if test="companyId != null">and company_id = #{companyId}</if>
+            <if test="companyUserId != null">and company_user_id = #{companyUserId}</if>
+        </where>
+    </select>
+
+    <insert id="insertFsUserBindWx" parameterType="FsUserBindWx" useGeneratedKeys="true" keyProperty="id">
+        insert into fs_user_bind_wx
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="fsUserId != null">fs_user_id,</if>
+            <if test="unionId != null">union_id,</if>
+            <if test="maOpenId != null">ma_open_id,</if>
+            <if test="mpOpenId != null">mp_open_id,</if>
+            <if test="bizType != null">biz_type,</if>
+            <if test="companyId != null">company_id,</if>
+            <if test="companyUserId != null">company_user_id,</if>
+            <if test="bindUserId != null">bind_user_id,</if>
+            <if test="externalContactId != null">external_contact_id,</if>
+            <if test="userCompanyUserId != null">user_company_user_id,</if>
+            <if test="type != null">type,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateTime != null">update_time,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="fsUserId != null">#{fsUserId},</if>
+            <if test="unionId != null">#{unionId},</if>
+            <if test="maOpenId != null">#{maOpenId},</if>
+            <if test="mpOpenId != null">#{mpOpenId},</if>
+            <if test="bizType != null">#{bizType},</if>
+            <if test="companyId != null">#{companyId},</if>
+            <if test="companyUserId != null">#{companyUserId},</if>
+            <if test="bindUserId != null">#{bindUserId},</if>
+            <if test="externalContactId != null">#{externalContactId},</if>
+            <if test="userCompanyUserId != null">#{userCompanyUserId},</if>
+            <if test="type != null">#{type},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+        </trim>
+    </insert>
+
+    <update id="updateFsUserBindWx" parameterType="FsUserBindWx">
+        update fs_user_bind_wx
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="fsUserId != null">fs_user_id = #{fsUserId},</if>
+            <if test="unionId != null">union_id = #{unionId},</if>
+            <if test="maOpenId != null">ma_open_id = #{maOpenId},</if>
+            <if test="mpOpenId != null">mp_open_id = #{mpOpenId},</if>
+            <if test="bizType != null">biz_type = #{bizType},</if>
+            <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="companyUserId != null">company_user_id = #{companyUserId},</if>
+            <if test="bindUserId != null">bind_user_id = #{bindUserId},</if>
+            <if test="externalContactId != null">external_contact_id = #{externalContactId},</if>
+            <if test="userCompanyUserId != null">user_company_user_id = #{userCompanyUserId},</if>
+            <if test="type != null">type = #{type},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteFsUserBindWxById" parameterType="Long">
+        delete from fs_user_bind_wx where id = #{id}
+    </delete>
+
+    <delete id="deleteFsUserBindWxByIds" parameterType="Long">
+        delete from fs_user_bind_wx where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <select id="selectUnassignedUserList" parameterType="com.fs.his.domain.vo.UnassignedUserQuery" resultType="com.fs.his.domain.vo.UnassignedUserVO">
+        SELECT
+            fu.user_id        AS userId,
+            fu.nick_name       AS nickName,
+            fu.avatar          AS avatar,
+            fu.phone           AS phone,
+            fu.create_time     AS createTime,
+            bx.id              AS bindId,
+            IF(bx.id IS NOT NULL, 1, 0) AS bindStatus
+        FROM
+            fs_user fu
+            LEFT JOIN fs_user_bind_wx bx ON fu.user_id = bx.fs_user_id
+        WHERE
+            fu.biz_type = 1
+            AND fu.phone IS NOT NULL
+            AND fu.phone != ''
+            AND fu.union_id IS NULL
+        <if test="userId != null and userId != ''">
+            AND fu.user_id = #{userId}
+        </if>
+        <if test="nickName != null and nickName != ''">
+            AND fu.nick_name LIKE CONCAT('%', #{nickName}, '%')
+        </if>
+        <if test="phoneMk != null and phoneMk != ''">
+            AND fu.phone = #{phoneMk}
+        </if>
+        <if test="phone != null and phone != ''">
+            AND fu.phone LIKE CONCAT('%', #{phone}, '%')
+        </if>
+        <if test="bindStatus != null and bindStatus != ''">
+            <choose>
+                <when test="bindStatus == 0">
+                    AND bx.id IS NULL
+                </when>
+                <when test="bindStatus == 1">
+                    AND bx.id IS NOT NULL
+                </when>
+            </choose>
+        </if>
+        group by fu.user_id
+        ORDER BY fu.create_time DESC
+    </select>
+
+    <select id="selectBoundUserIds" resultType="java.lang.Long">
+        SELECT fs_user_id FROM fs_user_bind_wx
+        WHERE fs_user_id IN
+        <foreach item="userId" collection="userIds" open="(" separator="," close=")">
+            #{userId}
+        </foreach>
+        AND company_id = #{companyId}
+    </select>
+
+    <insert id="batchInsertBindIgnore">
+        INSERT IGNORE INTO fs_user_bind_wx (fs_user_id, company_id, create_time)
+        VALUES
+        <foreach item="item" collection="list" separator=",">
+            (#{item.fsUserId}, #{item.companyId}, NOW())
+        </foreach>
+    </insert>
+
+    <select id="selectBindListByFsUserId" parameterType="Long" resultType="com.fs.his.domain.vo.BindListVO">
+        SELECT
+            bx.id              AS id,
+            bx.fs_user_id       AS fsUserId,
+            bx.company_id       AS companyId,
+            bx.biz_type         AS bizType,
+            bx.create_time      AS createTime,
+            c.company_name      AS companyName,
+            bx.company_user_id  AS companyUserId,
+            cu.nick_name        AS companyUserName,
+            cu.user_name        AS companyUserAccount,
+            bx.type             AS type,
+            bx.bind_user_id     AS bindUserId,
+            bx.external_contact_id AS externalContactId,
+            ec.name             AS externalContactName,
+            ec.avatar           AS externalContactAvatar,
+            fucu.project_id     AS projectId
+        FROM fs_user_bind_wx bx
+        LEFT JOIN company c ON bx.company_id = c.company_id
+        LEFT JOIN company_user cu ON bx.company_user_id = cu.user_id
+        LEFT JOIN qw_external_contact ec ON bx.external_contact_id = ec.id
+        LEFT JOIN fs_user_company_user fucu ON bx.user_company_user_id = fucu.id
+        WHERE bx.fs_user_id = #{fsUserId}
+        ORDER BY bx.create_time DESC
+    </select>
+
+    <select id="selectUserDetailByFsUserId" parameterType="Long" resultType="com.fs.his.domain.vo.UserDetailVO">
+        SELECT
+            user_id        AS userId,
+            nick_name      AS nickName,
+            avatar         AS avatar,
+            phone          AS phone,
+            create_time    AS createTime,
+            sex            AS sex
+        FROM fs_user
+        WHERE user_id = #{fsUserId}
+    </select>
+
+    <select id="selectCompanyBindUserList" parameterType="com.fs.his.domain.vo.CompanyBindUserQuery" resultType="com.fs.his.domain.vo.CompanyBindUserVO">
+        SELECT
+            bx.id              AS id,
+            fu.user_id         AS userId,
+            fu.nick_name       AS nickName,
+            fu.avatar          AS avatar,
+            fu.phone           AS phone,
+            bx.create_time      AS createTime,
+            bx.bind_user_id    AS bindUserId,
+            bx.type             AS type,
+            bx.external_contact_id AS externalContactId,
+            ec.name            AS externalContactName,
+            ec.avatar          AS externalContactAvatar,
+            bx.company_user_id AS companyUserId,
+            cu.nick_name       AS companyUserName,
+            cu.user_name       AS companyUserAccount,
+            fucu.project_id    AS projectId
+        FROM fs_user_bind_wx bx
+        LEFT JOIN fs_user fu ON bx.fs_user_id = fu.user_id
+        LEFT JOIN qw_external_contact ec ON bx.external_contact_id = ec.id
+        LEFT JOIN company_user cu ON bx.company_user_id = cu.user_id
+        LEFT JOIN fs_user_company_user fucu ON bx.user_company_user_id = fucu.id
+        WHERE 1=1
+        <if test="companyId != null">
+            AND bx.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null">
+            AND bx.company_user_id = #{companyUserId}
+        </if>
+        <if test="userId != null and userId != ''">
+            AND fu.user_id = #{userId}
+        </if>
+        <if test="nickName != null and nickName != ''">
+            AND fu.nick_name LIKE CONCAT('%', #{nickName}, '%')
+        </if>
+        <if test="phoneMk != null and phoneMk != ''">
+            AND fu.phone = #{phoneMk}
+        </if>
+        <if test="bindStatus != null">
+            <choose>
+                <when test="bindStatus == 0">
+                    AND bx.bind_user_id IS NULL
+                </when>
+                <when test="bindStatus == 1">
+                    AND bx.bind_user_id IS NOT NULL
+                </when>
+            </choose>
+        </if>
+        <if test="assignStatus != null">
+            <choose>
+                <when test="assignStatus == 0">
+                    AND bx.company_user_id IS NULL
+                </when>
+                <when test="assignStatus == 1">
+                    AND bx.company_user_id IS NOT NULL
+                </when>
+            </choose>
+        </if>
+        ORDER BY bx.create_time DESC
+    </select>
+
+    <select id="selectAssociatedUserList" parameterType="com.fs.his.domain.vo.AssociatedUserQuery" resultType="com.fs.his.domain.vo.AssociatedUserVO">
+        SELECT
+            fu.user_id         AS userId,
+            fu.nick_name       AS nickName,
+            fu.avatar          AS avatar
+        FROM fs_user fu
+        INNER JOIN fs_user_company_user fc ON fu.user_id = fc.user_id
+        WHERE fu.create_time > '2026-03-01 00:00:00' AND fu.union_id IS NOT NULL
+        <if test="companyId != null">
+            AND fc.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null">
+            AND fc.company_user_id = #{companyUserId}
+        </if>
+        <if test="userId != null and userId != ''">
+            AND fu.user_id = #{userId}
+        </if>
+        <if test="nickName != null and nickName != ''">
+            AND fu.nick_name LIKE CONCAT('%', #{nickName}, '%')
+        </if>
+        ORDER BY fu.create_time DESC
+    </select>
+
+    <select id="selectFsUserWxInfo" parameterType="Long" resultType="com.fs.his.domain.vo.FsUserWxInfoVO">
+        SELECT
+            user_id        AS userId,
+            union_id       AS unionId,
+            ma_open_id     AS maOpenId,
+            mp_open_id     AS mpOpenId
+        FROM fs_user
+        WHERE user_id = #{userId}
+    </select>
+
+    <update id="updateBindWxUser">
+        UPDATE fs_user_bind_wx
+        SET bind_user_id = #{bindUserId},
+            company_user_id = #{companyUserId},
+            union_id = #{unionId},
+            ma_open_id = #{maOpenId},
+            mp_open_id = #{mpOpenId},
+            external_contact_id = #{externalContactId},
+            user_company_user_id = #{userCompanyUserId},
+            type = #{type}
+        WHERE id = #{id}
+    </update>
+
+    <update id="unbindWxUser" parameterType="Long">
+        UPDATE fs_user_bind_wx
+        SET bind_user_id = NULL,
+            union_id = NULL,
+            ma_open_id = NULL,
+            mp_open_id = NULL,
+            external_contact_id = NULL,
+            user_company_user_id = NULL,
+            type = 1
+        WHERE id = #{id}
+    </update>
+
+    <select id="selectBoundUserInfo" parameterType="Long" resultType="com.fs.his.domain.vo.AssociatedUserVO">
+        SELECT
+            user_id         AS userId,
+            nick_name       AS nickName,
+            avatar          AS avatar
+        FROM fs_user
+        WHERE user_id = #{userId}
+    </select>
+
+    <select id="selectBindWxByUserIdAndCmpanyUserId" resultType="com.fs.his.domain.FsUserBindWx">
+        SELECT id,union_id,ma_open_id,mp_open_id FROM fs_user_bind_wx WHERE fs_user_id = #{userId} AND company_user_id = #{companyUserId} AND bind_user_id IS NOT NULL
+    </select>
+
+    <select id="selectExternalContactList" parameterType="com.fs.his.domain.vo.ExternalContactQuery" resultType="com.fs.his.domain.vo.ExternalContactVO">
+        SELECT
+            ec.id               AS id,
+            ec.name             AS name,
+            ec.avatar           AS avatar,
+            ec.external_user_id AS externalUserId,
+            qc.corp_name        AS corpName
+        FROM qw_external_contact ec
+        INNER JOIN qw_company qc ON ec.corp_id = qc.corp_id
+        WHERE ec.status != 4
+        <if test="companyId != null">
+            AND ec.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null">
+            AND ec.company_user_id = #{companyUserId}
+        </if>
+        <if test="name != null and name != ''">
+            AND ec.name LIKE CONCAT('%', #{name}, '%')
+        </if>
+        <if test="corpName != null and corpName != ''">
+            AND qc.corp_name LIKE CONCAT('%', #{corpName}, '%')
+        </if>
+        ORDER BY ec.create_time DESC
+    </select>
+
+    <select id="selectExternalContactById" parameterType="Long" resultType="com.fs.his.domain.vo.ExternalContactVO">
+        SELECT
+            id              AS id,
+            name            AS name,
+            avatar          AS avatar,
+            external_user_id AS externalUserId
+        FROM qw_external_contact
+        WHERE id = #{id}
+    </select>
+
+    <update id="batchAssignCompanyUser">
+        UPDATE fs_user_bind_wx
+        SET company_user_id = #{companyUserId}
+        WHERE id IN
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <update id="unassignCompanyUser">
+        UPDATE fs_user_bind_wx SET company_user_id = NULL WHERE id = #{id}
+    </update>
+
+</mapper>

+ 30 - 21
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -100,7 +100,7 @@ public class AppLoginController extends AppBaseController{
             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.setAvatar("https://bjyjb-1362704775.cos.ap-chongqing.myqcloud.com/defaultavatar/mr.png");
             user.setPassword(Md5Utils.hash(param.getPassword()));
             user.setCreateTime(new Date());
 
@@ -280,7 +280,8 @@ public class AppLoginController extends AppBaseController{
     public R bindWeChat(@Validated @RequestBody FsUserEditUnionidParam param) {
         try {
             logger.info("zyp app绑定微信,param:{}", param);
-            Map result = WxUtil.getAccessToken(param.getCode(), openProperties.getAppId(), openProperties.getSecret());
+            String appid = openProperties.getAppId();
+            Map result = WxUtil.getAccessToken(param.getCode(), appid, openProperties.getSecret());
             String accessToken = result.get("access_token").toString();
             String unionid = result.get("unionid").toString();
             String openid = result.get("openid").toString();
@@ -288,8 +289,9 @@ public class AppLoginController extends AppBaseController{
             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 user = findUserByPhoneAppId(param.getPhone(),appid);
+            if (user!=null && user.getUnionId() == null){
                 FsUser userByUnionId = userMapper.selectFsUserByUnionid(unionid);
 //                if (userByUnionId!=null){
 //                    return R.error("该微信已绑定其他用户,请勿重复绑定!");
@@ -465,9 +467,13 @@ public class AppLoginController extends AppBaseController{
             return R.error("获取手机号失败");
         }
         // 根据手机号查询用户
+        String appid = openProperties.getAppId();
+        Integer bizType = fsCoursePlaySourceConfigService.resolveBizTypeByAppId(appid);
         FsUser user = null;
         List<FsUser> usersByPhone = findUsersByPhone(param.getPhone());
         if (CollectionUtil.isEmpty(usersByPhone)){
+            param.setAppId(appid);
+            param.setBizType(bizType);
             FsUser newUser = createNewUser(param);
             if (StringUtils.isNotEmpty(newUser.getJpushId())) {
 //                try {
@@ -534,7 +540,7 @@ public class AppLoginController extends AppBaseController{
        if(StringUtils.isNotBlank(param.getAvatar())){
            newUser.setAvatar(param.getAvatar());
        }else {
-           newUser.setAvatar("https://cos.his.cdwjyyh.com/fs/20240926/420728ee06e54575ba82665dedb4756b.png");
+           newUser.setAvatar("https://bjyjb-1362704775.cos.ap-chongqing.myqcloud.com/defaultavatar/mr.png");
        }
         if (StringUtils.isNotEmpty(param.getJpushId())) {
             newUser.setJpushId(param.getJpushId());
@@ -542,6 +548,9 @@ public class AppLoginController extends AppBaseController{
         if(StringUtils.isNotBlank(param.getAppId())){
             newUser.setAppId(param.getAppId());
         }
+        if(param.getBizType() != null){
+            newUser.setBizType(param.getBizType());
+        }
         userService.insertFsUser(newUser);
         return newUser;
     }
@@ -559,6 +568,19 @@ public class AppLoginController extends AppBaseController{
         return user;
     }
 
+    private FsUser findUserByPhoneAppId(String phone,String appid) {
+        // 先根据加密手机号查询用户
+        String jiami = (encryptPhone(phone));
+        FsUser user = userMapper.selectFsUserByPhoneLimitOneAppId(jiami,appid);
+
+        // 如果没有找到用户,再根据手机号查询
+        if (user == null) {
+            user = userMapper.selectFsUserByPhoneLimitOneAppId(phone,appid);
+
+        }
+        return user;
+    }
+
     private FsUser findUserByPhoneAndUnionId(String phone,String unionId) {
         // 先根据加密手机号查询用户
         String jiami = (encryptPhone(phone));
@@ -761,7 +783,7 @@ public class AppLoginController extends AppBaseController{
         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.setAvatar("https://bjyjb-1362704775.cos.ap-chongqing.myqcloud.com/defaultavatar/mr.png");
         user.setPassword(Md5Utils.hash(password));
         user.setCreateTime(new Date());
         user.setAppId(appid);
@@ -804,8 +826,9 @@ public class AppLoginController extends AppBaseController{
             FsUserLoginParam userLoginParam = new FsUserLoginParam();
             userLoginParam.setPhone(phone);
             userLoginParam.setAppId(appid);
+            userLoginParam.setLoginType(1);
+            userLoginParam.setBizType(bizType);
             FsUser newUser = createNewUser(userLoginParam);
-            fillUserBizType(newUser, bizType);
             user.add(newUser);
 //            return R.error("此电话号码未绑定用户");
         }
@@ -909,18 +932,4 @@ public class AppLoginController extends AppBaseController{
         return fsUsers;
     }
 
-    /**
-     * 补全用户业务类型
-     */
-    private void fillUserBizType(FsUser user, Integer bizType) {
-        if (user == null || user.getUserId() == null || bizType == null || user.getBizType() != null) {
-            return;
-        }
-        FsUser update = new FsUser();
-        update.setUserId(user.getUserId());
-        update.setBizType(bizType);
-        userService.updateFsUser(update);
-        user.setBizType(bizType);
-    }
-
 }

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

@@ -225,7 +225,8 @@ public class IntegralController extends  AppBaseController {
     public R shareHealth(@RequestBody FsUserAddIntegralTemplateParam fsUserAddIntegralTemplateParam){
         fsUserAddIntegralTemplateParam.setUserId(Long.parseLong(getUserId()));
         fsUserAddIntegralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_3.getValue());
-        return userIntegralLogsService.addIntegralTemplate(fsUserAddIntegralTemplateParam);
+//        return userIntegralLogsService.addIntegralTemplate(fsUserAddIntegralTemplateParam);
+        return R.ok();
     }
 
     @Login

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

@@ -112,7 +112,7 @@ public class PatientController extends  AppBaseController {
         integralTemplateParam.setUserId(Long.parseLong(getUserId()));
         integralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_11.getValue());
         integralTemplateParam.setBusinessId(patient.getPatientId().toString());
-        userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
+//        userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
         return R.ok("操作成功");
     }
 

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

@@ -108,11 +108,11 @@ public class UserAddressController extends  AppBaseController {
         addressService.insertFsUserAddress(userAddress);
 
         // 填写就诊人信息
-        FsUserAddIntegralTemplateParam integralTemplateParam = new FsUserAddIntegralTemplateParam();
-        integralTemplateParam.setUserId(Long.parseLong(getUserId()));
-        integralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_12.getValue());
-        integralTemplateParam.setBusinessId(userAddress.getAddressId().toString());
-        fsUserIntegralLogsService.addIntegralTemplate(integralTemplateParam);
+//        FsUserAddIntegralTemplateParam integralTemplateParam = new FsUserAddIntegralTemplateParam();
+//        integralTemplateParam.setUserId(Long.parseLong(getUserId()));
+//        integralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_12.getValue());
+//        integralTemplateParam.setBusinessId(userAddress.getAddressId().toString());
+//        fsUserIntegralLogsService.addIntegralTemplate(integralTemplateParam);
 
         fsUserIntegralLogsService.addUserAddressIntegral(Long.parseLong(getUserId()),userAddress.getAddressId());
         return R.ok("操作成功");

+ 11 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/StoreInfoCheckController.java

@@ -1,6 +1,7 @@
 package com.fs.app.controller.store;
 
 import com.fs.common.core.domain.R;
+import com.fs.common.utils.txocr.ContainsResult;
 import com.fs.common.utils.txocr.TxOcrClient;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,7 +22,16 @@ public class StoreInfoCheckController {
      */
     @GetMapping("/businessLicenseCheck")
     public R businessLicenseCheck(String imageUrl){
-        return R.ok().put("data", TxOcrClient.isContains(imageUrl, null));
+        ContainsResult result = TxOcrClient.isContains(imageUrl, null);
+        if(result != null){
+            if(result.getFlag()){
+                return R.ok().put("data", result);
+            }else {
+                return R.error(result.getMessage());
+            }
+        }else {
+            return R.error("操作失败,请稍后重试!");
+        }
     }
 
 

+ 3 - 0
fs-user-app/src/main/java/com/fs/app/param/FsUserLoginParam.java

@@ -32,4 +32,7 @@ public class FsUserLoginParam implements Serializable {
 
     //登录id
     private String appId;
+
+    /** 业务类型:1看课 2商城 */
+    private Integer bizType;
 }