Procházet zdrojové kódy

营销满减活动提交

yjwang před 1 dnem
rodič
revize
7fda5e498a
74 změnil soubory, kde provedl 4259 přidání a 155 odebrání
  1. 96 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStorePromotionController.java
  2. 2 1
      fs-company-app/src/main/java/com/fs/app/utils/JwtUtils.java
  3. 199 0
      fs-company/src/main/java/com/fs/company/controller/his/FsImFriendshipController.java
  4. 238 0
      fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java
  5. 8 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCompanyUserMapper.java
  6. 26 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  7. 74 6
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  8. 27 0
      fs-service/src/main/java/com/fs/his/mapper/FsImFriendshipMapper.java
  9. 1 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  10. 3 0
      fs-service/src/main/java/com/fs/his/param/FsAddUserAndSaleFriendParam.java
  11. 30 0
      fs-service/src/main/java/com/fs/his/param/FsAddUserParam.java
  12. 35 0
      fs-service/src/main/java/com/fs/his/param/FsImFriendshipBindExternalQueryParam.java
  13. 41 0
      fs-service/src/main/java/com/fs/his/param/FsImFriendshipBindListParam.java
  14. 32 0
      fs-service/src/main/java/com/fs/his/param/FsImFriendshipBindUserQueryParam.java
  15. 22 0
      fs-service/src/main/java/com/fs/his/service/IFsImFriendshipBindService.java
  16. 38 0
      fs-service/src/main/java/com/fs/his/service/IFsImFriendshipService.java
  17. 15 0
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  18. 114 0
      fs-service/src/main/java/com/fs/his/service/impl/FsImFriendshipBindServiceImpl.java
  19. 59 59
      fs-service/src/main/java/com/fs/his/service/impl/FsImFriendshipServiceImpl.java
  20. 160 51
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  21. 1 1
      fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java
  22. 37 0
      fs-service/src/main/java/com/fs/his/vo/FsImFriendshipBindExternalVO.java
  23. 47 0
      fs-service/src/main/java/com/fs/his/vo/FsImFriendshipBindListVO.java
  24. 20 0
      fs-service/src/main/java/com/fs/his/vo/FsImFriendshipBindUserVO.java
  25. 9 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreOrderScrm.java
  26. 84 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionActivity.java
  27. 27 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionScope.java
  28. 33 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionTier.java
  29. 40 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionUsage.java
  30. 41 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStorePromotionActivityDTO.java
  31. 18 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStorePromotionTierDTO.java
  32. 18 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStorePromotionUsageCountDTO.java
  33. 7 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductCategoryScrmMapper.java
  34. 31 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionActivityMapper.java
  35. 19 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionScopeMapper.java
  36. 23 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionTierMapper.java
  37. 24 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionUsageMapper.java
  38. 25 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStorePromotionComputeParam.java
  39. 19 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStorePromotionListMultiStoreParam.java
  40. 21 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStorePromotionListParam.java
  41. 5 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductCategoryScrmService.java
  42. 49 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStorePromotionComputeService.java
  43. 28 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStorePromotionService.java
  44. 52 2
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  45. 8 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductCategoryScrmServiceImpl.java
  46. 634 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStorePromotionComputeServiceImpl.java
  47. 451 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStorePromotionServiceImpl.java
  48. 243 0
      fs-service/src/main/java/com/fs/hisStore/support/FsStorePromotionTierCalculator.java
  49. 49 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionActivityItemVO.java
  50. 42 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionComputeResultVO.java
  51. 23 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionDetailVO.java
  52. 23 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionListVO.java
  53. 20 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionScopeCategoryVO.java
  54. 17 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionTierItemVO.java
  55. 17 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionTierMatchVO.java
  56. 10 0
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  57. 234 23
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  58. 1 1
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  59. 2 0
      fs-service/src/main/java/com/fs/store/param/h5/FsUserPageListParam.java
  60. 47 0
      fs-service/src/main/java/com/fs/store/param/h5/TagIdsStringArrayDeserializer.java
  61. 2 2
      fs-service/src/main/resources/application-config-druid-yjb.yml
  62. 9 2
      fs-service/src/main/resources/application-druid-yjb.yml
  63. 30 0
      fs-service/src/main/resources/db/fs_store_promotion_tier_type.sql
  64. 3 0
      fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml
  65. 143 0
      fs-service/src/main/resources/mapper/his/FsImFriendshipMapper.xml
  66. 8 0
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  67. 10 1
      fs-service/src/main/resources/mapper/hisStore/FsStoreOrderScrmMapper.xml
  68. 165 0
      fs-service/src/main/resources/mapper/hisStore/FsStorePromotionActivityMapper.xml
  69. 43 0
      fs-service/src/main/resources/mapper/hisStore/FsStorePromotionScopeMapper.xml
  70. 50 0
      fs-service/src/main/resources/mapper/hisStore/FsStorePromotionTierMapper.xml
  71. 41 0
      fs-service/src/main/resources/mapper/hisStore/FsStorePromotionUsageMapper.xml
  72. 6 5
      fs-user-app/src/main/java/com/fs/app/controller/store/StoreOrderScrmController.java
  73. 28 0
      fs-user-app/src/main/java/com/fs/app/service/StoreOrderScrmAmountService.java
  74. 2 1
      fs-user-app/src/main/java/com/fs/app/utils/JwtUtils.java

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

@@ -0,0 +1,96 @@
+package com.fs.hisStore.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import com.fs.hisStore.dto.FsStorePromotionActivityDTO;
+import com.fs.hisStore.service.IFsStorePromotionService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 阶梯满减活动 Controller
+ */
+@RestController
+@RequestMapping("/store/storePromotion")
+public class FsStorePromotionController extends BaseController {
+
+    @Autowired
+    private IFsStorePromotionService promotionService;
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStorePromotionActivity query) {
+        startPage();
+        List<FsStorePromotionActivity> list = promotionService.selectFsStorePromotionList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:export')")
+    @Log(title = "阶梯满减", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsStorePromotionActivity query) {
+        List<FsStorePromotionActivity> list = promotionService.selectFsStorePromotionList(query);
+        ExcelUtil<FsStorePromotionActivity> util = new ExcelUtil<>(FsStorePromotionActivity.class);
+        return util.exportExcel(list, "storePromotion");
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(promotionService.selectFsStorePromotionById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:add')")
+    @Log(title = "阶梯满减", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStorePromotionActivityDTO dto) {
+        return toAjax(promotionService.insertFsStorePromotion(dto));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:edit')")
+    @Log(title = "阶梯满减", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStorePromotionActivityDTO dto) {
+        return toAjax(promotionService.updateFsStorePromotion(dto));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:remove')")
+    @Log(title = "阶梯满减", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(promotionService.deleteFsStorePromotionByIds(ids));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:enable')")
+    @Log(title = "阶梯满减", businessType = BusinessType.UPDATE)
+    @PutMapping("/{id}/enable")
+    public AjaxResult enable(@PathVariable Long id) {
+        promotionService.enableActivity(id);
+        com.fs.hisStore.vo.FsStorePromotionDetailVO detail = promotionService.selectFsStorePromotionById(id);
+        Map<String, Object> data = new HashMap<>(1);
+        if (detail != null) {
+            data.put("newDisplayStatus", detail.getDisplayStatus());
+        }
+        return AjaxResult.success(data);
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storePromotion:disable')")
+    @Log(title = "阶梯满减", businessType = BusinessType.UPDATE)
+    @PutMapping("/{id}/disable")
+    public AjaxResult disable(@PathVariable Long id) {
+        promotionService.disableActivity(id);
+        Map<String, Object> data = new HashMap<>(1);
+        data.put("newDisplayStatus", 4);
+        return AjaxResult.success(data);
+    }
+}

+ 2 - 1
fs-company-app/src/main/java/com/fs/app/utils/JwtUtils.java

@@ -21,7 +21,8 @@ public class JwtUtils {
 
 
     private String secret;
-    private long expire;
+    /** 默认7天(秒),防止配置未加载时 token 立即过期 */
+    private long expire = 604800L;
     private String header;
 
     /**

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

@@ -0,0 +1,199 @@
+package com.fs.company.controller.his;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.his.domain.FsImFriendship;
+import com.fs.his.param.FsAddUserParam;
+import com.fs.his.param.FsImFriendshipBindExternalQueryParam;
+import com.fs.his.param.FsImFriendshipBindListParam;
+import com.fs.his.param.FsImFriendshipBindUserQueryParam;
+import com.fs.his.vo.FsImFriendshipBindExternalVO;
+import com.fs.his.vo.FsImFriendshipBindListVO;
+import com.fs.his.vo.FsImFriendshipBindUserVO;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.his.service.IFsImFriendshipService;
+import com.fs.his.service.IFsUserService;
+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 java.util.List;
+
+/**
+ * im用户好友绑定关系Controller
+ * 
+ * @author fs
+ * @date 2026-01-27
+ */
+@RestController
+@RequestMapping("/his/friendship")
+public class FsImFriendshipController extends BaseController
+{
+    @Autowired
+    private IFsUserService userService;
+
+    @Autowired
+    private IFsImFriendshipService fsImFriendshipService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    /**
+     * 设置 IM 绑定数据范围:管理员看本公司,子账号看自己
+     */
+    private void applyBindDataScope(Long[] companyIdHolder, Long[] companyUserIdHolder) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser.getCompany() != null) {
+            companyIdHolder[0] = loginUser.getCompany().getCompanyId();
+        }
+        if (!loginUser.getUser().isAdmin()) {
+            companyUserIdHolder[0] = loginUser.getUser().getUserId();
+        }
+    }
+
+    /**
+     * 查询im用户好友绑定关系列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:friendship:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsImFriendship fsImFriendship)
+    {
+        startPage();
+        List<FsImFriendship> list = fsImFriendshipService.selectFsImFriendshipList(fsImFriendship);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询 IM 好友绑定关系(联表 fs_user、company、company_user)
+     * 销售管理员:查看当前销售公司全部绑定;子账号:仅查看本人绑定
+     */
+    @ApiOperation("查询IM好友绑定关系")
+    @GetMapping("/bindList")
+    public TableDataInfo bindList(FsImFriendshipBindListParam param)
+    {
+        Long[] companyIdHolder = new Long[1];
+        Long[] companyUserIdHolder = new Long[1];
+        applyBindDataScope(companyIdHolder, companyUserIdHolder);
+        param.setCurrentCompanyId(companyIdHolder[0]);
+        param.setCurrentCompanyUserId(companyUserIdHolder[0]);
+        if (param.getStatus() == null) {
+            param.setStatus(1);
+        }
+        startPage();
+        List<FsImFriendshipBindListVO> list = fsImFriendshipService.selectFsImFriendshipBindList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询可绑定的用户列表
+     * 手动发课(addType=1):关联 fs_user_company_user,管理员查本公司,子账号查本人关联
+     * 自动发课(addType=2):关联 qw_external_contact.fs_user_id,数据范围规则与手动一致,仅返回 fs_user 字段
+     */
+    @ApiOperation("查询可绑定用户列表")
+    @GetMapping("/bindUserList")
+    public TableDataInfo bindUserList(FsImFriendshipBindUserQueryParam param)
+    {
+        startPage();
+        List<FsImFriendshipBindUserVO> list = fsImFriendshipService.selectBindUserList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询 IM 绑定可选销售列表:管理员看本公司全部销售,子账号仅看自己
+     */
+    @ApiOperation("查询IM绑定可选销售列表")
+    @GetMapping("/bindCompanyUserList")
+    public R bindCompanyUserList()
+    {
+        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);
+    }
+
+    /**
+     * 查询可绑定的外部联系人列表(自动发课添加好友,关联 fs_user_company_user)
+     */
+    @ApiOperation("查询可绑定外部联系人列表")
+    @GetMapping("/bindExternalContactList")
+    public TableDataInfo bindExternalContactList(FsImFriendshipBindExternalQueryParam param)
+    {
+        Long[] companyIdHolder = new Long[1];
+        Long[] companyUserIdHolder = new Long[1];
+        applyBindDataScope(companyIdHolder, companyUserIdHolder);
+        param.setCurrentCompanyId(companyIdHolder[0]);
+        param.setCurrentCompanyUserId(companyUserIdHolder[0]);
+        startPage();
+        List<FsImFriendshipBindExternalVO> list = fsImFriendshipService.selectBindExternalContactList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取im用户好友绑定关系详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:friendship:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") String id)
+    {
+        return AjaxResult.success(fsImFriendshipService.selectFsImFriendshipById(id));
+    }
+
+    /**
+     * 删除im用户好友绑定关系
+     */
+    @PreAuthorize("@ss.hasPermi('his:friendship:remove')")
+    @Log(title = "im用户好友绑定关系", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String[] ids)
+    {
+        return toAjax(fsImFriendshipService.deleteFsImFriendshipByIds(ids));
+    }
+
+    /**
+     * 添加用户和销售IM好友(同步执行,IM失败直接返回)
+     * @param param 请求参数
+     * @return R
+     **/
+    @ApiOperation("添加用户和销售IM好友")
+    @Log(title = "IM绑定好友", businessType = BusinessType.INSERT)
+    @PostMapping("/addUserAndSaleFriend")
+    public R addUserAndSaleFriend(@RequestBody FsAddUserParam param){
+        if(param.getUserIds() == null || param.getUserIds().isEmpty()){
+            return R.error("操作失败,请传入用户关键信息!");
+        }else if(param.getAddType() == null || (param.getAddType() != 1 && param.getAddType() != 2)){
+            return R.error("操作失败,加好友类型有误!");
+        }else if(param.getAddType() == 2  && param.getExternalUserId() == null){
+            return R.error("操作失败,请传入外部用户关键信息!");
+        }else if(param.getAddType() == 2 && param.getUserIds().size() > 1){
+            return R.error("操作失败,自动发课添加好友只能选择一个用户!");
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (!loginUser.getUser().isAdmin()) {
+            param.setCompanyUserId(loginUser.getUser().getUserId());
+        } else if (param.getCompanyUserId() == null) {
+            param.setCompanyUserId(loginUser.getUser().getUserId());
+        } else {
+            CompanyUser selected = companyUserService.selectCompanyUserById(param.getCompanyUserId());
+            if (selected == null || !loginUser.getCompany().getCompanyId().equals(selected.getCompanyId())) {
+                return R.error("所选销售不存在或不属于当前公司");
+            }
+        }
+        return fsImFriendshipService.addUserAndSaleFriend(param);
+    }
+}

+ 238 - 0
fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java

@@ -30,9 +30,13 @@ import com.fs.course.mapper.FsCourseRedPacketLogMapper;
 import com.fs.course.param.newfs.FsUserCourseAddCompanyUserParam;
 import com.fs.course.service.*;
 import com.fs.course.vo.FsUserCourseVideoQVO;
+import com.fs.course.mapper.FsUserCompanyUserMapper;
 import com.fs.his.domain.FsUser;
+import com.fs.his.param.FsAddUserParam;
+import com.fs.his.service.IFsImFriendshipService;
 import com.fs.his.service.IFsInquiryOrderService;
 import com.fs.his.service.IFsUserWxService;
+import com.fs.his.vo.OptionsVO;
 import com.fs.his.utils.qrcode.QRCodeUtils;
 import com.fs.hisStore.config.MedicalMallConfig;
 import com.fs.hisStore.domain.FsStoreOrderScrm;
@@ -69,6 +73,13 @@ import java.util.stream.Collectors;
 @Slf4j
 public class CommonController {
 
+    private static final int IM_BIND_BATCH_SIZE = 100;
+    private static final int IM_BIND_BATCH_SIZE_BY_DEPT = 1000;
+    /** 手动发课添加好友 */
+    private static final int IM_ADD_TYPE_MANUAL = 1;
+    /** 仅处理该时间之后绑定的会员 */
+    private static final LocalDateTime IM_BIND_TIME_AFTER = LocalDateTime.of(2026, 3, 1, 0, 0, 0);
+
     @Autowired
     private SopLogsTaskService service;
     @Autowired
@@ -182,6 +193,233 @@ public class CommonController {
     @Autowired
     CompanyUserMapper companyUserMapper;
 
+    @Autowired
+    private FsUserCompanyUserMapper fsUserCompanyUserMapper;
+
+    @Autowired
+    private IFsImFriendshipService fsImFriendshipService;
+
+    /**
+     * 按全部销售批量补绑 IM 好友(手动类型,每批100;单批/单销售失败不影响后续执行)
+     */
+    @ApiOperation("按销售批量补绑IM好友")
+    @GetMapping("/batchBindImFriendByCompanyUser")
+    public R batchBindImFriendByCompanyUser(
+            @RequestParam(value = "companyUserId", required = false) Long companyUserId) {
+        List<Long> companyUserIds = companyUserId != null
+                ? Collections.singletonList(companyUserId)
+                : fsUserCompanyUserMapper.getBindCompanyId();
+        if (companyUserIds == null || companyUserIds.isEmpty()) {
+            return R.ok("未查询到销售信息").put("companyUserCount", 0);
+        }
+
+        int companyUserCount = 0;
+        int totalUserCount = 0;
+        int totalBatchCount = 0;
+        int totalSuccessBatchCount = 0;
+        int totalFailBatchCount = 0;
+        List<String> failMessages = new ArrayList<>();
+
+        for (Long salesId : companyUserIds) {
+            if (salesId == null) {
+                continue;
+            }
+            companyUserCount++;
+            try {
+                ImBindSummary summary = bindImFriendForCompanyUser(salesId, IM_BIND_BATCH_SIZE);
+                totalUserCount += summary.getUserCount();
+                totalBatchCount += summary.getBatchCount();
+                totalSuccessBatchCount += summary.getSuccessBatchCount();
+                totalFailBatchCount += summary.getFailBatchCount();
+                failMessages.addAll(summary.getFailMessages());
+            } catch (Exception e) {
+                totalFailBatchCount++;
+                failMessages.add(String.format("销售ID=%s处理异常:%s", salesId, e.getMessage()));
+                log.error("IM好友批量绑定销售处理异常,销售ID={}", salesId, e);
+            }
+        }
+
+        return R.ok("处理完成")
+                .put("companyUserCount", companyUserCount)
+                .put("totalUserCount", totalUserCount)
+                .put("batchCount", totalBatchCount)
+                .put("successBatchCount", totalSuccessBatchCount)
+                .put("failBatchCount", totalFailBatchCount)
+                .put("failMessages", failMessages);
+    }
+
+    /**
+     * 按部门批量补绑 IM 好友:查 company 表 dept_id(可能多条)-> 各公司下销售,每批1000;IM 成功后再写 fs_im_friendship
+     */
+    @ApiOperation("按部门批量补绑IM好友")
+    @GetMapping("/batchBindImFriendByDept")
+    public R batchBindImFriendByDept(@RequestParam("deptId") Long deptId) {
+        if (deptId == null) {
+            return R.error("部门ID不能为空");
+        }
+        List<OptionsVO> companies = companyService.selectAllCompanyList(deptId);
+        if (companies == null || companies.isEmpty()) {
+            return R.ok("未查询到部门对应的公司信息")
+                    .put("deptId", deptId)
+                    .put("companyCount", 0);
+        }
+
+        int companyCount = 0;
+        int companyUserCount = 0;
+        int totalUserCount = 0;
+        int totalBatchCount = 0;
+        int totalSuccessBatchCount = 0;
+        int totalFailBatchCount = 0;
+        List<Long> companyIds = new ArrayList<>();
+        List<String> failMessages = new ArrayList<>();
+
+        for (OptionsVO company : companies) {
+            if (company == null || company.getDictValue() == null) {
+                continue;
+            }
+            Long companyId = company.getDictValue();
+            companyCount++;
+            companyIds.add(companyId);
+
+            List<CompanyUser> companyUsers = companyUserMapper.selectAllCompanyUserByCompanyIdAndDeptId(companyId, null);
+            if (companyUsers == null || companyUsers.isEmpty()) {
+                continue;
+            }
+
+            for (CompanyUser companyUser : companyUsers) {
+                if (companyUser == null || companyUser.getUserId() == null) {
+                    continue;
+                }
+                companyUserCount++;
+                try {
+                    ImBindSummary summary = bindImFriendForCompanyUser(companyUser.getUserId(), IM_BIND_BATCH_SIZE_BY_DEPT);
+                    totalUserCount += summary.getUserCount();
+                    totalBatchCount += summary.getBatchCount();
+                    totalSuccessBatchCount += summary.getSuccessBatchCount();
+                    totalFailBatchCount += summary.getFailBatchCount();
+                    failMessages.addAll(summary.getFailMessages());
+                } catch (Exception e) {
+                    totalFailBatchCount++;
+                    failMessages.add(String.format("公司ID=%s,销售ID=%s处理异常:%s",
+                            companyId, companyUser.getUserId(), e.getMessage()));
+                    log.error("按部门IM好友批量绑定异常,deptId={},companyId={},销售ID={}",
+                            deptId, companyId, companyUser.getUserId(), e);
+                }
+            }
+        }
+
+        if (companyUserCount == 0) {
+            return R.ok("未查询到公司下销售信息")
+                    .put("deptId", deptId)
+                    .put("companyCount", companyCount)
+                    .put("companyIds", companyIds)
+                    .put("companyUserCount", 0);
+        }
+
+        return R.ok("处理完成")
+                .put("deptId", deptId)
+                .put("companyCount", companyCount)
+                .put("companyIds", companyIds)
+                .put("companyUserCount", companyUserCount)
+                .put("totalUserCount", totalUserCount)
+                .put("batchCount", totalBatchCount)
+                .put("successBatchCount", totalSuccessBatchCount)
+                .put("failBatchCount", totalFailBatchCount)
+                .put("failMessages", failMessages);
+    }
+
+    /**
+     * 单个销售:查询绑定会员并分批补绑 IM 好友
+     */
+    private ImBindSummary bindImFriendForCompanyUser(Long companyUserId, int batchSize) {
+        ImBindSummary summary = new ImBindSummary();
+        List<Long> userIds = fsUserCompanyUserMapper.selectDistinctUserIdsByCompanyUserId(
+                companyUserId, IM_BIND_TIME_AFTER);
+        if (userIds == null || userIds.isEmpty()) {
+            return summary;
+        }
+
+        summary.setUserCount(userIds.size());
+        int batchCount = 0;
+        for (int i = 0; i < userIds.size(); i += batchSize) {
+            batchCount++;
+            int endIndex = Math.min(i + batchSize, userIds.size());
+            List<Long> batchUserIds = new ArrayList<>(userIds.subList(i, endIndex));
+
+            FsAddUserParam param = new FsAddUserParam();
+            param.setCompanyUserId(companyUserId);
+            param.setUserIds(batchUserIds);
+            param.setAddType(IM_ADD_TYPE_MANUAL);
+
+            try {
+                R result = fsImFriendshipService.addUserAndSaleFriend(param);
+                if (isSuccess(result)) {
+                    summary.successBatchCount++;
+                    log.info("IM好友批量绑定成功,销售ID={},批次={},用户数={}",
+                            companyUserId, batchCount, batchUserIds.size());
+                } else {
+                    summary.failBatchCount++;
+                    String msg = result == null ? "返回为空" : String.valueOf(result.get("msg"));
+                    summary.failMessages.add(String.format("销售ID=%s,第%d批失败:%s",
+                            companyUserId, batchCount, msg));
+                    log.warn("IM好友批量绑定失败,销售ID={},批次={},原因={}",
+                            companyUserId, batchCount, msg);
+                }
+            } catch (Exception e) {
+                summary.failBatchCount++;
+                summary.failMessages.add(String.format("销售ID=%s,第%d批异常:%s",
+                        companyUserId, batchCount, e.getMessage()));
+                log.error("IM好友批量绑定异常,销售ID={},批次={}", companyUserId, batchCount, e);
+            }
+        }
+        summary.setBatchCount(batchCount);
+        return summary;
+    }
+
+    private static class ImBindSummary {
+        private int userCount;
+        private int batchCount;
+        private int successBatchCount;
+        private int failBatchCount;
+        private final List<String> failMessages = new ArrayList<>();
+
+        int getUserCount() {
+            return userCount;
+        }
+
+        void setUserCount(int userCount) {
+            this.userCount = userCount;
+        }
+
+        int getBatchCount() {
+            return batchCount;
+        }
+
+        void setBatchCount(int batchCount) {
+            this.batchCount = batchCount;
+        }
+
+        int getSuccessBatchCount() {
+            return successBatchCount;
+        }
+
+        int getFailBatchCount() {
+            return failBatchCount;
+        }
+
+        List<String> getFailMessages() {
+            return failMessages;
+        }
+    }
+
+    private boolean isSuccess(R result) {
+        if (result == null) {
+            return false;
+        }
+        Object code = result.get("code");
+        return code instanceof Number && ((Number) code).intValue() == 200;
+    }
+
     /**
      * 获取跳转微信小程序的链接地址
      */

+ 8 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCompanyUserMapper.java

@@ -8,6 +8,7 @@ import com.fs.qw.dto.UserProjectDTO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 
@@ -66,6 +67,10 @@ public interface FsUserCompanyUserMapper extends BaseMapper<FsUserCompanyUser>{
      */
     int deleteFsUserCompanyUserByIds(Long[] ids);
 
+    @Select("SELECT DISTINCT user_id FROM fs_user_company_user WHERE company_user_id = #{companyUserId} AND user_id IS NOT NULL AND create_time > #{bindTimeAfter} ORDER BY user_id")
+    List<Long> selectDistinctUserIdsByCompanyUserId(@Param("companyUserId") Long companyUserId,
+                                                    @Param("bindTimeAfter") LocalDateTime bindTimeAfter);
+
     /**
      * 获取当前销售的所有重粉会员
      * @param companyUserId
@@ -102,4 +107,7 @@ public interface FsUserCompanyUserMapper extends BaseMapper<FsUserCompanyUser>{
 
     //获取手动今日新增会员
     List<SystemUserStatisticsVo> selectManualTodayUser();
+
+    @Select("SELECT company_user_id FROM fs_user_company_user WHERE company_user_id IS NOT NULL GROUP BY company_user_id")
+    List<Long> getBindCompanyId();
 }

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

@@ -398,6 +398,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 //判断是否完课
                 long percentage = (duration * 100 / videoDuration);
                 if (percentage >= config.getAnswerRate()) {
+                    auditWxWatchFinish(userId, videoId, companyUserId, duration, videoDuration, percentage, config.getAnswerRate());
                     watchLog.setLogType(2); // 设置状态为“已完成”checkFsUserWatchStatus
                     watchLog.setFinishTime(new Date());
                     String heartbeatKey ="h5wxuser:watch:heartbeat:" + userId+ ":" + videoId + ":" + companyUserId;
@@ -412,6 +413,31 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         }
         batchUpdateFsUserCourseWatchLog(logs,100);
     }
+
+    /** 完课审计:墙钟与上报时长允许误差(秒) */
+    private static final long WATCH_FINISH_WALL_CLOCK_BUFFER_SECONDS = 120L;
+
+    /**
+     * 会员H5完课审计(仅异常时打日志,不参与业务判断)
+     */
+    private void auditWxWatchFinish(Long userId, Long videoId, Long companyUserId, Long duration,
+                                    Long videoDuration, long percentage, Integer answerRate) {
+        String uvKey = userId + ":" + videoId;
+        long elapsedSeconds = -1L;
+        try {
+            FsCourseWatchLog dbLog = fsCourseWatchLogMapper.getWatchCourseVideoByFsUser(userId, videoId, companyUserId);
+            if (dbLog != null && dbLog.getCreateTime() != null) {
+                elapsedSeconds = (System.currentTimeMillis() - dbLog.getCreateTime().getTime()) / 1000;
+            }
+        } catch (Exception ignored) {
+        }
+        if (elapsedSeconds < 0 || duration <= elapsedSeconds + WATCH_FINISH_WALL_CLOCK_BUFFER_SECONDS) {
+            return;
+        }
+        log.warn("[WXH5-WATCH-FINISH] uvKey={} request={} reason=WALL_CLOCK_MISMATCH elapsedSec={} videoDuration={} percent={}",
+                uvKey, duration, elapsedSeconds, videoDuration, percentage);
+    }
+
     public Long getFsUserVideoDuration(Long videoId){
         //将视频时长也存到redis
         String videoRedisKey = "h5wxuser:video:duration:" + videoId;

+ 74 - 6
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -2575,18 +2575,20 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
             log.error("视频时长识别错误:{}", param);
             return R.error();
         }
-        // 判断param.getDuration()是否大于videoDuration(允许10的误差区间)
-        if (param.getDuration() > videoDuration + 10) {
-            return R.error();
-        }
         // 从Redis中获取观看时长
         String redisKey = "h5wxuser:watch:duration:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
-//        log.info("看课redis缓存key:{}", redisKey);
         try {
             String durationStr = redisCache.getCacheObject(redisKey);
-//            log.info("看课记录:{}", durationStr);
             long duration = durationStr != null ? Long.parseLong(durationStr) : 0L;
 
+            // 审计日志:仅记录,不改变业务逻辑
+            auditWxWatchDurationReport(param, duration, videoDuration);
+
+            // 判断param.getDuration()是否大于videoDuration(允许10的误差区间)
+            if (param.getDuration() > videoDuration + 10) {
+                return R.error();
+            }
+
             // 更新Redis中的观看时长
             if (param.getDuration() != null && param.getDuration() > duration) {
                 //24小时过期
@@ -2862,6 +2864,72 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
         }
 
     }
+    /** 看课时长审计:单次增量超过该值告警 */
+    private static final long WATCH_AUDIT_LARGE_JUMP_SECONDS = 300L;
+    /** 看课时长审计:相对心跳间隔校验,delta > gap * factor + base */
+    private static final long WATCH_AUDIT_JUMP_MIN_DELTA_SECONDS = 60L;
+    private static final double WATCH_AUDIT_JUMP_GAP_FACTOR = 1.5;
+    private static final long WATCH_AUDIT_JUMP_GAP_BASE_SECONDS = 30L;
+    /** 看课时长审计:墙钟与上报时长允许误差(秒) */
+    private static final long WATCH_AUDIT_WALL_CLOCK_BUFFER_SECONDS = 120L;
+    private static final int WATCH_AUDIT_HIGH_PERCENT = 95;
+
+    /**
+     * 会员H5看课时长上报审计(仅异常时打日志,不参与业务判断)
+     */
+    private void auditWxWatchDurationReport(FsUserCourseVideoUParam param, long redisDuration, long videoDuration) {
+        String uvKey = param.getUserId() + ":" + param.getVideoId();
+        long requestDuration = param.getDuration() != null ? param.getDuration() : 0L;
+        long delta = requestDuration - redisDuration;
+        if (delta <= 0 && requestDuration <= videoDuration + 10) {
+            return;
+        }
+
+        long gapSeconds = -1L;
+        String heartbeatKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+        try {
+            String lastHeartbeatStr = redisCache.getCacheObject(heartbeatKey);
+            if (lastHeartbeatStr != null) {
+                LocalDateTime lastHeartbeat = LocalDateTime.parse(lastHeartbeatStr);
+                gapSeconds = Duration.between(lastHeartbeat, LocalDateTime.now()).getSeconds();
+            }
+        } catch (Exception ignored) {
+        }
+
+        long elapsedSeconds = -1L;
+        try {
+            FsCourseWatchLog watchLog = courseWatchLogMapper.getWatchCourseVideoByFsUser(
+                    param.getUserId(), param.getVideoId(), param.getCompanyUserId());
+            if (watchLog != null && watchLog.getCreateTime() != null) {
+                elapsedSeconds = (System.currentTimeMillis() - watchLog.getCreateTime().getTime()) / 1000;
+            }
+        } catch (Exception ignored) {
+        }
+
+        long percent = videoDuration > 0 ? requestDuration * 100 / videoDuration : 0;
+        List<String> reasons = new ArrayList<>();
+        if (requestDuration > videoDuration + 10) {
+            reasons.add("EXCEED_VIDEO");
+        }
+        if (delta > WATCH_AUDIT_JUMP_MIN_DELTA_SECONDS && gapSeconds >= 0
+                && delta > gapSeconds * WATCH_AUDIT_JUMP_GAP_FACTOR + WATCH_AUDIT_JUMP_GAP_BASE_SECONDS) {
+            reasons.add("JUMP_TOO_FAST");
+        }
+        if (delta > WATCH_AUDIT_LARGE_JUMP_SECONDS) {
+            reasons.add("LARGE_JUMP");
+        }
+        if (elapsedSeconds >= 0 && percent >= WATCH_AUDIT_HIGH_PERCENT
+                && requestDuration > elapsedSeconds + WATCH_AUDIT_WALL_CLOCK_BUFFER_SECONDS) {
+            reasons.add("WALL_CLOCK_MISMATCH");
+        }
+
+        if (reasons.isEmpty()) {
+            return;
+        }
+        log.warn("[WXH5-WATCH-DURATION] uvKey={} request={} reason={} redisBefore={} delta={} gapSec={} elapsedSec={}",
+                uvKey, requestDuration, String.join(",", reasons), redisDuration, delta, gapSeconds, elapsedSeconds);
+    }
+
     //会员-更新心跳时间
     public void updateHeartbeatWx(FsUserCourseVideoUParam param) {
         String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();

+ 27 - 0
fs-service/src/main/java/com/fs/his/mapper/FsImFriendshipMapper.java

@@ -3,6 +3,13 @@ package com.fs.his.mapper;
 import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.his.domain.FsImFriendship;
+import com.fs.his.param.FsImFriendshipBindListParam;
+import com.fs.his.param.FsImFriendshipBindExternalQueryParam;
+import com.fs.his.param.FsImFriendshipBindUserQueryParam;
+import com.fs.his.vo.FsImFriendshipBindExternalVO;
+import com.fs.his.vo.FsImFriendshipBindListVO;
+import com.fs.his.vo.FsImFriendshipBindUserVO;
+import org.apache.ibatis.annotations.Param;
 
 /**
  * im用户好友绑定关系Mapper接口
@@ -27,6 +34,21 @@ public interface FsImFriendshipMapper extends BaseMapper<FsImFriendship>{
      */
     List<FsImFriendship> selectFsImFriendshipList(FsImFriendship fsImFriendship);
 
+    /**
+     * 联表查询 IM 好友绑定关系列表
+     */
+    List<FsImFriendshipBindListVO> selectFsImFriendshipBindList(FsImFriendshipBindListParam param);
+
+    /**
+     * 查询可绑定的用户列表(手动发课,关联 fs_user_company_user)
+     */
+    List<FsImFriendshipBindUserVO> selectBindUserList(FsImFriendshipBindUserQueryParam param);
+
+    /**
+     * 查询可绑定的外部联系人列表
+     */
+    List<FsImFriendshipBindExternalVO> selectBindExternalContactList(FsImFriendshipBindExternalQueryParam param);
+
     /**
      * 新增im用户好友绑定关系
      * 
@@ -35,6 +57,11 @@ public interface FsImFriendshipMapper extends BaseMapper<FsImFriendship>{
      */
     int insertFsImFriendship(FsImFriendship fsImFriendship);
 
+    /**
+     * 批量新增im用户好友绑定关系
+     */
+    int batchInsertFsImFriendship(@Param("list") List<FsImFriendship> list);
+
     /**
      * 修改im用户好友绑定关系
      * 

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

@@ -459,4 +459,5 @@ public interface FsUserMapper
     @Select("select * from fs_user where phone=#{phone} AND app_id LIKE CONCAT('%',#{appid},'%')")
     List<FsUser> selectFsUsersByPhoneLimitOne(@Param("phone") String phone, @Param("appid") String appid);
 
+    List<FsUser> selectFsUserListByUserIds(@Param("userIds") List<Long> userIds);
 }

+ 3 - 0
fs-service/src/main/java/com/fs/his/param/FsAddUserAndSaleFriendParam.java

@@ -11,4 +11,7 @@ public class FsAddUserAndSaleFriendParam implements Serializable {
 
     //外部联系人ID
     private Long externalUserId;
+
+    //用户ID
+    private Long userId;
 }

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

@@ -0,0 +1,30 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class FsAddUserParam implements Serializable {
+    /**
+     * 批量添加用户ID
+     * **/
+    private List<Long> userIds;
+
+    /**
+     * 加入类型
+     * 1: 手动发课添加好友、2:自动发课添加好友
+     * **/
+    private Integer addType;
+
+    /**
+     * 外部联系人ID
+     * **/
+    private Long externalUserId;
+
+    /**
+     * 销售ID
+     * **/
+    private Long companyUserId;
+}

+ 35 - 0
fs-service/src/main/java/com/fs/his/param/FsImFriendshipBindExternalQueryParam.java

@@ -0,0 +1,35 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * IM好友绑定-外部联系人查询参数
+ */
+@Data
+public class FsImFriendshipBindExternalQueryParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 外部联系人主键ID(模糊) */
+    private String id;
+
+    /** 外部联系人昵称(模糊) */
+    private String name;
+
+    /** 销售公司名称(模糊) */
+    private String companyName;
+
+    /** 所属销售名称(模糊) */
+    private String companyUserNickName;
+
+    /** 企业微信名称(模糊) */
+    private String corpName;
+
+    /** 当前销售公司ID(后端赋值) */
+    private Long currentCompanyId;
+
+    /** 当前登录销售ID(子账号数据范围,后端赋值) */
+    private Long currentCompanyUserId;
+}

+ 41 - 0
fs-service/src/main/java/com/fs/his/param/FsImFriendshipBindListParam.java

@@ -0,0 +1,41 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * IM好友绑定关系联表查询参数
+ */
+@Data
+public class FsImFriendshipBindListParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 用户ID(模糊) */
+    private String userId;
+
+    /** 用户昵称(模糊) */
+    private String userNickName;
+
+    /** 销售公司ID(模糊) */
+    private String companyId;
+
+    /** 销售公司名称(模糊) */
+    private String companyName;
+
+    /** 销售ID(模糊,仅展示;当前登录销售由后端强制过滤) */
+    private String companyUserId;
+
+    /** 销售名称(模糊) */
+    private String companyUserNickName;
+
+    /** 绑定状态,默认查已绑定 */
+    private Integer status;
+
+    /** 当前销售公司ID(管理员数据范围,后端赋值) */
+    private Long currentCompanyId;
+
+    /** 当前登录销售ID(子账号数据范围,后端赋值) */
+    private Long currentCompanyUserId;
+}

+ 32 - 0
fs-service/src/main/java/com/fs/his/param/FsImFriendshipBindUserQueryParam.java

@@ -0,0 +1,32 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * IM好友绑定-用户查询参数
+ */
+@Data
+public class FsImFriendshipBindUserQueryParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 用户ID(模糊/精确字符串) */
+    private String userId;
+
+    /** 用户ID精确匹配(后端解析赋值) */
+    private Long userIdExact;
+
+    /** 用户昵称(模糊) */
+    private String userNickName;
+
+    /** 添加类型:1手动发课 2自动发课添加好友 */
+    private Integer addType;
+
+    /** 当前销售公司ID(后端赋值) */
+    private Long currentCompanyId;
+
+    /** 当前登录销售ID(子账号数据范围,后端赋值) */
+    private Long currentCompanyUserId;
+}

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

@@ -0,0 +1,22 @@
+package com.fs.his.service;
+
+import java.util.List;
+
+/**
+ * IM 好友绑定关系写入中间服务(仅负责落库,避免与 OpenIM 形成循环依赖)
+ */
+public interface IFsImFriendshipBindService {
+
+    /**
+     * 写入单条绑定关系
+     */
+    void addBindInfo(String ownerUserID, String friendUserID);
+
+    /**
+     * 批量写入销售与用户 IM 绑定关系
+     *
+     * @param imFriendUserIDs 用户 IM ID 列表(U 前缀)
+     * @param imCompanyUserID 销售 IM ID(C 前缀)
+     */
+    void batchAddBindInfo(List<String> imFriendUserIDs, String imCompanyUserID);
+}

+ 38 - 0
fs-service/src/main/java/com/fs/his/service/IFsImFriendshipService.java

@@ -2,7 +2,16 @@ package com.fs.his.service;
 
 import java.util.List;
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
 import com.fs.his.domain.FsImFriendship;
+import com.fs.his.domain.FsUser;
+import com.fs.his.param.FsAddUserParam;
+import com.fs.his.param.FsImFriendshipBindExternalQueryParam;
+import com.fs.his.param.FsImFriendshipBindListParam;
+import com.fs.his.param.FsImFriendshipBindUserQueryParam;
+import com.fs.his.vo.FsImFriendshipBindExternalVO;
+import com.fs.his.vo.FsImFriendshipBindListVO;
+import com.fs.his.vo.FsImFriendshipBindUserVO;
 
 /**
  * im用户好友绑定关系Service接口
@@ -27,6 +36,21 @@ public interface IFsImFriendshipService extends IService<FsImFriendship>{
      */
     List<FsImFriendship> selectFsImFriendshipList(FsImFriendship fsImFriendship);
 
+    /**
+     * 联表查询 IM 好友绑定关系列表
+     */
+    List<FsImFriendshipBindListVO> selectFsImFriendshipBindList(FsImFriendshipBindListParam param);
+
+    /**
+     * 查询可绑定的用户列表
+     */
+    List<FsImFriendshipBindUserVO> selectBindUserList(FsImFriendshipBindUserQueryParam param);
+
+    /**
+     * 查询可绑定的外部联系人列表
+     */
+    List<FsImFriendshipBindExternalVO> selectBindExternalContactList(FsImFriendshipBindExternalQueryParam param);
+
     /**
      * 新增im用户好友绑定关系
      * 
@@ -66,4 +90,18 @@ public interface IFsImFriendshipService extends IService<FsImFriendship>{
      * **/
     void addBindInfo(String ownerUserID, String friendUserID);
 
+    /**
+     * 批量写入销售与用户 IM 绑定关系(与 addBindInfo 逻辑一致)
+     * @param imFriendUserIDs 用户 IM ID 列表(U 前缀)
+     * @param imCompanyUserID 销售 IM ID(C 前缀)
+     */
+    void batchAddBindInfo(List<String> imFriendUserIDs, String imCompanyUserID);
+
+    /**
+     * 添加用户和销售IM好友
+     * @param param 传入信息
+     * @return R
+     * **/
+    R addUserAndSaleFriend(FsAddUserParam param);
+
 }

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

@@ -16,6 +16,7 @@ import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserAddress;
 import com.fs.his.dto.FindUsersByDTO;
 import com.fs.his.param.FindUserByParam;
+import com.fs.his.param.FsAddUserParam;
 import com.fs.his.param.FsUserParam;
 import com.fs.his.vo.FsUserVO;
 import com.fs.his.vo.FsUserExportListVO;
@@ -252,4 +253,18 @@ public interface IFsUserService
 
     R updatePasswordByPhone(String password, String encryptPhone);
 
+    /**
+     * 批量获取用户信息
+     * @param userIds 用户ID集合
+     * **/
+    List<FsUser> selectFsUserListByUserIds(List<Long> userIds);
+
+    /**
+     * 批量添加用户和销售IM好友
+     * @param userList 用户信息集合
+     * @param compayUserId 销售ID
+     * @return R
+     * **/
+    R batchAddUserAndSaleFriend(List<FsUser> userList,Long compayUserId);
+
 }

+ 114 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsImFriendshipBindServiceImpl.java

@@ -0,0 +1,114 @@
+package com.fs.his.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.his.domain.FsImFriendship;
+import com.fs.his.mapper.FsImFriendshipMapper;
+import com.fs.his.service.IFsImFriendshipBindService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * IM 好友绑定关系写入中间服务实现
+ */
+@Slf4j
+@Service
+public class FsImFriendshipBindServiceImpl implements IFsImFriendshipBindService {
+
+    private static final String USER_ID_PREFIX = "U";
+    private static final String COMPANY_ID_PREFIX = "C";
+
+    @Autowired
+    private FsImFriendshipMapper fsImFriendshipMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void addBindInfo(String ownerUserID, String friendUserID) {
+        batchAddBindInfo(Collections.singletonList(ownerUserID), friendUserID);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void batchAddBindInfo(List<String> imFriendUserIDs, String imCompanyUserID) {
+        if (CollectionUtils.isEmpty(imFriendUserIDs) || imCompanyUserID == null) {
+            return;
+        }
+        List<Long> userIdsToBind = new ArrayList<>();
+        Long companyUserId = null;
+        for (String imFriendUserID : imFriendUserIDs) {
+            if (imFriendUserID == null) {
+                continue;
+            }
+            Long userId = splitId(imFriendUserID, imCompanyUserID, 1);
+            Long currentCompanyUserId = splitId(imFriendUserID, imCompanyUserID, 2);
+            if (userId != null && currentCompanyUserId != null) {
+                companyUserId = currentCompanyUserId;
+                userIdsToBind.add(userId);
+            }
+        }
+        if (companyUserId == null || userIdsToBind.isEmpty()) {
+            return;
+        }
+        List<Long> distinctUserIds = userIdsToBind.stream().distinct().collect(Collectors.toList());
+        List<FsImFriendship> activeList = fsImFriendshipMapper.selectList(new LambdaQueryWrapper<FsImFriendship>()
+                .select(FsImFriendship::getId, FsImFriendship::getUserId, FsImFriendship::getStatus)
+                .eq(FsImFriendship::getCompanyUserId, companyUserId)
+                .eq(FsImFriendship::getStatus, 1)
+                .in(FsImFriendship::getUserId, distinctUserIds));
+        Set<Long> boundUserIds = activeList.stream()
+                .map(FsImFriendship::getUserId)
+                .collect(Collectors.toCollection(HashSet::new));
+        List<FsImFriendship> toInsert = new ArrayList<>();
+        Date now = new Date();
+        for (Long userId : distinctUserIds) {
+            if (boundUserIds.contains(userId)) {
+                continue;
+            }
+            log.info("添加绑定关系----销售《-》用户:{}", companyUserId + "--" + userId);
+            FsImFriendship imFriendship = new FsImFriendship();
+            imFriendship.setUserId(userId);
+            imFriendship.setCompanyUserId(companyUserId);
+            imFriendship.setStatus(1);
+            imFriendship.setCreateTime(now);
+            toInsert.add(imFriendship);
+            boundUserIds.add(userId);
+        }
+        if (!toInsert.isEmpty()) {
+            fsImFriendshipMapper.batchInsertFsImFriendship(toInsert);
+        }
+    }
+
+    private Long splitId(String ownerUserID, String friendUserID, Integer type) {
+        if (type == null || (type != 1 && type != 2)) {
+            throw new IllegalArgumentException("type参数必须为1或2");
+        }
+        String targetPrefix = (type == 1) ? USER_ID_PREFIX : COMPANY_ID_PREFIX;
+        if (ownerUserID != null && ownerUserID.startsWith(targetPrefix)) {
+            return extractNumberFromId(ownerUserID, targetPrefix);
+        }
+        if (friendUserID != null && friendUserID.startsWith(targetPrefix)) {
+            return extractNumberFromId(friendUserID, targetPrefix);
+        }
+        return null;
+    }
+
+    private Long extractNumberFromId(String idWithPrefix, String prefix) {
+        try {
+            return Long.parseLong(idWithPrefix.substring(prefix.length()));
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("ID格式错误: " + idWithPrefix + " 的数字部分无效", e);
+        } catch (StringIndexOutOfBoundsException e) {
+            throw new IllegalArgumentException("ID格式错误: " + idWithPrefix + " 长度不足", e);
+        }
+    }
+}

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

@@ -1,18 +1,27 @@
 package com.fs.his.service.impl;
 
-import java.util.Date;
 import java.util.List;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.common.core.domain.R;
 import com.fs.common.utils.DateUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.his.domain.FsUser;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.param.FsAddUserParam;
+import com.fs.his.param.FsImFriendshipBindExternalQueryParam;
+import com.fs.his.param.FsImFriendshipBindListParam;
+import com.fs.his.param.FsImFriendshipBindUserQueryParam;
+import com.fs.his.vo.FsImFriendshipBindExternalVO;
+import com.fs.his.vo.FsImFriendshipBindListVO;
+import com.fs.his.vo.FsImFriendshipBindUserVO;
+import com.fs.his.service.IFsImFriendshipBindService;
+import com.fs.his.service.IFsUserService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.fs.his.mapper.FsImFriendshipMapper;
 import com.fs.his.domain.FsImFriendship;
 import com.fs.his.service.IFsImFriendshipService;
-import org.springframework.transaction.annotation.Transactional;
 
 /**
  * im用户好友绑定关系Service业务层处理
@@ -23,8 +32,11 @@ import org.springframework.transaction.annotation.Transactional;
 @Service
 @Slf4j
 public class FsImFriendshipServiceImpl extends ServiceImpl<FsImFriendshipMapper, FsImFriendship> implements IFsImFriendshipService {
-    private static final String USER_ID_PREFIX = "U";
-    private static final String COMPANY_ID_PREFIX = "C";
+    @Autowired
+    private IFsUserService userService;
+
+    @Autowired
+    private IFsImFriendshipBindService fsImFriendshipBindService;
 
 
     /**
@@ -49,6 +61,32 @@ public class FsImFriendshipServiceImpl extends ServiceImpl<FsImFriendshipMapper,
         return baseMapper.selectFsImFriendshipList(fsImFriendship);
     }
 
+    @Override
+    public List<FsImFriendshipBindListVO> selectFsImFriendshipBindList(FsImFriendshipBindListParam param) {
+        return baseMapper.selectFsImFriendshipBindList(param);
+    }
+
+    @Override
+    public List<FsImFriendshipBindUserVO> selectBindUserList(FsImFriendshipBindUserQueryParam param) {
+        resolveBindUserIdExact(param);
+        return baseMapper.selectBindUserList(param);
+    }
+
+    private void resolveBindUserIdExact(FsImFriendshipBindUserQueryParam param) {
+        if (StringUtils.isNotEmpty(param.getUserId()) && param.getUserId().matches("\\d+")) {
+            try {
+                param.setUserIdExact(Long.parseLong(param.getUserId()));
+            } catch (NumberFormatException ignored) {
+                param.setUserIdExact(null);
+            }
+        }
+    }
+
+    @Override
+    public List<FsImFriendshipBindExternalVO> selectBindExternalContactList(FsImFriendshipBindExternalQueryParam param) {
+        return baseMapper.selectBindExternalContactList(param);
+    }
+
     /**
      * 新增im用户好友绑定关系
      *
@@ -96,65 +134,27 @@ public class FsImFriendshipServiceImpl extends ServiceImpl<FsImFriendshipMapper,
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void addBindInfo(String ownerUserID, String friendUserID) {
-        Long userId = splitId(ownerUserID, friendUserID, 1);
-        Long companyUserId = splitId(ownerUserID, friendUserID, 2);
-        //只记录销售和用户关系
-        if(userId != null && companyUserId != null){
-            log.info("添加绑定关系----销售《-》用户:{}", companyUserId + "--" + userId);
-            FsImFriendship imFriendship = baseMapper.selectOne(new LambdaQueryWrapper<FsImFriendship>()
-                    .select(FsImFriendship::getId, FsImFriendship::getStatus)
-                    .eq(FsImFriendship::getCompanyUserId, companyUserId)
-                    .eq(FsImFriendship::getUserId, userId).eq(FsImFriendship::getStatus, 1));
-            if (imFriendship == null) {
-                //插入数据
-                imFriendship = new FsImFriendship();
-                imFriendship.setUserId(userId);
-                imFriendship.setCompanyUserId(companyUserId);
-                imFriendship.setStatus(1);
-                imFriendship.setCreateTime(new Date());
-                baseMapper.insert(imFriendship);
-            } else if (imFriendship != null && imFriendship.getStatus() != null && imFriendship.getStatus() == 0) {
-                //更新数据
-                imFriendship.setStatus(1);
-                baseMapper.updateById(imFriendship);
-            }
-        }
+        fsImFriendshipBindService.addBindInfo(ownerUserID, friendUserID);
     }
 
-    /**
-     * 从用户ID字符串中提取数字ID
-     *
-     * @param ownerUserID  拥有者用户ID
-     * @param friendUserID 好友用户ID
-     * @param type         1:用户ID(U开头) 2:销售ID(C开头)
-     * @return 提取的数字ID,如果未找到返回null
-     */
-    public Long splitId(String ownerUserID, String friendUserID, Integer type) {
-        if (type == null || (type != 1 && type != 2)) {
-            throw new IllegalArgumentException("type参数必须为1或2");
-        }
-        String targetPrefix = (type == 1) ? USER_ID_PREFIX : COMPANY_ID_PREFIX;
-        if (ownerUserID != null && ownerUserID.startsWith(targetPrefix)) {
-            return extractNumberFromId(ownerUserID, targetPrefix);
-        }
-        if (friendUserID != null && friendUserID.startsWith(targetPrefix)) {
-            return extractNumberFromId(friendUserID, targetPrefix);
-        }
-        return null;
+    @Override
+    public void batchAddBindInfo(List<String> imFriendUserIDs, String imCompanyUserID) {
+        fsImFriendshipBindService.batchAddBindInfo(imFriendUserIDs, imCompanyUserID);
     }
 
-    /**
-     * 从ID字符串中提取数字部分
-     */
-    private Long extractNumberFromId(String idWithPrefix, String prefix) {
-        try {
-            return Long.parseLong(idWithPrefix.substring(prefix.length()));
-        } catch (NumberFormatException e) {
-            throw new IllegalArgumentException("ID格式错误: " + idWithPrefix + " 的数字部分无效", e);
-        } catch (StringIndexOutOfBoundsException e) {
-            throw new IllegalArgumentException("ID格式错误: " + idWithPrefix + " 长度不足", e);
+    @Override
+    public R addUserAndSaleFriend(FsAddUserParam param) {
+        List<FsUser> userList = userService.selectFsUserListByUserIds(param.getUserIds());
+//        if (userList.isEmpty() || userList.size() != param.getUserIds().size()) {
+//            return R.error("用户信息有误,请稍后重试!");
+//        }
+        if (userList.isEmpty()) {
+            return R.error("用户信息有误,请稍后重试!");
+        }
+        if (param.getAddType() == 1) {
+            return userService.batchAddUserAndSaleFriend(userList, param.getCompanyUserId());
         }
+        return userService.addUserAndSaleFriend(userList.get(0), param.getCompanyUserId(), param.getExternalUserId());
     }
 }

+ 160 - 51
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -54,6 +54,7 @@ import com.fs.his.dto.FindUsersByDTO;
 import com.fs.his.enums.FsUserIntegralLogTypeEnum;
 import com.fs.his.mapper.*;
 import com.fs.his.param.FindUserByParam;
+import com.fs.his.param.FsAddUserParam;
 import com.fs.his.param.FsUserAddIntegralTemplateParam;
 import com.fs.his.param.FsUserParam;
 import com.fs.his.service.*;
@@ -178,6 +179,9 @@ public class FsUserServiceImpl implements IFsUserService {
 
     @Autowired
     private OpenIMService openIMService;
+
+    @Autowired
+    private FsImFriendshipMapper fsImFriendshipMapper;
     @Autowired
     private IFsUserBillScrmService billService;
     @Autowired
@@ -189,8 +193,8 @@ public class FsUserServiceImpl implements IFsUserService {
     @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
 
-    @Autowired
-    private IFsImFriendshipService fsImFriendshipService;
+    private static final String USER_ID_PREFIX = "U";
+    private static final String COMPANY_ID_PREFIX = "C";
 
     /**
      * 查询用户
@@ -581,7 +585,7 @@ public class FsUserServiceImpl implements IFsUserService {
         if (companyUser != null && companyUser.isAdmin() && param.getCompanyUserId() == null) {
 //            param.setUserId(0L);
 //            param.setCompanyId(companyUser.getCompanyId());
-            throw new RuntimeException("管理员,请选择销售!");
+            return null;
         }
 
         //筛选问题
@@ -1413,8 +1417,8 @@ public class FsUserServiceImpl implements IFsUserService {
     public R addUserAndSaleFriend(FsUser user, Long compayUserId, Long externalUserId) {
         //获取IM的管理员Token
         String adminToken = openIMService.getAdminToken();
-        String userId = "U" + user.getUserId().toString();
-        String compayUserIdValue = "C" + compayUserId.toString();
+        String userId = USER_ID_PREFIX + user.getUserId().toString();
+        String compayUserIdValue = COMPANY_ID_PREFIX + compayUserId.toString();
 
         //验证销售是否存在
         CompanyUser companyUser = companyUserMapper.selectCompanyUserById(compayUserId);
@@ -1425,56 +1429,28 @@ public class FsUserServiceImpl implements IFsUserService {
         QwExternalContact externalContact = qwExternalContactMapper.selectQwExternalContactById(externalUserId);
         if(externalContact ==  null){
             return R.error("操作失败,外部联系人不存在!");
+        }else if(externalContact.getFsUserId() != null && !externalContact.getFsUserId().equals(user.getUserId())){
+            return R.error("操作失败,外部联系人绑定信息与im绑定用户信息不一致!");
         }
         if (StringUtils.isNotEmpty(adminToken)) {
-            //判断是否注册过
-            ArrayList<String> userIds = new ArrayList<>();
-            userIds.add(userId);
-            JSONObject requestBody = new JSONObject().put("checkUserIDs", userIds);
-            String body = HttpRequest.post(IMConfig.URL+"/user/account_check")
-                    .header("operationID", String.valueOf(System.currentTimeMillis()))
-                    .header("token", adminToken)
-                    .body(requestBody.toString())
-                    .execute()
-                    .body();
-            JSONObject jsonObject = new JSONObject(body);
-            JSONArray results = jsonObject.getJSONObject("data").getJSONArray("results");
-            if (results != null && results.length() > 0) {
-                JSONObject resultObj = results.getJSONObject(0);
-                int accountStatus = resultObj.getInt("accountStatus");
-                //未注册进行用户注册
-                if (accountStatus==0){
-                    ArrayList<Object> users = new ArrayList<>();
-                    HashMap<String, String> map = new HashMap<>();
-                    map.put("userID",userId);
-                    map.put("nickname",user.getNickName());
-                    map.put("faceURL",user.getAvatar());
-                    users.add(map);
-                    requestBody = new JSONObject();
-                    requestBody.put("users", users);
-                    String registerBody = HttpRequest.post(IMConfig.URL+"/user/user_register")
-                            .header("operationID", String.valueOf(System.currentTimeMillis()))
-                            .header("token", adminToken).body(requestBody.toString()).execute().body();
-                    OpenImResponseDTO registerDTO = JSON.parseObject(registerBody,OpenImResponseDTO.class);
-                    if(registerDTO.getErrCode() != 0){
-                        logger.error("用户注册失败------》:{}",compayUserId+"--"+JSON.toJSONString(registerDTO));
-                        return R.error("用户注册IM失败,请联系管理员!");
-                    }
-                }
+            try {
+                ensureImAccountRegistered(compayUserIdValue, "2");
+                ensureImAccountRegistered(userId, "1");
+            } catch (ServiceException e) {
+                logger.error("IM账号注册/检测失败,销售ID={},用户ID={}", compayUserId, user.getUserId(), e);
+                return R.error(e.getMessage());
+            }
 
-                //添加用户与销售的关系链路
-                List<String> friendUserIDs = Arrays.asList(userId);
-                OpenImResponseDTO openImResponseDTO = openIMService.importFriend(compayUserIdValue, friendUserIDs);
-                if(openImResponseDTO != null && openImResponseDTO.getErrCode() != 0){
-                    logger.error("系链路绑定失败------》:{}",compayUserId+"--"+JSON.toJSONString(openImResponseDTO));
-                    return R.error("操作失败,销售与好友关系链路绑定失败!");
-                }
+            List<String> friendUserIDs = Collections.singletonList(userId);
+            OpenImResponseDTO openImResponseDTO = openIMService.importFriend(compayUserIdValue, friendUserIDs);
+            if(openImResponseDTO != null && openImResponseDTO.getErrCode() != 0){
+                logger.error("系链路绑定失败------》:{}",compayUserId+"--"+JSON.toJSONString(openImResponseDTO));
+                return R.error("操作失败,销售与好友关系链路绑定失败!");
+            }
 
-                if(externalContact.getFsUserId() == null){
-                    //绑定外部联系人
-                    externalContact.setFsUserId(user.getUserId());
-                    qwExternalContactMapper.updateQwExternalContact(externalContact);
-                }
+            if(externalContact.getFsUserId() == null){
+                externalContact.setFsUserId(user.getUserId());
+                qwExternalContactMapper.updateQwExternalContact(externalContact);
             }
         }else {
             logger.error("获取IMTOKEN失败------》:{}",compayUserId);
@@ -1493,4 +1469,137 @@ public class FsUserServiceImpl implements IFsUserService {
         fsUserMapper.updatePasswordByPhone(password, encryptPhone);
         return R.ok();
     }
+
+    @Override
+    public List<FsUser> selectFsUserListByUserIds(List<Long> userIds) {
+        return fsUserMapper.selectFsUserListByUserIds(userIds);
+    }
+
+    @Override
+    public R batchAddUserAndSaleFriend(List<FsUser> userList, Long compayUserId) {
+        if (StringUtils.isEmpty(openIMService.getAdminToken())) {
+            logger.error("获取IMTOKEN失败------》:{}", compayUserId);
+            return R.error("获取IMTOKEN失败,请联系管理员!");
+        }
+        String companyImUserId = COMPANY_ID_PREFIX + compayUserId;
+
+        List<FsUser> validUsers = userList.stream()
+                .filter(user -> user != null && user.getUserId() != null)
+                .collect(Collectors.toList());
+        if (validUsers.isEmpty()) {
+            return R.error("没有可绑定的IM用户");
+        }
+
+        Set<Long> boundUserIds = queryBoundUserIds(compayUserId, validUsers);
+        List<FsUser> usersToBind = validUsers.stream()
+                .filter(user -> !boundUserIds.contains(user.getUserId()))
+                .collect(Collectors.toList());
+        if (usersToBind.isEmpty()) {
+            return R.ok("全部已绑定");
+        }
+
+        List<String> memberImUserIds = usersToBind.stream()
+                .map(user -> USER_ID_PREFIX + user.getUserId())
+                .collect(Collectors.toList());
+
+        try {
+            openIMService.batchEnsureAccountsRegistered(Collections.singletonList(companyImUserId), "2");
+        } catch (ServiceException e) {
+            logger.error("销售IM账号注册/检测失败,销售ID={}", compayUserId, e);
+            return R.error("销售IM账号注册失败:" + e.getMessage());
+        }
+
+        List<String> registeredImUserIds;
+        try {
+            registeredImUserIds = openIMService.batchEnsureAccountsRegistered(memberImUserIds, "1");
+        } catch (ServiceException e) {
+            logger.error("批量IM账号注册/检测失败,销售ID={}", compayUserId, e);
+            return R.error("IM账号注册失败:" + e.getMessage());
+        }
+
+        List<String> imUserIds = memberImUserIds.stream()
+                .filter(registeredImUserIds::contains)
+                .collect(Collectors.toList());
+        if (imUserIds.isEmpty()) {
+            return R.error("没有可绑定的IM用户");
+        }
+
+        OpenImResponseDTO openImResponseDTO = openIMService.importFriend(companyImUserId, imUserIds, true);
+        if (openImResponseDTO != null && openImResponseDTO.getErrCode() != 0) {
+            logger.error("系链路绑定失败------》:{}", compayUserId + "--" + JSON.toJSONString(openImResponseDTO));
+            return R.error("操作失败,销售与好友关系链路绑定失败!");
+        }
+        if (imUserIds.size() < usersToBind.size()) {
+            return R.ok("部分用户绑定成功,部分用户IM注册失败已跳过");
+        }
+        return R.ok("操作成功!");
+    }
+
+    private Set<Long> queryBoundUserIds(Long companyUserId, List<FsUser> userList) {
+        List<Long> userIds = userList.stream().map(FsUser::getUserId).collect(Collectors.toList());
+        if (userIds.isEmpty()) {
+            return Collections.emptySet();
+        }
+        List<FsImFriendship> activeList = fsImFriendshipMapper.selectList(Wrappers.<FsImFriendship>lambdaQuery()
+                .select(FsImFriendship::getUserId)
+                .eq(FsImFriendship::getCompanyUserId, companyUserId)
+                .eq(FsImFriendship::getStatus, 1)
+                .in(FsImFriendship::getUserId, userIds));
+        return activeList.stream()
+                .map(FsImFriendship::getUserId)
+                .collect(Collectors.toSet());
+    }
+
+    /**
+     * 检测 IM 账号是否已注册,未注册则自动注册(与 OpenIMServiceImpl.accountCheck 逻辑一致)
+     */
+    private void ensureImAccountRegistered(String imUserId, String type) {
+        R result = openIMService.accountCheck(imUserId, type);
+        if (result == null || result.get("code") == null
+                || ((Number) result.get("code")).intValue() != 200) {
+            throw new ServiceException("IM账号注册失败:" + imUserId);
+        }
+    }
+
+    private String checkUser(List<String > userIds, String adminToken){
+        JSONObject requestBody = new JSONObject().put("checkUserIDs", userIds);
+        return HttpRequest.post(IMConfig.URL+"/user/account_check")
+                .header("operationID", String.valueOf(System.currentTimeMillis()))
+                .header("token", adminToken)
+                .body(requestBody.toString())
+                .execute()
+                .body();
+    }
+
+    private void userRegister(ArrayList<Object> users, String adminToken){
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("users", users);
+        String registerBody = HttpRequest.post(IMConfig.URL+"/user/user_register")
+                .header("operationID", String.valueOf(System.currentTimeMillis()))
+                .header("token", adminToken).body(requestBody.toString()).execute().body();
+        OpenImResponseDTO registerDTO = JSON.parseObject(registerBody,OpenImResponseDTO.class);
+        if(registerDTO.getErrCode() != 0){
+            logger.error("用户注册失败------》:{}",JSON.toJSONString(registerDTO));
+            throw new ServiceException("用户注册IM失败:" + registerDTO.getErrDlt());
+        }
+    }
+
+    /**
+     * OpenIM 注册昵称不能为空,与 OpenIMServiceImpl 保持一致兜底
+     */
+    private String resolveImNickname(FsUser user) {
+        if (user == null) {
+            return "微信用户";
+        }
+        if (StringUtils.isNotEmpty(user.getNickName())) {
+            return user.getNickName();
+        }
+        if (StringUtils.isNotEmpty(user.getNickname())) {
+            return user.getNickname();
+        }
+        if (user.getUserId() != null) {
+            return "用户" + user.getUserId();
+        }
+        return "微信用户";
+    }
 }

+ 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("18996202854"));
+        System.out.println(encryptPhone("13121679182"));
     }
 }

+ 37 - 0
fs-service/src/main/java/com/fs/his/vo/FsImFriendshipBindExternalVO.java

@@ -0,0 +1,37 @@
+package com.fs.his.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * IM好友绑定-外部联系人选择 VO
+ */
+@Data
+public class FsImFriendshipBindExternalVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private String name;
+
+    private String avatar;
+
+    private String externalUserId;
+
+    /** 销售公司ID */
+    private Long companyId;
+
+    /** 销售公司名称 */
+    private String companyName;
+
+    /** 所属销售ID */
+    private Long companyUserId;
+
+    /** 所属销售名称 */
+    private String companyUserNickName;
+
+    /** 企业微信名称 */
+    private String corpName;
+}

+ 47 - 0
fs-service/src/main/java/com/fs/his/vo/FsImFriendshipBindListVO.java

@@ -0,0 +1,47 @@
+package com.fs.his.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * IM好友绑定关系联表列表 VO
+ */
+@Data
+public class FsImFriendshipBindListVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 绑定关系ID */
+    private String id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 用户昵称 */
+    private String userNickName;
+
+    /** 用户头像 */
+    private String userAvatar;
+
+    /** 销售公司ID */
+    private Long companyId;
+
+    /** 销售公司名称 */
+    private String companyName;
+
+    /** 销售ID */
+    private Long companyUserId;
+
+    /** 销售名称 */
+    private String companyUserNickName;
+
+    /** 绑定状态 */
+    private Integer status;
+
+    /** 绑定时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

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

@@ -0,0 +1,20 @@
+package com.fs.his.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * IM好友绑定-用户选择 VO
+ */
+@Data
+public class FsImFriendshipBindUserVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+
+    private String nickName;
+
+    private String avatar;
+}

+ 9 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreOrderScrm.java

@@ -89,6 +89,15 @@ public class FsStoreOrderScrm extends BaseEntity
     /** 优惠券金额 */
     private BigDecimal couponPrice;
 
+    /** 阶梯满减活动ID */
+    private Long promotionActivityId;
+
+    /** 命中档位ID */
+    private Long promotionTierId;
+
+    /** 满减优惠金额 */
+    private BigDecimal promotionDiscountAmount;
+
     /** 支付状态 */
     @Excel(name = "支付状态 1已支付 0未支付 2支付中")
     private Integer paid;

+ 84 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionActivity.java

@@ -0,0 +1,84 @@
+package com.fs.hisStore.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * 店铺阶梯满减活动主表 fs_store_promotion_activity
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class FsStorePromotionActivity extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    @Excel(name = "活动名称")
+    private String title;
+
+    private Long storeId;
+
+    @TableField(exist = false)
+    private String storeName;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("start_time")
+    private Date startTime;
+
+    /** 活动结束时间(避免与 BaseEntity.endTime 查询字段冲突) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("end_time")
+    @JsonProperty("endTime")
+    private Date activityEndTime;
+
+    /** 1全场 2指定分类 3指定商品 */
+    private Integer scopeType;
+
+    /** 阶梯类型:1金额 2折扣 */
+    private Integer tierType;
+
+    private Integer isStackable;
+
+    private Integer isCapped;
+
+    private Integer limitPerUser;
+
+    /** 0草稿 1启用 3已结束 */
+    private Integer status;
+
+    /** NULL未操作 1启用 0关闭 */
+    private Integer manualStatus;
+
+    private Integer version;
+
+    private Integer isDel;
+
+    @TableField("remark")
+    @JsonProperty("remark")
+    private String activityRemark;
+
+    /** 列表展示态 0草稿 1未开始 2进行中 3已结束 4已关闭 */
+    @TableField(exist = false)
+    private Integer displayStatus;
+
+    @TableField(exist = false)
+    private String displayStatusLabel;
+
+    @TableField(exist = false)
+    private Integer tierCount;
+
+    @TableField(exist = false)
+    private String scopeTypeLabel;
+
+    /** 阶梯类型文案 */
+    @TableField(exist = false)
+    private String tierTypeLabel;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionScope.java

@@ -0,0 +1,27 @@
+package com.fs.hisStore.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 活动适用范围 fs_store_promotion_scope
+ */
+@Data
+public class FsStorePromotionScope implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long activityId;
+
+    private Integer scopeType;
+
+    private Long targetId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionTier.java

@@ -0,0 +1,33 @@
+package com.fs.hisStore.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 阶梯满减档位 fs_store_promotion_tier
+ */
+@Data
+public class FsStorePromotionTier implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long activityId;
+
+    private Integer sortOrder;
+
+    private BigDecimal thresholdAmount;
+
+    private BigDecimal discountAmount;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 40 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStorePromotionUsage.java

@@ -0,0 +1,40 @@
+package com.fs.hisStore.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 用户活动参与记录 fs_store_promotion_usage
+ */
+@Data
+public class FsStorePromotionUsage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long activityId;
+
+    private Long userId;
+
+    private Long orderId;
+
+    private BigDecimal orderAmount;
+
+    private BigDecimal discountAmount;
+
+    private Long tierId;
+
+    /** 0待支付 1已生效 2已回滚 */
+    private Integer usageStatus;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date usageTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 41 - 0
fs-service/src/main/java/com/fs/hisStore/dto/FsStorePromotionActivityDTO.java

@@ -0,0 +1,41 @@
+package com.fs.hisStore.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class FsStorePromotionActivityDTO implements Serializable {
+
+    private Long id;
+
+    private String title;
+
+    private Long storeId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date endTime;
+
+    private Integer scopeType;
+
+    /** 阶梯类型:1金额 2折扣 */
+    private Integer tierType;
+
+    private Integer isStackable;
+
+    private Integer isCapped;
+
+    private Integer limitPerUser;
+
+    private String remark;
+
+    private List<Long> scopeIds;
+
+    private List<FsStorePromotionTierDTO> tiers;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/hisStore/dto/FsStorePromotionTierDTO.java

@@ -0,0 +1,18 @@
+package com.fs.hisStore.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+@Data
+public class FsStorePromotionTierDTO implements Serializable {
+
+    private Long id;
+
+    private Integer sortOrder;
+
+    private BigDecimal thresholdAmount;
+
+    private BigDecimal discountAmount;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/hisStore/dto/FsStorePromotionUsageCountDTO.java

@@ -0,0 +1,18 @@
+package com.fs.hisStore.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 用户活动已生效参与次数(批量统计)
+ */
+@Data
+public class FsStorePromotionUsageCountDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long activityId;
+
+    private Integer usedCount;
+}

+ 7 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductCategoryScrmMapper.java

@@ -115,4 +115,11 @@ public interface FsStoreProductCategoryScrmMapper
     @Select("select cate_id dict_value, cate_name dict_label,is_del status from fs_store_product_category_scrm WHERE pid = 0 and is_del=0 ")
     List<OptionsVO> selectFsStoreProductPidList();
 
+    @Select({"<script>",
+            "SELECT * FROM fs_store_product_category_scrm",
+            "WHERE is_del = 0 AND cate_id IN",
+            "<foreach collection='cateIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>",
+            "</script>"})
+    List<FsStoreProductCategoryScrm> selectByCateIds(@Param("cateIds") List<Long> cateIds);
+
 }

+ 31 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionActivityMapper.java

@@ -0,0 +1,31 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface FsStorePromotionActivityMapper {
+
+    FsStorePromotionActivity selectFsStorePromotionActivityById(Long id);
+
+    List<FsStorePromotionActivity> selectFsStorePromotionActivityList(FsStorePromotionActivity query);
+
+    int insertFsStorePromotionActivity(FsStorePromotionActivity activity);
+
+    int updateFsStorePromotionActivity(FsStorePromotionActivity activity);
+
+    int deleteFsStorePromotionActivityByIds(Long[] ids);
+
+    int updateManualStatus(@Param("id") Long id, @Param("manualStatus") Integer manualStatus);
+
+    int expireActivities();
+
+    int countConflictActivity(@Param("storeId") Long storeId,
+                              @Param("startTime") Date startTime,
+                              @Param("endTime") Date endTime,
+                              @Param("excludeId") Long excludeId);
+
+    List<FsStorePromotionActivity> selectActivePromotionsByStoreId(@Param("storeId") Long storeId);
+}

+ 19 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionScopeMapper.java

@@ -0,0 +1,19 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStorePromotionScope;
+
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface FsStorePromotionScopeMapper {
+
+    List<FsStorePromotionScope> selectByActivityId(Long activityId);
+
+    List<FsStorePromotionScope> selectByActivityIds(@Param("activityIds") List<Long> activityIds);
+
+    int insertFsStorePromotionScope(FsStorePromotionScope scope);
+
+    int batchInsertFsStorePromotionScope(@Param("list") List<FsStorePromotionScope> list);
+
+    int deleteByActivityId(Long activityId);
+}

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionTierMapper.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStorePromotionTier;
+
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface FsStorePromotionTierMapper {
+
+    List<FsStorePromotionTier> selectByActivityId(Long activityId);
+
+    int insertFsStorePromotionTier(FsStorePromotionTier tier);
+
+    int batchInsertFsStorePromotionTier(@Param("list") List<FsStorePromotionTier> list);
+
+    int deleteByActivityId(Long activityId);
+
+    /**
+     * 批量获取活动的层级信息
+     * @param activityIds 活动集合
+     * **/
+    List<FsStorePromotionTier> selectByActivityIds(@Param("activityIds") List<Long> activityIds);
+}

+ 24 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePromotionUsageMapper.java

@@ -0,0 +1,24 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStorePromotionUsage;
+import com.fs.hisStore.dto.FsStorePromotionUsageCountDTO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 用户活动参与记录 Mapper
+ */
+public interface FsStorePromotionUsageMapper {
+
+    int countEffectiveByActivityAndUser(@Param("activityId") Long activityId, @Param("userId") Long userId);
+
+    List<FsStorePromotionUsageCountDTO> countEffectiveByActivityIdsAndUser(@Param("activityIds") List<Long> activityIds,
+                                                                          @Param("userId") Long userId);
+
+    int insertFsStorePromotionUsage(FsStorePromotionUsage usage);
+
+    int updateUsageStatusByOrderId(@Param("orderId") Long orderId,
+                                   @Param("usageStatus") Integer usageStatus,
+                                   @Param("fromStatus") Integer fromStatus);
+}

+ 25 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStorePromotionComputeParam.java

@@ -0,0 +1,25 @@
+package com.fs.hisStore.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+@Data
+public class FsStorePromotionComputeParam implements Serializable {
+
+    @NotBlank(message = "orderKey不能为空")
+    private String orderKey;
+
+    @NotNull(message = "promotionActivityId不能为空")
+    @ApiModelProperty(value = "选中的活动ID", required = true)
+    private Long promotionActivityId;
+
+    @ApiModelProperty(value = "店铺ID,单店可不传")
+    private Long storeId;
+
+    @ApiModelProperty(value = "已选优惠券ID,用于叠加校验")
+    private Long couponUserId;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStorePromotionListMultiStoreParam.java

@@ -0,0 +1,19 @@
+package com.fs.hisStore.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class FsStorePromotionListMultiStoreParam implements Serializable {
+
+    @NotEmpty(message = "orderKeys不能为空")
+    @ApiModelProperty(value = "多店铺orderKey列表", required = true)
+    private List<String> orderKeys;
+
+    @ApiModelProperty(value = "已选优惠券ID")
+    private Long couponUserId;
+}

+ 21 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStorePromotionListParam.java

@@ -0,0 +1,21 @@
+package com.fs.hisStore.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+@Data
+public class FsStorePromotionListParam implements Serializable {
+
+    @NotBlank(message = "orderKey不能为空")
+    @ApiModelProperty(value = "confirm返回的订单缓存key", required = true)
+    private String orderKey;
+
+    @ApiModelProperty(value = "店铺ID,单店可不传")
+    private Long storeId;
+
+    @ApiModelProperty(value = "已选优惠券ID,用于叠加校验")
+    private Long couponUserId;
+}

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

@@ -24,6 +24,11 @@ public interface IFsStoreProductCategoryScrmService
      */
     public FsStoreProductCategoryScrm selectFsStoreProductCategoryById(Long cateId);
 
+    /**
+     * 按分类ID批量查询
+     */
+    List<FsStoreProductCategoryScrm> selectByCateIds(List<Long> cateIds);
+
     /**
      * 查询商品分类列表
      *

+ 49 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStorePromotionComputeService.java

@@ -0,0 +1,49 @@
+package com.fs.hisStore.service;
+
+import com.fs.hisStore.domain.FsStoreOrderScrm;
+import com.fs.hisStore.param.FsStorePromotionComputeParam;
+import com.fs.hisStore.param.FsStorePromotionListMultiStoreParam;
+import com.fs.hisStore.param.FsStorePromotionListParam;
+import com.fs.hisStore.vo.FsStoreCartQueryVO;
+import com.fs.hisStore.vo.FsStorePromotionComputeResultVO;
+import com.fs.hisStore.vo.FsStorePromotionListVO;
+
+import java.util.List;
+
+/**
+ * 用户端阶梯满减/件数折扣计算服务
+ */
+public interface IFsStorePromotionComputeService {
+
+    FsStorePromotionListVO listApplicablePromotions(Long userId, FsStorePromotionListParam param);
+
+    List<FsStorePromotionListVO> listApplicablePromotionsMultiStore(Long userId, FsStorePromotionListMultiStoreParam param);
+
+    FsStorePromotionComputeResultVO computePromotion(Long userId, FsStorePromotionComputeParam param);
+
+    /**
+     * 基于已加载购物车计算活动优惠(供 computedOrder 复用,避免重复读 Redis)
+     */
+    FsStorePromotionComputeResultVO computePromotionWithCarts(Long userId,
+                                                              List<FsStoreCartQueryVO> carts,
+                                                              Long storeId,
+                                                              Long promotionActivityId,
+                                                              Long couponUserId);
+
+    /** 下单后写入待支付参与记录(usage_status=0) */
+    void recordPendingUsage(FsStoreOrderScrm order, FsStorePromotionComputeResultVO promotion);
+
+    /** 支付成功确认参与(usage_status=0→1) */
+    void confirmUsageByOrderId(Long orderId);
+
+    /** 取消/超时回滚参与次数(usage_status→2) */
+    void rollbackUsageByOrderId(Long orderId);
+
+    /**
+     * 根据购物车与活动配置自动匹配最优满减/折扣(respect 活动时效、范围、限购、叠加券等)
+     */
+    FsStorePromotionComputeResultVO autoApplyBestPromotion(Long userId,
+                                                           List<FsStoreCartQueryVO> carts,
+                                                           Long storeId,
+                                                           Long couponUserId);
+}

+ 28 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStorePromotionService.java

@@ -0,0 +1,28 @@
+package com.fs.hisStore.service;
+
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import com.fs.hisStore.dto.FsStorePromotionActivityDTO;
+import com.fs.hisStore.vo.FsStorePromotionDetailVO;
+
+import java.util.List;
+
+public interface IFsStorePromotionService {
+
+    FsStorePromotionDetailVO selectFsStorePromotionById(Long id);
+
+    List<FsStorePromotionActivity> selectFsStorePromotionList(FsStorePromotionActivity query);
+
+    int insertFsStorePromotion(FsStorePromotionActivityDTO dto);
+
+    int updateFsStorePromotion(FsStorePromotionActivityDTO dto);
+
+    int deleteFsStorePromotionByIds(Long[] ids);
+
+    int enableActivity(Long id);
+
+    int disableActivity(Long id);
+
+    void fillDisplayLabels(List<FsStorePromotionActivity> list);
+
+    void fillDisplayLabels(FsStorePromotionActivity activity);
+}

+ 52 - 2
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -252,6 +252,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     @Autowired
     private IFsStoreCouponUserScrmService couponUserService;
     @Autowired
+    private IFsStorePromotionComputeService promotionComputeService;
+    @Autowired
     private ICompanyService companyService;
     @Autowired
     private IFsStoreOrderItemScrmService storeOrderItemService;
@@ -755,6 +757,9 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         if (payPrice.compareTo(BigDecimal.ZERO) <= 0) {
             payPrice = BigDecimal.ZERO;
         }
+
+        payPrice = subtractAutoPromotionDiscount(uid, carts, null, param.getCouponUserId(), payPrice);
+
         //优惠券
         if (param.getCouponUserId() != null) {
             FsStoreCouponUserScrm couponUser = couponUserService.selectFsStoreCouponUserById(param.getCouponUserId());
@@ -901,6 +906,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 storeOrder.setServiceFee(config.getServiceFee());
             }
 
+            applyPromotionFields(storeOrder, carts, userId, param.getCouponUserId(), param.getStoreId());
+
             //后台制单处理
             if (param.getPayPrice() != null && param.getPayPrice().compareTo(BigDecimal.ZERO) > 0) {
                 if (param.getPayPrice().compareTo(dto.getTotalPrice()) > 0) {
@@ -964,6 +971,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             if (flag == 0) {
                 return R.error("订单创建失败");
             }
+            promotionComputeService.recordPendingUsage(storeOrder,
+                    promotionComputeService.autoApplyBestPromotion(userId, carts, param.getStoreId(), param.getCouponUserId()));
             if (!isPay && storeOrder.getCompanyId() != null) {
                 // 添加订单审核
                 addOrderAudit(storeOrder);
@@ -1266,6 +1275,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             this.refundIntegral(order);
             //退回优惠券
             this.refundCoupon(order);
+            promotionComputeService.rollbackUsageByOrderId(orderId);
             //退回库存
             this.refundStock(order);
             fsStoreOrderMapper.cancelOrder(orderId);
@@ -2326,6 +2336,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         storeOrder.setStatus(OrderInfoEnum.STATUS_1.getValue());
         storeOrder.setPayTime(new Date());
         fsStoreOrderMapper.updateFsStoreOrder(storeOrder);
+        promotionComputeService.confirmUsageByOrderId(order.getId());
         // 添加订单审核
         if (storeOrder.getCompanyId() != null) {
             addOrderAudit(order);
@@ -5592,6 +5603,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             storeOrder.setShippingType(1);
             storeOrder.setCreateTime(new Date());
             storeOrder.setStatus(0);
+            applyPromotionFields(storeOrder, carts, userId, param.getCouponUserId(), param.getStoreId());
             //后台制单处理
             if (param.getPayPrice() != null && param.getPayPrice().compareTo(BigDecimal.ZERO) > 0) {
                 storeOrder.setPayPrice(param.getPayPrice());
@@ -5623,6 +5635,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             if (flag == 0) {
                 return R.error("订单创建失败");
             }
+            promotionComputeService.recordPendingUsage(storeOrder,
+                    promotionComputeService.autoApplyBestPromotion(userId, carts, param.getStoreId(), param.getCouponUserId()));
             //收款单更新
 //            if(param.getPaymentId()!=null&&param.getPaymentId()>0){
 //                FsStorePayment payment=new FsStorePayment();
@@ -5788,9 +5802,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
 
 
         for (int i = 0; i < orderKeys.size(); i++) {
-//        for (String orderKey : orderKeys) {
             FsStoreOrderComputedParam computedParam = new FsStoreOrderComputedParam();
-            BeanUtils.copyProperties(computedParam, params);
+            BeanUtils.copyProperties(params, computedParam);
             computedParam.setOrderKey(orderKeys.get(i));
             if (!createOrderKeys.isEmpty() && createOrderKeys.get(i) != null) {
                 computedParam.setCreateOrderKey(createOrderKeys.get(i));
@@ -6143,6 +6156,9 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         if (payPrice.compareTo(BigDecimal.ZERO) <= 0) {
             payPrice = BigDecimal.ZERO;
         }
+
+        payPrice = subtractAutoPromotionDiscount(uid, carts, null, param.getCouponUserId(), payPrice);
+
         //优惠券
         if (param.getCouponUserId() != null) {
             FsStoreCouponUserScrm couponUser = couponUserService.selectFsStoreCouponUserById(param.getCouponUserId());
@@ -6163,6 +6179,39 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 .build();
     }
 
+    /** 积分之后、优惠券之前:自动匹配满减/折扣并扣减 */
+    private BigDecimal subtractAutoPromotionDiscount(long uid,
+                                                     List<FsStoreCartQueryVO> carts,
+                                                     Long storeId,
+                                                     Long couponUserId,
+                                                     BigDecimal payPrice) {
+        FsStorePromotionComputeResultVO promotion = promotionComputeService.autoApplyBestPromotion(
+                uid, carts, storeId, couponUserId);
+        if (promotion == null || promotion.getPromotionDiscountAmount() == null) {
+            return payPrice;
+        }
+        BigDecimal newPayPrice = NumberUtil.sub(payPrice, promotion.getPromotionDiscountAmount());
+        return newPayPrice.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : newPayPrice;
+    }
+
+    private void applyPromotionFields(FsStoreOrderScrm storeOrder,
+                                      List<FsStoreCartQueryVO> carts,
+                                      long userId,
+                                      Long couponUserId,
+                                      Long storeId) {
+        if (storeOrder == null) {
+            return;
+        }
+        FsStorePromotionComputeResultVO promotion = promotionComputeService.autoApplyBestPromotion(
+                userId, carts, storeId, couponUserId);
+        if (promotion == null || promotion.getPromotionDiscountAmount() == null
+                || promotion.getPromotionDiscountAmount().compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        storeOrder.setPromotionActivityId(promotion.getPromotionActivityId());
+        storeOrder.setPromotionTierId(promotion.getPromotionTierId());
+        storeOrder.setPromotionDiscountAmount(promotion.getPromotionDiscountAmount());
+    }
 
     /**
      * 获取某字段价格
@@ -6440,6 +6489,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             this.refundIntegral(order);
             //退回优惠券
             this.refundCoupon(order);
+            promotionComputeService.rollbackUsageByOrderId(order.getId());
             //退回库存
             this.refundStock(order);
             fsStoreOrderMapper.cancelOrder(order.getId());

+ 8 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductCategoryScrmServiceImpl.java

@@ -41,6 +41,14 @@ public class FsStoreProductCategoryScrmServiceImpl implements IFsStoreProductCat
         return fsStoreProductCategoryMapper.selectFsStoreProductCategoryById(cateId);
     }
 
+    @Override
+    public List<FsStoreProductCategoryScrm> selectByCateIds(List<Long> cateIds) {
+        if (cateIds == null || cateIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return fsStoreProductCategoryMapper.selectByCateIds(cateIds);
+    }
+
     /**
      * 查询商品分类列表
      *

+ 634 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStorePromotionComputeServiceImpl.java

@@ -0,0 +1,634 @@
+package com.fs.hisStore.service.impl;
+
+import cn.hutool.core.util.NumberUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.common.exception.CustomException;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.hisStore.domain.FsStoreOrderScrm;
+import com.fs.hisStore.domain.FsStoreProductCategoryScrm;
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import com.fs.hisStore.domain.FsStorePromotionScope;
+import com.fs.hisStore.domain.FsStorePromotionTier;
+import com.fs.hisStore.domain.FsStorePromotionUsage;
+import com.fs.hisStore.mapper.FsStorePromotionActivityMapper;
+import com.fs.hisStore.mapper.FsStorePromotionScopeMapper;
+import com.fs.hisStore.mapper.FsStorePromotionTierMapper;
+import com.fs.hisStore.mapper.FsStorePromotionUsageMapper;
+import com.fs.hisStore.dto.FsStorePromotionUsageCountDTO;
+import com.fs.hisStore.param.FsStorePromotionComputeParam;
+import com.fs.hisStore.param.FsStorePromotionListMultiStoreParam;
+import com.fs.hisStore.param.FsStorePromotionListParam;
+import com.fs.hisStore.service.IFsStoreProductCategoryScrmService;
+import com.fs.hisStore.service.IFsStorePromotionComputeService;
+import com.fs.hisStore.support.FsStorePromotionTierCalculator;
+import com.fs.hisStore.support.FsStorePromotionTierCalculator.EligibleSummary;
+import com.fs.hisStore.vo.FsStoreCartQueryVO;
+import com.fs.hisStore.vo.FsStorePromotionActivityItemVO;
+import com.fs.hisStore.vo.FsStorePromotionComputeResultVO;
+import com.fs.hisStore.vo.FsStorePromotionListVO;
+import com.fs.hisStore.vo.FsStorePromotionTierItemVO;
+import com.fs.hisStore.vo.FsStorePromotionTierMatchVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class FsStorePromotionComputeServiceImpl implements IFsStorePromotionComputeService {
+
+    private static final Map<Integer, String> SCOPE_TYPE_LABELS = new HashMap<>();
+    private static final Map<Integer, String> TIER_TYPE_LABELS = new HashMap<>();
+
+    static {
+        SCOPE_TYPE_LABELS.put(1, "全场通用");
+        SCOPE_TYPE_LABELS.put(2, "指定分类");
+        SCOPE_TYPE_LABELS.put(3, "指定商品");
+        TIER_TYPE_LABELS.put(1, "金额");
+        TIER_TYPE_LABELS.put(2, "折扣");
+    }
+
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private FsStorePromotionActivityMapper activityMapper;
+    @Autowired
+    private FsStorePromotionTierMapper tierMapper;
+    @Autowired
+    private FsStorePromotionScopeMapper scopeMapper;
+    @Autowired
+    private FsStorePromotionUsageMapper usageMapper;
+    @Autowired
+    private IFsStoreProductCategoryScrmService categoryService;
+
+    @Override
+    public FsStorePromotionListVO listApplicablePromotions(Long userId, FsStorePromotionListParam param) {
+        List<FsStoreCartQueryVO> carts = loadCartsByOrderKey(param.getOrderKey());
+        Long storeId = resolveStoreId(carts, param.getStoreId());
+        if (storeId == null) {
+            throw new CustomException("无法识别店铺信息");
+        }
+        List<FsStoreCartQueryVO> storeCarts = filterCartsByStore(carts, storeId);
+        return buildPromotionListVO(userId, storeId, storeCarts, param.getCouponUserId());
+    }
+
+    @Override
+    public List<FsStorePromotionListVO> listApplicablePromotionsMultiStore(Long userId,
+                                                                           FsStorePromotionListMultiStoreParam param) {
+        List<FsStorePromotionListVO> result = new ArrayList<>();
+        for (String orderKey : param.getOrderKeys()) {
+            List<FsStoreCartQueryVO> carts = loadCartsByOrderKey(orderKey);
+            Long storeId = resolveStoreId(carts, null);
+            if (storeId == null) {
+                throw new CustomException("无法识别店铺信息");
+            }
+            result.add(buildPromotionListVO(userId, storeId, carts, param.getCouponUserId()));
+        }
+        return result;
+    }
+
+    @Override
+    public FsStorePromotionComputeResultVO computePromotion(Long userId, FsStorePromotionComputeParam param) {
+        List<FsStoreCartQueryVO> carts = loadCartsByOrderKey(param.getOrderKey());
+        return computePromotionWithCarts(userId, carts, param.getStoreId(),
+                param.getPromotionActivityId(), param.getCouponUserId());
+    }
+
+    @Override
+    public FsStorePromotionComputeResultVO computePromotionWithCarts(Long userId,
+                                                                     List<FsStoreCartQueryVO> carts,
+                                                                     Long storeId,
+                                                                     Long promotionActivityId,
+                                                                     Long couponUserId) {
+        if (promotionActivityId == null) {
+            return null;
+        }
+        if (CollectionUtils.isEmpty(carts)) {
+            throw new CustomException("购物车为空", 501);
+        }
+        Long resolvedStoreId = resolveStoreId(carts, storeId);
+        if (resolvedStoreId == null) {
+            throw new CustomException("无法识别店铺信息");
+        }
+        List<FsStoreCartQueryVO> storeCarts = filterCartsByStore(carts, resolvedStoreId);
+        activityMapper.expireActivities();
+
+        FsStorePromotionActivity activity = activityMapper.selectFsStorePromotionActivityById(promotionActivityId);
+        if (activity == null || activity.getIsDel() != null && activity.getIsDel() == 1) {
+            throw new CustomException("活动不存在");
+        }
+        validateActivityActive(activity, resolvedStoreId);
+
+        List<FsStorePromotionTier> tiers = tierMapper.selectByActivityId(activity.getId());
+        List<FsStorePromotionScope> scopes = loadScopesIfNeeded(activity);
+        Set<Long> scopeTargetIds = scopes.stream().map(FsStorePromotionScope::getTargetId).collect(Collectors.toSet());
+        Map<Long, FsStoreProductCategoryScrm> categoryMap = buildCategoryMap(storeCarts, scopes);
+
+        EligibleSummary summary = FsStorePromotionTierCalculator.computeEligibleSummary(
+                storeCarts, activity.getScopeType(), scopeTargetIds, categoryMap);
+        int usedCount = usageMapper.countEffectiveByActivityAndUser(activity.getId(), userId);
+        FsStorePromotionActivityItemVO item = buildActivityItem(
+                activity, tiers, summary, couponUserId, usedCount);
+
+        FsStorePromotionComputeResultVO result = new FsStorePromotionComputeResultVO();
+        result.setStoreId(resolvedStoreId);
+        result.setStoreName(resolveStoreName(storeCarts));
+        result.setPromotionActivityId(activity.getId());
+        result.setPromotionTitle(activity.getTitle());
+        result.setTierType(activity.getTierType());
+        result.setTierTypeLabel(resolveTierTypeLabel(activity.getTierType()));
+        result.setEligibleAmount(summary.getAmount());
+        result.setEligibleQuantity(summary.getQuantity());
+        result.setMatchedTier(item.getMatchedTier());
+        result.setNextTierTip(item.getNextTierTip());
+        result.setPromotionDiscountAmount(item.getEstimatedDiscount());
+        result.setEnabled(item.getEnabled());
+        result.setDisabledReason(item.getDisabledReason());
+        result.setPromotionRemainCount(item.getUserRemainCount());
+
+        if (Boolean.TRUE.equals(item.getEnabled()) && item.getMatchedTier() != null) {
+            result.setPromotionTierId(item.getMatchedTier().getTierId());
+        }
+
+        BigDecimal storeTotal = calculateStoreTotalAmount(storeCarts);
+        BigDecimal discount = item.getEstimatedDiscount() == null ? BigDecimal.ZERO : item.getEstimatedDiscount();
+        result.setPayAmountAfterPromotion(NumberUtil.sub(storeTotal, discount).max(BigDecimal.ZERO));
+        return result;
+    }
+
+    private List<FsStorePromotionScope> loadScopesIfNeeded(FsStorePromotionActivity activity) {
+        if (activity.getScopeType() == null || activity.getScopeType() == 1) {
+            return Collections.emptyList();
+        }
+        return scopeMapper.selectByActivityId(activity.getId());
+    }
+
+    private FsStorePromotionListVO buildPromotionListVO(Long userId,
+                                                        Long storeId,
+                                                        List<FsStoreCartQueryVO> storeCarts,
+                                                        Long couponUserId) {
+        activityMapper.expireActivities();
+        List<FsStorePromotionActivity> activities = activityMapper.selectActivePromotionsByStoreId(storeId);
+
+        FsStorePromotionListVO vo = new FsStorePromotionListVO();
+        vo.setStoreId(storeId);
+        vo.setStoreName(resolveStoreName(storeCarts));
+        if (CollectionUtils.isEmpty(activities)) {
+            vo.setActivities(Collections.emptyList());
+            return vo;
+        }
+
+        PromotionBatchData batchData = loadPromotionBatchData(activities, userId);
+        Map<Long, FsStoreProductCategoryScrm> categoryMap = buildCategoryMap(
+                storeCarts, flattenScopes(batchData.getScopeMap()));
+        EligibleSummary storeSummary = summarizeStoreCarts(storeCarts);
+
+        List<FsStorePromotionActivityItemVO> activityItems = new ArrayList<>();
+        for (FsStorePromotionActivity activity : activities) {
+            List<FsStorePromotionTier> tiers = batchData.getTierMap().get(activity.getId());
+            if (CollectionUtils.isEmpty(tiers)) {
+                continue;
+            }
+            Set<Long> scopeTargetIds = resolveScopeTargetIds(batchData.getScopeMap(), activity.getId());
+            EligibleSummary summary = FsStorePromotionTierCalculator.computeEligibleSummary(
+                    storeCarts, activity.getScopeType(), scopeTargetIds, categoryMap);
+            int usedCount = batchData.getUsageCountMap().getOrDefault(activity.getId(), 0);
+            activityItems.add(buildActivityItem(activity, tiers, summary, couponUserId, usedCount));
+        }
+
+        vo.setEligibleAmount(storeSummary.getAmount());
+        vo.setEligibleQuantity(storeSummary.getQuantity());
+        vo.setActivities(activityItems);
+        vo.setRecommendedActivityId(selectRecommendedActivityId(activityItems));
+        return vo;
+    }
+
+    /**
+     * 单次请求内批量加载活动阶梯、适用范围、用户参与次数,避免循环查库。
+     */
+    private PromotionBatchData loadPromotionBatchData(List<FsStorePromotionActivity> activities, Long userId) {
+        List<Long> activityIds = activities.stream()
+                .map(FsStorePromotionActivity::getId)
+                .collect(Collectors.toList());
+        return new PromotionBatchData(
+                loadTierMap(activityIds),
+                loadScopeMap(activities),
+                loadUsageCountMap(activityIds, userId));
+    }
+
+    private Map<Long, List<FsStorePromotionTier>> loadTierMap(List<Long> activityIds) {
+        if (CollectionUtils.isEmpty(activityIds)) {
+            return Collections.emptyMap();
+        }
+        List<FsStorePromotionTier> tierList = tierMapper.selectByActivityIds(activityIds);
+        if (CollectionUtils.isEmpty(tierList)) {
+            return Collections.emptyMap();
+        }
+        Map<Long, List<FsStorePromotionTier>> tierMap = tierList.stream()
+                .collect(Collectors.groupingBy(FsStorePromotionTier::getActivityId));
+        tierMap.values().forEach(list -> list.sort(Comparator.comparing(
+                tier -> tier.getSortOrder() == null ? 0 : tier.getSortOrder())));
+        return tierMap;
+    }
+
+    private Map<Long, Integer> loadUsageCountMap(List<Long> activityIds, Long userId) {
+        if (userId == null || CollectionUtils.isEmpty(activityIds)) {
+            return Collections.emptyMap();
+        }
+        List<FsStorePromotionUsageCountDTO> countList =
+                usageMapper.countEffectiveByActivityIdsAndUser(activityIds, userId);
+        if (CollectionUtils.isEmpty(countList)) {
+            return Collections.emptyMap();
+        }
+        Map<Long, Integer> usageCountMap = new HashMap<>(countList.size());
+        for (FsStorePromotionUsageCountDTO item : countList) {
+            if (item.getActivityId() != null) {
+                usageCountMap.put(item.getActivityId(),
+                        item.getUsedCount() == null ? 0 : item.getUsedCount());
+            }
+        }
+        return usageCountMap;
+    }
+
+    private Set<Long> resolveScopeTargetIds(Map<Long, List<FsStorePromotionScope>> scopeMap, Long activityId) {
+        return scopeMap.getOrDefault(activityId, Collections.emptyList()).stream()
+                .map(FsStorePromotionScope::getTargetId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+    }
+
+    private boolean isStoreWideScope(Integer scopeType) {
+        return scopeType == null || scopeType == 1;
+    }
+
+    private static final class PromotionBatchData {
+        private final Map<Long, List<FsStorePromotionTier>> tierMap;
+        private final Map<Long, List<FsStorePromotionScope>> scopeMap;
+        private final Map<Long, Integer> usageCountMap;
+
+        private PromotionBatchData(Map<Long, List<FsStorePromotionTier>> tierMap,
+                                   Map<Long, List<FsStorePromotionScope>> scopeMap,
+                                   Map<Long, Integer> usageCountMap) {
+            this.tierMap = tierMap;
+            this.scopeMap = scopeMap;
+            this.usageCountMap = usageCountMap;
+        }
+
+        private Map<Long, List<FsStorePromotionTier>> getTierMap() {
+            return tierMap;
+        }
+
+        private Map<Long, List<FsStorePromotionScope>> getScopeMap() {
+            return scopeMap;
+        }
+
+        private Map<Long, Integer> getUsageCountMap() {
+            return usageCountMap;
+        }
+    }
+
+    private FsStorePromotionActivityItemVO buildActivityItem(FsStorePromotionActivity activity,
+                                                             List<FsStorePromotionTier> tiers,
+                                                             EligibleSummary summary,
+                                                             Long couponUserId,
+                                                             int usedCount) {
+        FsStorePromotionActivityItemVO item = new FsStorePromotionActivityItemVO();
+        item.setActivityId(activity.getId());
+        item.setTitle(activity.getTitle());
+        item.setTierType(activity.getTierType());
+        item.setTierTypeLabel(resolveTierTypeLabel(activity.getTierType()));
+        item.setScopeType(activity.getScopeType());
+        item.setScopeTypeLabel(SCOPE_TYPE_LABELS.getOrDefault(activity.getScopeType(), ""));
+        item.setIsStackable(activity.getIsStackable() != null && activity.getIsStackable() == 1);
+        item.setIsCapped(activity.getIsCapped() != null && activity.getIsCapped() == 1);
+        item.setLimitPerUser(activity.getLimitPerUser());
+        item.setEligibleAmount(summary.getAmount());
+        item.setEligibleQuantity(summary.getQuantity());
+        item.setTiers(convertTierItems(tiers));
+
+        item.setUserUsedCount(usedCount);
+        item.setUserRemainCount(calcRemainCount(activity.getLimitPerUser(), usedCount));
+
+        String disabledReason = resolveDisabledReason(activity, summary, tiers, couponUserId, usedCount);
+        item.setDisabledReason(disabledReason);
+        item.setEnabled(disabledReason == null);
+
+        FsStorePromotionTier matchedTier = FsStorePromotionTierCalculator.matchTier(activity, summary, tiers);
+        if (matchedTier != null) {
+            item.setMatchedTier(toTierMatchVO(matchedTier));
+            item.setEstimatedDiscount(FsStorePromotionTierCalculator.calculateDiscount(activity, matchedTier, summary, tiers));
+        } else {
+            item.setEstimatedDiscount(BigDecimal.ZERO);
+        }
+        item.setNextTierTip(FsStorePromotionTierCalculator.buildNextTierTip(activity, summary, tiers));
+        return item;
+    }
+
+    private String resolveDisabledReason(FsStorePromotionActivity activity,
+                                         EligibleSummary summary,
+                                         List<FsStorePromotionTier> tiers,
+                                         Long couponUserId,
+                                         int usedCount) {
+        if (activity.getLimitPerUser() != null && activity.getLimitPerUser() > 0
+                && usedCount >= activity.getLimitPerUser()) {
+            return "您已超过该活动参与次数限制";
+        }
+        if (couponUserId != null && activity.getIsStackable() != null && activity.getIsStackable() == 0) {
+            return "该活动不可与优惠券叠加使用";
+        }
+        if (summary.getAmount().compareTo(BigDecimal.ZERO) <= 0 && summary.getQuantity() <= 0) {
+            return "购物车中无适用商品";
+        }
+        FsStorePromotionTier matchedTier = FsStorePromotionTierCalculator.matchTier(activity, summary, tiers);
+        if (matchedTier == null) {
+            int tierType = activity.getTierType() == null ? FsStorePromotionTierCalculator.TIER_TYPE_AMOUNT : activity.getTierType();
+            if (tierType == FsStorePromotionTierCalculator.TIER_TYPE_DISCOUNT) {
+                return "适用商品件数未达最低门槛";
+            }
+            return "适用商品金额未达最低门槛";
+        }
+        return null;
+    }
+
+    private Long selectRecommendedActivityId(List<FsStorePromotionActivityItemVO> activities) {
+        return activities.stream()
+                .filter(item -> Boolean.TRUE.equals(item.getEnabled()))
+                .filter(item -> item.getEstimatedDiscount() != null
+                        && item.getEstimatedDiscount().compareTo(BigDecimal.ZERO) > 0)
+                .max(Comparator.comparing(FsStorePromotionActivityItemVO::getEstimatedDiscount))
+                .map(FsStorePromotionActivityItemVO::getActivityId)
+                .orElse(null);
+    }
+
+    private void validateActivityActive(FsStorePromotionActivity activity, Long storeId) {
+        Date now = new Date();
+        if (!Objects.equals(activity.getStoreId(), storeId)) {
+            throw new CustomException("活动与当前店铺不匹配");
+        }
+        if (activity.getManualStatus() == null || activity.getManualStatus() != 1) {
+            throw new CustomException("活动已结束,请重新下单");
+        }
+        if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
+            throw new CustomException("活动未开始");
+        }
+        if (activity.getActivityEndTime() != null && activity.getActivityEndTime().before(now)) {
+            throw new CustomException("活动已结束,请重新下单");
+        }
+    }
+
+    private List<FsStoreCartQueryVO> loadCartsByOrderKey(String orderKey) {
+        String cartIds = redisCache.getCacheObject("orderKey:" + orderKey);
+        if (ObjectUtil.isNull(cartIds)) {
+            throw new CustomException("订单已过期", 501);
+        }
+        List<FsStoreCartQueryVO> carts = redisCache.getCacheObject("orderCarts:" + orderKey);
+        if (CollectionUtils.isEmpty(carts)) {
+            throw new CustomException("购物车为空", 501);
+        }
+        return carts;
+    }
+
+    private Long resolveStoreId(List<FsStoreCartQueryVO> carts, Long paramStoreId) {
+        if (paramStoreId != null) {
+            return paramStoreId;
+        }
+        return carts.stream()
+                .map(FsStoreCartQueryVO::getStoreId)
+                .filter(Objects::nonNull)
+                .findFirst()
+                .orElse(null);
+    }
+
+    private List<FsStoreCartQueryVO> filterCartsByStore(List<FsStoreCartQueryVO> carts, Long storeId) {
+        if (storeId == null) {
+            return carts;
+        }
+        return carts.stream()
+                .filter(cart -> storeId.equals(cart.getStoreId()))
+                .collect(Collectors.toList());
+    }
+
+    private String resolveStoreName(List<FsStoreCartQueryVO> carts) {
+        return carts.stream()
+                .map(FsStoreCartQueryVO::getStoreName)
+                .filter(Objects::nonNull)
+                .findFirst()
+                .orElse(null);
+    }
+
+    private BigDecimal calculateStoreTotalAmount(List<FsStoreCartQueryVO> carts) {
+        BigDecimal total = BigDecimal.ZERO;
+        for (FsStoreCartQueryVO cart : carts) {
+            if (cart.getCartNum() == null || cart.getPrice() == null) {
+                continue;
+            }
+            total = NumberUtil.add(total, NumberUtil.mul(cart.getCartNum(), cart.getPrice()));
+        }
+        return total;
+    }
+
+    private Integer calcRemainCount(Integer limitPerUser, int usedCount) {
+        if (limitPerUser == null || limitPerUser <= 0) {
+            return null;
+        }
+        return Math.max(limitPerUser - usedCount, 0);
+    }
+
+    private String resolveTierTypeLabel(Integer tierType) {
+        return TIER_TYPE_LABELS.getOrDefault(tierType == null ? 1 : tierType, "金额");
+    }
+
+    private List<FsStorePromotionTierItemVO> convertTierItems(List<FsStorePromotionTier> tiers) {
+        if (CollectionUtils.isEmpty(tiers)) {
+            return Collections.emptyList();
+        }
+        List<FsStorePromotionTierItemVO> items = new ArrayList<>();
+        for (FsStorePromotionTier tier : tiers) {
+            FsStorePromotionTierItemVO vo = new FsStorePromotionTierItemVO();
+            vo.setSortOrder(tier.getSortOrder());
+            vo.setThresholdAmount(tier.getThresholdAmount());
+            vo.setDiscountAmount(tier.getDiscountAmount());
+            items.add(vo);
+        }
+        return items;
+    }
+
+    private FsStorePromotionTierMatchVO toTierMatchVO(FsStorePromotionTier tier) {
+        FsStorePromotionTierMatchVO vo = new FsStorePromotionTierMatchVO();
+        vo.setTierId(tier.getId());
+        vo.setThresholdAmount(tier.getThresholdAmount());
+        vo.setDiscountAmount(tier.getDiscountAmount());
+        return vo;
+    }
+
+    private Map<Long, List<FsStorePromotionScope>> loadScopeMap(List<FsStorePromotionActivity> activities) {
+        Map<Long, List<FsStorePromotionScope>> scopeMap = new HashMap<>(activities.size());
+        List<Long> scopedActivityIds = new ArrayList<>();
+        for (FsStorePromotionActivity activity : activities) {
+            if (isStoreWideScope(activity.getScopeType())) {
+                scopeMap.put(activity.getId(), Collections.emptyList());
+            } else {
+                scopedActivityIds.add(activity.getId());
+            }
+        }
+        if (CollectionUtils.isEmpty(scopedActivityIds)) {
+            return scopeMap;
+        }
+        List<FsStorePromotionScope> scopeList = scopeMapper.selectByActivityIds(scopedActivityIds);
+        Map<Long, List<FsStorePromotionScope>> grouped = CollectionUtils.isEmpty(scopeList)
+                ? Collections.emptyMap()
+                : scopeList.stream().collect(Collectors.groupingBy(FsStorePromotionScope::getActivityId));
+        for (Long activityId : scopedActivityIds) {
+            scopeMap.put(activityId, grouped.getOrDefault(activityId, Collections.emptyList()));
+        }
+        return scopeMap;
+    }
+
+    private List<FsStorePromotionScope> flattenScopes(Map<Long, List<FsStorePromotionScope>> scopeMap) {
+        List<FsStorePromotionScope> scopes = new ArrayList<>();
+        for (List<FsStorePromotionScope> list : scopeMap.values()) {
+            scopes.addAll(list);
+        }
+        return scopes;
+    }
+
+    private Map<Long, FsStoreProductCategoryScrm> buildCategoryMap(List<FsStoreCartQueryVO> carts,
+                                                                   List<FsStorePromotionScope> scopes) {
+        Set<Long> cateIds = new HashSet<>();
+        for (FsStoreCartQueryVO cart : carts) {
+            if (cart.getCateId() != null) {
+                cateIds.add(cart.getCateId());
+            }
+        }
+        if (!CollectionUtils.isEmpty(scopes)) {
+            for (FsStorePromotionScope scope : scopes) {
+                if (scope.getScopeType() != null && scope.getScopeType() == 2 && scope.getTargetId() != null) {
+                    cateIds.add(scope.getTargetId());
+                }
+            }
+        }
+        if (cateIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<FsStoreProductCategoryScrm> categories = categoryService.selectByCateIds(new ArrayList<>(cateIds));
+        if (CollectionUtils.isEmpty(categories)) {
+            return Collections.emptyMap();
+        }
+        return categories.stream()
+                .collect(Collectors.toMap(FsStoreProductCategoryScrm::getCateId, item -> item, (a, b) -> a));
+    }
+
+    private EligibleSummary summarizeStoreCarts(List<FsStoreCartQueryVO> storeCarts) {
+        EligibleSummary summary = new EligibleSummary();
+        summary.setAmount(calculateStoreTotalAmount(storeCarts));
+        int quantity = 0;
+        for (FsStoreCartQueryVO cart : storeCarts) {
+            if (cart.getCartNum() != null) {
+                quantity += cart.getCartNum();
+            }
+        }
+        summary.setQuantity(quantity);
+        return summary;
+    }
+
+    @Override
+    public void recordPendingUsage(FsStoreOrderScrm order, FsStorePromotionComputeResultVO promotion) {
+        if (order == null || order.getId() == null || promotion == null || promotion.getPromotionActivityId() == null) {
+            return;
+        }
+        BigDecimal discount = promotion.getPromotionDiscountAmount();
+        if (discount == null || discount.compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        FsStorePromotionUsage usage = new FsStorePromotionUsage();
+        usage.setActivityId(promotion.getPromotionActivityId());
+        usage.setUserId(order.getUserId());
+        usage.setOrderId(order.getId());
+        usage.setOrderAmount(order.getTotalPrice());
+        usage.setDiscountAmount(discount);
+        usage.setTierId(promotion.getPromotionTierId());
+        usage.setUsageStatus(0);
+        Date now = new Date();
+        usage.setUsageTime(now);
+        usage.setCreateTime(now);
+        usageMapper.insertFsStorePromotionUsage(usage);
+    }
+
+    @Override
+    public void confirmUsageByOrderId(Long orderId) {
+        if (orderId == null) {
+            return;
+        }
+        usageMapper.updateUsageStatusByOrderId(orderId, 1, 0);
+    }
+
+    @Override
+    public void rollbackUsageByOrderId(Long orderId) {
+        if (orderId == null) {
+            return;
+        }
+        usageMapper.updateUsageStatusByOrderId(orderId, 2, 0);
+    }
+
+    @Override
+    public FsStorePromotionComputeResultVO autoApplyBestPromotion(Long userId,
+                                                                  List<FsStoreCartQueryVO> carts,
+                                                                  Long storeId,
+                                                                  Long couponUserId) {
+        if (CollectionUtils.isEmpty(carts)) {
+            return null;
+        }
+        Long resolvedStoreId = resolveStoreId(carts, storeId);
+        if (resolvedStoreId == null) {
+            return null;
+        }
+        List<FsStoreCartQueryVO> storeCarts = filterCartsByStore(carts, resolvedStoreId);
+        FsStorePromotionListVO listVO = buildPromotionListVO(userId, resolvedStoreId, storeCarts, couponUserId);
+        Long bestId = listVO.getRecommendedActivityId();
+        if (bestId == null || CollectionUtils.isEmpty(listVO.getActivities())) {
+            return null;
+        }
+        FsStorePromotionActivityItemVO best = listVO.getActivities().stream()
+                .filter(item -> bestId.equals(item.getActivityId()))
+                .findFirst()
+                .orElse(null);
+        if (best == null || !Boolean.TRUE.equals(best.getEnabled())) {
+            return null;
+        }
+        BigDecimal discount = best.getEstimatedDiscount();
+        if (discount == null || discount.compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        FsStorePromotionComputeResultVO result = new FsStorePromotionComputeResultVO();
+        result.setStoreId(resolvedStoreId);
+        result.setStoreName(listVO.getStoreName());
+        result.setPromotionActivityId(best.getActivityId());
+        result.setPromotionTitle(best.getTitle());
+        result.setTierType(best.getTierType());
+        result.setTierTypeLabel(best.getTierTypeLabel());
+        result.setEligibleAmount(best.getEligibleAmount());
+        result.setEligibleQuantity(best.getEligibleQuantity());
+        result.setMatchedTier(best.getMatchedTier());
+        result.setNextTierTip(best.getNextTierTip());
+        result.setPromotionDiscountAmount(discount);
+        result.setEnabled(true);
+        result.setPromotionRemainCount(best.getUserRemainCount());
+        if (best.getMatchedTier() != null) {
+            result.setPromotionTierId(best.getMatchedTier().getTierId());
+        }
+        BigDecimal storeTotal = calculateStoreTotalAmount(storeCarts);
+        result.setPayAmountAfterPromotion(NumberUtil.sub(storeTotal, discount).max(BigDecimal.ZERO));
+        return result;
+    }
+}

+ 451 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStorePromotionServiceImpl.java

@@ -0,0 +1,451 @@
+package com.fs.hisStore.service.impl;
+
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import com.fs.hisStore.domain.FsStorePromotionScope;
+import com.fs.hisStore.domain.FsStorePromotionTier;
+import com.fs.hisStore.domain.FsStoreProductCategoryScrm;
+import com.fs.hisStore.dto.FsStorePromotionActivityDTO;
+import com.fs.hisStore.dto.FsStorePromotionTierDTO;
+import com.fs.hisStore.mapper.FsStorePromotionActivityMapper;
+import com.fs.hisStore.mapper.FsStorePromotionScopeMapper;
+import com.fs.hisStore.mapper.FsStorePromotionTierMapper;
+import com.fs.hisStore.service.IFsStoreProductCategoryScrmService;
+import com.fs.hisStore.service.IFsStoreProductScrmService;
+import com.fs.hisStore.service.IFsStorePromotionService;
+import com.fs.hisStore.vo.FsStorePromotionDetailVO;
+import com.fs.hisStore.vo.FsStorePromotionScopeCategoryVO;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class FsStorePromotionServiceImpl implements IFsStorePromotionService {
+
+    private static final Map<Integer, String> DISPLAY_STATUS_LABELS = new HashMap<>();
+    private static final Map<Integer, String> SCOPE_TYPE_LABELS = new HashMap<>();
+    private static final Map<Integer, String> TIER_TYPE_LABELS = new HashMap<>();
+
+    static {
+        DISPLAY_STATUS_LABELS.put(0, "草稿");
+        DISPLAY_STATUS_LABELS.put(1, "未开始");
+        DISPLAY_STATUS_LABELS.put(2, "进行中");
+        DISPLAY_STATUS_LABELS.put(3, "已结束");
+        DISPLAY_STATUS_LABELS.put(4, "已关闭");
+        SCOPE_TYPE_LABELS.put(1, "全场通用");
+        SCOPE_TYPE_LABELS.put(2, "指定分类");
+        SCOPE_TYPE_LABELS.put(3, "指定商品");
+        TIER_TYPE_LABELS.put(1, "金额");
+        TIER_TYPE_LABELS.put(2, "折扣");
+    }
+
+    @Autowired
+    private FsStorePromotionActivityMapper activityMapper;
+    @Autowired
+    private FsStorePromotionTierMapper tierMapper;
+    @Autowired
+    private FsStorePromotionScopeMapper scopeMapper;
+    @Autowired
+    private IFsStoreProductCategoryScrmService categoryService;
+    @Autowired
+    private IFsStoreProductScrmService productService;
+
+    @Override
+    public FsStorePromotionDetailVO selectFsStorePromotionById(Long id) {
+        FsStorePromotionActivity activity = activityMapper.selectFsStorePromotionActivityById(id);
+        if (activity == null) {
+            return null;
+        }
+        fillDisplayLabels(activity);
+        FsStorePromotionDetailVO vo = new FsStorePromotionDetailVO();
+        BeanUtils.copyProperties(activity, vo);
+        vo.setTiers(tierMapper.selectByActivityId(id));
+        List<FsStorePromotionScope> scopes = scopeMapper.selectByActivityId(id);
+        List<Long> scopeIds = scopes.stream().map(FsStorePromotionScope::getTargetId).collect(Collectors.toList());
+        vo.setScopeIds(scopeIds);
+        fillScopeDetail(vo, scopeIds);
+        return vo;
+    }
+
+    private void fillScopeDetail(FsStorePromotionDetailVO vo, List<Long> scopeIds) {
+        if (vo.getScopeType() == null || CollectionUtils.isEmpty(scopeIds)) {
+            return;
+        }
+        if (Integer.valueOf(2).equals(vo.getScopeType())) {
+            List<FsStoreProductCategoryScrm> categoryList = categoryService.selectByCateIds(scopeIds);
+            Map<Long, FsStoreProductCategoryScrm> categoryMap = categoryList.stream()
+                    .collect(Collectors.toMap(FsStoreProductCategoryScrm::getCateId, c -> c, (a, b) -> a));
+            List<FsStorePromotionScopeCategoryVO> categories = new ArrayList<>();
+            for (Long cateId : scopeIds) {
+                FsStoreProductCategoryScrm category = categoryMap.get(cateId);
+                if (category == null) {
+                    continue;
+                }
+                FsStorePromotionScopeCategoryVO item = new FsStorePromotionScopeCategoryVO();
+                item.setCateId(category.getCateId());
+                item.setCateName(category.getCateName());
+                item.setPic(category.getPic());
+                categories.add(item);
+            }
+            vo.setScopeCategories(categories);
+            return;
+        }
+        if (Integer.valueOf(3).equals(vo.getScopeType())) {
+            String productIds = scopeIds.stream().map(String::valueOf).collect(Collectors.joining(","));
+            vo.setScopeProducts(productService.selectFsStoreProductByIds(productIds));
+        }
+    }
+
+    @Override
+    public List<FsStorePromotionActivity> selectFsStorePromotionList(FsStorePromotionActivity query) {
+        activityMapper.expireActivities();
+        List<FsStorePromotionActivity> list = activityMapper.selectFsStorePromotionActivityList(query);
+        fillDisplayLabels(list);
+        return list;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int insertFsStorePromotion(FsStorePromotionActivityDTO dto) {
+        validateActivityDto(dto, null);
+        FsStorePromotionActivity activity = buildActivityFromDto(dto);
+        activity.setStatus(0);
+        activity.setManualStatus(null);
+        activity.setVersion(0);
+        activity.setIsDel(0);
+        activity.setCreateTime(DateUtils.getNowDate());
+        activity.setUpdateTime(DateUtils.getNowDate());
+        try {
+            activity.setCreateBy(SecurityUtils.getUsername());
+            activity.setUpdateBy(SecurityUtils.getUsername());
+        } catch (Exception ignored) {
+        }
+        int rows = activityMapper.insertFsStorePromotionActivity(activity);
+        if (activity.getId() == null) {
+            throw new ServiceException("活动保存失败,请重试");
+        }
+        saveTiers(activity.getId(), dto.getTiers());
+        syncActivityScopes(activity.getId(), activity.getScopeType(), dto.getScopeIds());
+        return rows;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int updateFsStorePromotion(FsStorePromotionActivityDTO dto) {
+        if (dto.getId() == null) {
+            throw new ServiceException("活动ID不能为空");
+        }
+        FsStorePromotionActivity exist = activityMapper.selectFsStorePromotionActivityById(dto.getId());
+        if (exist == null) {
+            throw new ServiceException("活动不存在");
+        }
+        fillDisplayLabels(exist);
+        validateActivityDto(dto, exist);
+        if (Integer.valueOf(2).equals(exist.getDisplayStatus())) {
+            if (dto.getStartTime() != null && !dto.getStartTime().equals(exist.getStartTime())) {
+                throw new ServiceException("进行中的活动不可修改开始时间");
+            }
+            if (dto.getScopeType() != null && !dto.getScopeType().equals(exist.getScopeType())) {
+                throw new ServiceException("进行中的活动不可修改适用范围");
+            }
+            Integer existTierType = exist.getTierType() == null ? 1 : exist.getTierType();
+            Integer dtoTierType = dto.getTierType() == null ? existTierType : dto.getTierType();
+            if (!dtoTierType.equals(existTierType)) {
+                throw new ServiceException("进行中的活动不可修改阶梯类型");
+            }
+            if (dto.getEndTime() != null && dto.getEndTime().before(exist.getActivityEndTime())) {
+                throw new ServiceException("只能延后结束时间,不能提前");
+            }
+            dto.setStartTime(exist.getStartTime());
+            dto.setScopeType(exist.getScopeType());
+            dto.setTierType(existTierType);
+            if (dto.getTiers() != null) {
+                throw new ServiceException("进行中的活动不可修改阶梯档位");
+            }
+        }
+        FsStorePromotionActivity activity = buildActivityFromDto(dto);
+        activity.setId(dto.getId());
+        activity.setUpdateTime(DateUtils.getNowDate());
+        try {
+            activity.setUpdateBy(SecurityUtils.getUsername());
+        } catch (Exception ignored) {
+        }
+        int rows = activityMapper.updateFsStorePromotionActivity(activity);
+        if (dto.getTiers() != null) {
+            tierMapper.deleteByActivityId(dto.getId());
+            saveTiers(dto.getId(), dto.getTiers());
+        }
+        List<FsStorePromotionScope> existScopes = scopeMapper.selectByActivityId(dto.getId());
+        List<Long> existScopeIds = scopesToIds(existScopes);
+        Integer scopeType = dto.getScopeType() != null ? dto.getScopeType() : exist.getScopeType();
+        List<Long> scopeIds = dto.getScopeIds() != null ? dto.getScopeIds() : existScopeIds;
+        if (shouldSyncScopes(scopeType, scopeIds, exist.getScopeType(), existScopeIds)) {
+            syncActivityScopes(dto.getId(), scopeType, scopeIds);
+        }
+        return rows;
+    }
+
+    @Override
+    public int deleteFsStorePromotionByIds(Long[] ids) {
+        for (Long id : ids) {
+            FsStorePromotionActivity activity = activityMapper.selectFsStorePromotionActivityById(id);
+            if (activity == null) {
+                continue;
+            }
+            fillDisplayLabels(activity);
+            Integer ds = activity.getDisplayStatus();
+            if (ds != null && ds != 0 && ds != 3 && ds != 4) {
+                throw new ServiceException("仅草稿、已结束、已关闭状态的活动可删除");
+            }
+        }
+        return activityMapper.deleteFsStorePromotionActivityByIds(ids);
+    }
+
+    @Override
+    public int enableActivity(Long id) {
+        FsStorePromotionActivity activity = activityMapper.selectFsStorePromotionActivityById(id);
+        if (activity == null) {
+            throw new ServiceException("活动不存在");
+        }
+        if (activity.getActivityEndTime().before(new Date())) {
+            throw new ServiceException("活动已过期,无法启用");
+        }
+        int conflict = activityMapper.countConflictActivity(activity.getStoreId(), activity.getStartTime(), activity.getActivityEndTime(), id);
+        if (conflict > 0) {
+            throw new ServiceException("该时间段内已存在启用的满减活动,请调整时间");
+        }
+        return activityMapper.updateManualStatus(id, 1);
+    }
+
+    @Override
+    public int disableActivity(Long id) {
+        FsStorePromotionActivity activity = activityMapper.selectFsStorePromotionActivityById(id);
+        if (activity == null) {
+            throw new ServiceException("活动不存在");
+        }
+        fillDisplayLabels(activity);
+        Integer ds = activity.getDisplayStatus();
+        if (ds == null || (ds != 1 && ds != 2)) {
+            throw new ServiceException("仅未开始或进行中的活动可停用");
+        }
+        return activityMapper.updateManualStatus(id, 0);
+    }
+
+    @Override
+    public void fillDisplayLabels(List<FsStorePromotionActivity> list) {
+        if (list == null) {
+            return;
+        }
+        list.forEach(this::fillDisplayLabels);
+    }
+
+    @Override
+    public void fillDisplayLabels(FsStorePromotionActivity activity) {
+        if (activity == null || activity.getDisplayStatus() == null) {
+            return;
+        }
+        activity.setDisplayStatusLabel(getDisplayStatusLabel(activity.getDisplayStatus()));
+        activity.setScopeTypeLabel(getScopeTypeLabel(activity.getScopeType()));
+        activity.setTierTypeLabel(getTierTypeLabel(activity.getTierType()));
+    }
+
+    private void validateActivityDto(FsStorePromotionActivityDTO dto, FsStorePromotionActivity exist) {
+        if (StringUtils.isEmpty(dto.getTitle())) {
+            throw new ServiceException("活动名称不能为空");
+        }
+        if (dto.getStoreId() == null) {
+            throw new ServiceException("请选择店铺");
+        }
+        if (dto.getStartTime() == null || dto.getEndTime() == null) {
+            throw new ServiceException("请设置活动时间");
+        }
+        if (!dto.getStartTime().before(dto.getEndTime())) {
+            throw new ServiceException("结束时间必须大于开始时间");
+        }
+        if (exist == null && dto.getStartTime().before(new Date())) {
+            throw new ServiceException("开始时间不能早于当前时间");
+        }
+        List<FsStorePromotionTierDTO> tiers = dto.getTiers();
+        if (tiers == null || tiers.isEmpty()) {
+            if (exist == null) {
+                throw new ServiceException("至少设置一档满减规则");
+            }
+            return;
+        }
+        if (tiers.size() > 10) {
+            throw new ServiceException("阶梯档位最多10档");
+        }
+        int tierType = dto.getTierType() == null ? 1 : dto.getTierType();
+        if (tierType != 1 && tierType != 2) {
+            throw new ServiceException("阶梯类型有误");
+        }
+        for (int i = 0; i < tiers.size(); i++) {
+            FsStorePromotionTierDTO tier = tiers.get(i);
+            if (tier.getThresholdAmount() == null || tier.getDiscountAmount() == null) {
+                throw new ServiceException("请完善第" + (i + 1) + "档门槛与" + (tierType == 2 ? "折扣" : "优惠金额"));
+            }
+            if (tierType == 1) {
+                validateAmountTier(tiers, i, tier);
+            } else {
+                validateDiscountTier(tiers, i, tier);
+            }
+        }
+        Integer scopeType = dto.getScopeType();
+        if (scopeType != null && scopeType == 2 && CollectionUtils.isEmpty(dto.getScopeIds())) {
+            throw new ServiceException("请选择指定分类");
+        }
+        if (scopeType != null && scopeType == 3 && CollectionUtils.isEmpty(dto.getScopeIds())) {
+            throw new ServiceException("请选择指定商品");
+        }
+    }
+
+    private void validateAmountTier(List<FsStorePromotionTierDTO> tiers, int i, FsStorePromotionTierDTO tier) {
+        if (tier.getDiscountAmount().compareTo(tier.getThresholdAmount()) >= 0) {
+            throw new ServiceException("第" + (i + 1) + "档减扣金额必须小于门槛金额");
+        }
+        if (i > 0) {
+            if (tier.getThresholdAmount().compareTo(tiers.get(i - 1).getThresholdAmount()) <= 0) {
+                throw new ServiceException("第" + (i + 1) + "档门槛金额必须大于第" + i + "档");
+            }
+            if (tier.getDiscountAmount().compareTo(tiers.get(i - 1).getDiscountAmount()) < 0) {
+                throw new ServiceException("第" + (i + 1) + "档优惠金额不能小于第" + i + "档");
+            }
+        }
+    }
+
+    private void validateDiscountTier(List<FsStorePromotionTierDTO> tiers, int i, FsStorePromotionTierDTO tier) {
+        if (tier.getDiscountAmount().compareTo(new java.math.BigDecimal("0.1")) < 0
+                || tier.getDiscountAmount().compareTo(new java.math.BigDecimal("9.9")) > 0) {
+            throw new ServiceException("第" + (i + 1) + "档折扣需在0.1~9.9折之间");
+        }
+        if (i > 0) {
+            if (tier.getThresholdAmount().compareTo(tiers.get(i - 1).getThresholdAmount()) <= 0) {
+                throw new ServiceException("第" + (i + 1) + "档门槛金额必须大于第" + i + "档");
+            }
+            if (tier.getDiscountAmount().compareTo(tiers.get(i - 1).getDiscountAmount()) > 0) {
+                throw new ServiceException("第" + (i + 1) + "档折扣不能高于第" + i + "档");
+            }
+        }
+    }
+
+    private FsStorePromotionActivity buildActivityFromDto(FsStorePromotionActivityDTO dto) {
+        FsStorePromotionActivity activity = new FsStorePromotionActivity();
+        activity.setTitle(dto.getTitle());
+        activity.setStoreId(dto.getStoreId());
+        activity.setStartTime(dto.getStartTime());
+        activity.setActivityEndTime(dto.getEndTime());
+        activity.setScopeType(dto.getScopeType() == null ? 1 : dto.getScopeType());
+        activity.setTierType(dto.getTierType() == null ? 1 : dto.getTierType());
+        activity.setIsStackable(dto.getIsStackable() == null ? 1 : dto.getIsStackable());
+        activity.setIsCapped(dto.getIsCapped() == null ? 0 : dto.getIsCapped());
+        activity.setLimitPerUser(dto.getLimitPerUser() == null ? 0 : dto.getLimitPerUser());
+        activity.setActivityRemark(dto.getRemark());
+        return activity;
+    }
+
+    private List<Long> scopesToIds(List<FsStorePromotionScope> scopes) {
+        if (CollectionUtils.isEmpty(scopes)) {
+            return new ArrayList<>();
+        }
+        return scopes.stream().map(FsStorePromotionScope::getTargetId).collect(Collectors.toList());
+    }
+
+    private boolean shouldSyncScopes(Integer newType, List<Long> newIds, Integer oldType, List<Long> oldIds) {
+        if (!Objects.equals(newType, oldType)) {
+            return true;
+        }
+        if (newType == null || newType == 1) {
+            return !CollectionUtils.isEmpty(oldIds);
+        }
+        Set<Long> newSet = new HashSet<>(CollectionUtils.isEmpty(newIds) ? new ArrayList<>() : newIds);
+        Set<Long> oldSet = new HashSet<>(CollectionUtils.isEmpty(oldIds) ? new ArrayList<>() : oldIds);
+        return !newSet.equals(oldSet);
+    }
+
+    /**
+     * 同步适用范围到 fs_store_promotion_scope:先删后插
+     */
+    private void syncActivityScopes(Long activityId, Integer scopeType, List<Long> scopeIds) {
+        scopeMapper.deleteByActivityId(activityId);
+        if (scopeType == null || scopeType == 1 || CollectionUtils.isEmpty(scopeIds)) {
+            return;
+        }
+        List<FsStorePromotionScope> scopeList = new ArrayList<>();
+        for (Long targetId : scopeIds) {
+            if (targetId == null) {
+                continue;
+            }
+            FsStorePromotionScope scope = new FsStorePromotionScope();
+            scope.setActivityId(activityId);
+            scope.setScopeType(scopeType);
+            scope.setTargetId(targetId);
+            scopeList.add(scope);
+        }
+        if (scopeList.isEmpty()) {
+            return;
+        }
+        if (scopeList.size() == 1) {
+            scopeMapper.insertFsStorePromotionScope(scopeList.get(0));
+            return;
+        }
+        scopeMapper.batchInsertFsStorePromotionScope(scopeList);
+    }
+
+    private void saveTiers(Long activityId, List<FsStorePromotionTierDTO> tiers) {
+        if (CollectionUtils.isEmpty(tiers)) {
+            return;
+        }
+        List<FsStorePromotionTier> tierList = new ArrayList<>();
+        int sort = 1;
+        for (FsStorePromotionTierDTO dto : tiers) {
+            FsStorePromotionTier tier = new FsStorePromotionTier();
+            tier.setActivityId(activityId);
+            tier.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : sort);
+            tier.setThresholdAmount(dto.getThresholdAmount());
+            tier.setDiscountAmount(dto.getDiscountAmount());
+            tierList.add(tier);
+            sort++;
+        }
+        if (tierList.size() == 1) {
+            tierMapper.insertFsStorePromotionTier(tierList.get(0));
+            return;
+        }
+        tierMapper.batchInsertFsStorePromotionTier(tierList);
+    }
+
+    private String getDisplayStatusLabel(Integer displayStatus) {
+        if (displayStatus == null) {
+            return "";
+        }
+        return DISPLAY_STATUS_LABELS.getOrDefault(displayStatus, "未知");
+    }
+
+    private String getScopeTypeLabel(Integer scopeType) {
+        if (scopeType == null) {
+            return "";
+        }
+        return SCOPE_TYPE_LABELS.getOrDefault(scopeType, "未知");
+    }
+
+    private String getTierTypeLabel(Integer tierType) {
+        if (tierType == null) {
+            return TIER_TYPE_LABELS.getOrDefault(1, "金额");
+        }
+        return TIER_TYPE_LABELS.getOrDefault(tierType, "未知");
+    }
+}

+ 243 - 0
fs-service/src/main/java/com/fs/hisStore/support/FsStorePromotionTierCalculator.java

@@ -0,0 +1,243 @@
+package com.fs.hisStore.support;
+
+import cn.hutool.core.util.NumberUtil;
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import com.fs.hisStore.domain.FsStorePromotionTier;
+import com.fs.hisStore.domain.FsStoreProductCategoryScrm;
+import com.fs.hisStore.vo.FsStoreCartQueryVO;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 阶梯满减/件数折扣计算工具
+ */
+public final class FsStorePromotionTierCalculator {
+
+    public static final int TIER_TYPE_AMOUNT = 1;
+    public static final int TIER_TYPE_DISCOUNT = 2;
+
+    private FsStorePromotionTierCalculator() {
+    }
+
+    @Data
+    public static class EligibleSummary implements Serializable {
+        private BigDecimal amount = BigDecimal.ZERO;
+        private int quantity;
+    }
+
+    public static EligibleSummary computeEligibleSummary(List<FsStoreCartQueryVO> storeCarts,
+                                                         Integer scopeType,
+                                                         Set<Long> scopeTargetIds,
+                                                         Map<Long, FsStoreProductCategoryScrm> categoryMap) {
+        EligibleSummary summary = new EligibleSummary();
+        if (storeCarts == null || storeCarts.isEmpty()) {
+            return summary;
+        }
+        int scope = scopeType == null ? 1 : scopeType;
+        for (FsStoreCartQueryVO cart : storeCarts) {
+            if (cart == null || cart.getCartNum() == null || cart.getCartNum() <= 0) {
+                continue;
+            }
+            if (!isCartInScope(cart, scope, scopeTargetIds, categoryMap)) {
+                continue;
+            }
+            BigDecimal price = cart.getPrice() == null ? BigDecimal.ZERO : cart.getPrice();
+            summary.amount = NumberUtil.add(summary.amount, NumberUtil.mul(cart.getCartNum(), price));
+            summary.quantity += cart.getCartNum();
+        }
+        summary.amount = summary.amount.setScale(2, RoundingMode.HALF_UP);
+        return summary;
+    }
+
+    public static boolean isCartInScope(FsStoreCartQueryVO cart,
+                                        int scopeType,
+                                        Set<Long> scopeTargetIds,
+                                        Map<Long, FsStoreProductCategoryScrm> categoryMap) {
+        if (scopeType == 1) {
+            return true;
+        }
+        if (scopeTargetIds == null || scopeTargetIds.isEmpty()) {
+            return false;
+        }
+        if (scopeType == 3) {
+            return cart.getProductId() != null && scopeTargetIds.contains(cart.getProductId());
+        }
+        if (scopeType == 2) {
+            return isCategoryInScope(cart.getCateId(), scopeTargetIds, categoryMap);
+        }
+        return false;
+    }
+
+    public static boolean isCategoryInScope(Long cateId, Set<Long> scopeTargetIds,
+                                            Map<Long, FsStoreProductCategoryScrm> categoryMap) {
+        if (cateId == null || scopeTargetIds == null || scopeTargetIds.isEmpty()) {
+            return false;
+        }
+        Long current = cateId;
+        int guard = 0;
+        while (current != null && current > 0 && guard++ < 20) {
+            if (scopeTargetIds.contains(current)) {
+                return true;
+            }
+            FsStoreProductCategoryScrm category = categoryMap == null ? null : categoryMap.get(current);
+            if (category == null || category.getPid() == null || category.getPid() <= 0) {
+                break;
+            }
+            current = category.getPid();
+        }
+        return false;
+    }
+
+    public static FsStorePromotionTier matchTier(FsStorePromotionActivity activity,
+                                                 EligibleSummary summary,
+                                                 List<FsStorePromotionTier> tiers) {
+        if (activity == null || summary == null || tiers == null || tiers.isEmpty()) {
+            return null;
+        }
+        int tierType = activity.getTierType() == null ? TIER_TYPE_AMOUNT : activity.getTierType();
+        if (tierType == TIER_TYPE_DISCOUNT) {
+            return matchDiscountTier(summary.getQuantity(), tiers);
+        }
+        return matchAmountTier(summary.getAmount(), tiers);
+    }
+
+    public static FsStorePromotionTier matchAmountTier(BigDecimal eligibleAmount, List<FsStorePromotionTier> tiers) {
+        if (eligibleAmount == null || tiers == null || tiers.isEmpty()) {
+            return null;
+        }
+        FsStorePromotionTier matched = null;
+        for (FsStorePromotionTier tier : tiers) {
+            if (tier.getThresholdAmount() != null
+                    && eligibleAmount.compareTo(tier.getThresholdAmount()) >= 0) {
+                matched = tier;
+            }
+        }
+        return matched;
+    }
+
+    public static FsStorePromotionTier matchDiscountTier(int eligibleQuantity, List<FsStorePromotionTier> tiers) {
+        if (eligibleQuantity <= 0 || tiers == null || tiers.isEmpty()) {
+            return null;
+        }
+        FsStorePromotionTier matched = null;
+        for (FsStorePromotionTier tier : tiers) {
+            if (tier.getThresholdAmount() == null) {
+                continue;
+            }
+            int thresholdPieces = tier.getThresholdAmount().setScale(0, RoundingMode.UP).intValue();
+            if (eligibleQuantity >= thresholdPieces) {
+                matched = tier;
+            }
+        }
+        return matched;
+    }
+
+    public static BigDecimal calculateDiscount(FsStorePromotionActivity activity,
+                                               FsStorePromotionTier matchedTier,
+                                               EligibleSummary summary,
+                                               List<FsStorePromotionTier> tiers) {
+        if (activity == null || matchedTier == null || summary == null) {
+            return BigDecimal.ZERO;
+        }
+        int tierType = activity.getTierType() == null ? TIER_TYPE_AMOUNT : activity.getTierType();
+        if (tierType == TIER_TYPE_DISCOUNT) {
+            return calculateDiscountByRate(summary.getAmount(), matchedTier.getDiscountAmount());
+        }
+        return calculateAmountDiscount(activity, matchedTier, summary.getAmount(), tiers);
+    }
+
+    public static BigDecimal calculateAmountDiscount(FsStorePromotionActivity activity,
+                                                     FsStorePromotionTier matchedTier,
+                                                     BigDecimal eligibleAmount,
+                                                     List<FsStorePromotionTier> tiers) {
+        if (matchedTier == null || matchedTier.getDiscountAmount() == null) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal baseDiscount = matchedTier.getDiscountAmount();
+        if (activity == null || activity.getIsCapped() == null || activity.getIsCapped() != 1
+                || tiers == null || tiers.isEmpty()) {
+            return normalizeDiscount(baseDiscount, eligibleAmount);
+        }
+        FsStorePromotionTier lastTier = tiers.get(tiers.size() - 1);
+        BigDecimal lastThreshold = lastTier.getThresholdAmount();
+        BigDecimal lastDiscount = lastTier.getDiscountAmount();
+        if (lastThreshold == null || lastDiscount == null || eligibleAmount == null
+                || eligibleAmount.compareTo(lastThreshold) <= 0) {
+            return normalizeDiscount(baseDiscount, eligibleAmount);
+        }
+        BigDecimal exceed = eligibleAmount.subtract(lastThreshold);
+        BigDecimal extraUnits = exceed.divide(lastThreshold, 0, RoundingMode.DOWN);
+        BigDecimal totalDiscount = baseDiscount.add(extraUnits.multiply(lastDiscount));
+        return normalizeDiscount(totalDiscount, eligibleAmount);
+    }
+
+    public static BigDecimal calculateDiscountByRate(BigDecimal eligibleAmount, BigDecimal discountRate) {
+        if (eligibleAmount == null || discountRate == null || eligibleAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal payRate = discountRate.divide(BigDecimal.TEN, 4, RoundingMode.HALF_UP);
+        if (payRate.compareTo(BigDecimal.ZERO) <= 0 || payRate.compareTo(BigDecimal.ONE) >= 0) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal discount = eligibleAmount.multiply(BigDecimal.ONE.subtract(payRate))
+                .setScale(2, RoundingMode.HALF_UP);
+        return normalizeDiscount(discount, eligibleAmount);
+    }
+
+    public static String buildNextTierTip(FsStorePromotionActivity activity,
+                                          EligibleSummary summary,
+                                          List<FsStorePromotionTier> tiers) {
+        if (activity == null || summary == null || tiers == null || tiers.isEmpty()) {
+            return null;
+        }
+        int tierType = activity.getTierType() == null ? TIER_TYPE_AMOUNT : activity.getTierType();
+        for (FsStorePromotionTier tier : tiers) {
+            if (tier.getThresholdAmount() == null || tier.getDiscountAmount() == null) {
+                continue;
+            }
+            if (tierType == TIER_TYPE_DISCOUNT) {
+                int thresholdPieces = tier.getThresholdAmount().setScale(0, RoundingMode.UP).intValue();
+                if (summary.getQuantity() < thresholdPieces) {
+                    int diff = thresholdPieces - summary.getQuantity();
+                    return "再买" + diff + "件可享" + stripTrailingZero(tier.getDiscountAmount()) + "折";
+                }
+            } else if (summary.getAmount().compareTo(tier.getThresholdAmount()) < 0) {
+                BigDecimal diff = tier.getThresholdAmount().subtract(summary.getAmount()).setScale(2, RoundingMode.HALF_UP);
+                return "再购" + stripTrailingZero(diff) + "元可减" + stripTrailingZero(tier.getDiscountAmount()) + "元";
+            }
+        }
+        return null;
+    }
+
+    public static Set<Long> toScopeTargetIdSet(List<Long> scopeIds) {
+        if (scopeIds == null || scopeIds.isEmpty()) {
+            return Collections.emptySet();
+        }
+        return new HashSet<>(scopeIds);
+    }
+
+    private static BigDecimal normalizeDiscount(BigDecimal discount, BigDecimal eligibleAmount) {
+        if (discount == null || discount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        if (eligibleAmount != null && discount.compareTo(eligibleAmount) > 0) {
+            return eligibleAmount.setScale(2, RoundingMode.HALF_UP);
+        }
+        return discount.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static String stripTrailingZero(BigDecimal value) {
+        if (value == null) {
+            return "0";
+        }
+        return value.stripTrailingZeros().toPlainString();
+    }
+}

+ 49 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionActivityItemVO.java

@@ -0,0 +1,49 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class FsStorePromotionActivityItemVO implements Serializable {
+
+    private Long activityId;
+
+    private String title;
+
+    private Integer tierType;
+
+    private String tierTypeLabel;
+
+    private Integer scopeType;
+
+    private String scopeTypeLabel;
+
+    private Boolean isStackable;
+
+    private Boolean isCapped;
+
+    private Integer limitPerUser;
+
+    private Integer userUsedCount;
+
+    private Integer userRemainCount;
+
+    private Boolean enabled;
+
+    private String disabledReason;
+
+    private BigDecimal eligibleAmount;
+
+    private Integer eligibleQuantity;
+
+    private FsStorePromotionTierMatchVO matchedTier;
+
+    private String nextTierTip;
+
+    private BigDecimal estimatedDiscount;
+
+    private List<FsStorePromotionTierItemVO> tiers;
+}

+ 42 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionComputeResultVO.java

@@ -0,0 +1,42 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+@Data
+public class FsStorePromotionComputeResultVO implements Serializable {
+
+    private Long storeId;
+
+    private String storeName;
+
+    private Long promotionActivityId;
+
+    private String promotionTitle;
+
+    private Integer tierType;
+
+    private String tierTypeLabel;
+
+    private Long promotionTierId;
+
+    private BigDecimal eligibleAmount;
+
+    private Integer eligibleQuantity;
+
+    private BigDecimal promotionDiscountAmount;
+
+    private BigDecimal payAmountAfterPromotion;
+
+    private Integer promotionRemainCount;
+
+    private Boolean enabled;
+
+    private String disabledReason;
+
+    private FsStorePromotionTierMatchVO matchedTier;
+
+    private String nextTierTip;
+}

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionDetailVO.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.vo;
+
+import com.fs.hisStore.domain.FsStorePromotionActivity;
+import com.fs.hisStore.domain.FsStorePromotionTier;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class FsStorePromotionDetailVO extends FsStorePromotionActivity {
+
+    private List<FsStorePromotionTier> tiers;
+
+    private List<Long> scopeIds;
+
+    /** 指定分类详情(scopeType=2) */
+    private List<FsStorePromotionScopeCategoryVO> scopeCategories;
+
+    /** 指定商品详情(scopeType=3) */
+    private List<FsStoreProductActivityListVO> scopeProducts;
+}

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionListVO.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class FsStorePromotionListVO implements Serializable {
+
+    private Long storeId;
+
+    private String storeName;
+
+    private BigDecimal eligibleAmount;
+
+    private Integer eligibleQuantity;
+
+    private Long recommendedActivityId;
+
+    private List<FsStorePromotionActivityItemVO> activities;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionScopeCategoryVO.java

@@ -0,0 +1,20 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 满减活动详情-指定分类展示项
+ */
+@Data
+public class FsStorePromotionScopeCategoryVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long cateId;
+
+    private String cateName;
+
+    private String pic;
+}

+ 17 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionTierItemVO.java

@@ -0,0 +1,17 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class FsStorePromotionTierItemVO implements Serializable {
+
+    private Integer sortOrder;
+
+    private BigDecimal thresholdAmount;
+
+    private BigDecimal discountAmount;
+}

+ 17 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStorePromotionTierMatchVO.java

@@ -0,0 +1,17 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class FsStorePromotionTierMatchVO implements Serializable {
+
+    private Long tierId;
+
+    private BigDecimal thresholdAmount;
+
+    private BigDecimal discountAmount;
+}

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

@@ -30,8 +30,18 @@ public interface OpenIMService {
 
     OpenImResponseDTO importFriend(String ownerUserID,List<String> friendUserIDs);
 
+    /**
+     * 导入好友关系;skipFriendCheck=true 时跳过逐个 is_friend 检查(批量补绑场景)
+     */
+    OpenImResponseDTO importFriend(String ownerUserID, List<String> friendUserIDs, boolean skipFriendCheck);
+
     OpenImResponseDTO isFriend(String userID1, String userID2);
     R accountCheck(String userId, String type);
+
+    /**
+     * 批量检测并注册 IM 账号(不获取 user_token,适用于补绑场景)
+     */
+    List<String> batchEnsureAccountsRegistered(List<String> imUserIds, String type);
     void checkAndImportFriend(Long companyUserId,String fsUserId);
     OpenImResponseDTO sendCourse(Long userId,Long companyUserId,String url,String title,String linkImageUrl,String cropId) throws JsonProcessingException;
     void checkAndImportFriendByDianBo(Long companyUserId,String fsUserId,String cropId, boolean isUpdate);

+ 234 - 23
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -32,11 +32,10 @@ import com.fs.his.dto.PayloadDTO;
 import com.fs.his.mapper.FsDoctorMapper;
 import com.fs.his.mapper.FsFollowMapper;
 import com.fs.his.mapper.FsUserMapper;
-import com.fs.his.service.IFsImFriendshipService;
+import com.fs.his.service.IFsImFriendshipBindService;
 import com.fs.im.config.IMConfig;
 import com.fs.im.domain.FsImMsgSendDetail;
 import com.fs.im.domain.FsImMsgSendLog;
-import com.fs.im.domain.ImSendLog;
 import com.fs.im.dto.*;
 import com.fs.im.mapper.FsImMsgSendDetailMapper;
 import com.fs.im.mapper.FsImMsgSendLogMapper;
@@ -49,12 +48,10 @@ import com.github.pagehelper.util.StringUtil;
 import com.google.common.base.Joiner;
 import lombok.Data;
 import lombok.extern.slf4j.Slf4j;
-import org.jetbrains.annotations.NotNull;
 import org.json.JSONArray;
 import org.json.JSONObject;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
@@ -62,11 +59,15 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 @Service
 @Slf4j
 public class OpenIMServiceImpl implements OpenIMService {
+
+    private static final int IM_ACCOUNT_CHECK_BATCH_SIZE = 200;
+    private static final int IM_USER_REGISTER_BATCH_SIZE = 100;
     @Autowired
     IMConfig imConfig;
     @Autowired
@@ -104,7 +105,7 @@ public class OpenIMServiceImpl implements OpenIMService {
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
 
     @Autowired
-    private IFsImFriendshipService fsImFriendshipService;
+    private IFsImFriendshipBindService fsImFriendshipBindService;
 
 
 //    @Value("${openIM.prefix}")
@@ -868,57 +869,86 @@ public class OpenIMServiceImpl implements OpenIMService {
      */
     @Override
     public OpenImResponseDTO importFriend(String ownerUserID, List<String> friendUserIDs) {
+        return importFriend(ownerUserID, friendUserIDs, false);
+    }
+
+    @Override
+    public OpenImResponseDTO importFriend(String ownerUserID, List<String> friendUserIDs, boolean skipFriendCheck) {
+        if (CollectionUtils.isEmpty(friendUserIDs)) {
+            return null;
+        }
+        if (skipFriendCheck) {
+            return doImportFriend(ownerUserID, friendUserIDs, friendUserIDs);
+        }
+
         //先检查用户是否存在好友关系
         List<String> newFriendIds = new ArrayList<>();
+        List<String> bindFriendUserIDs = new ArrayList<>(friendUserIDs.size());
         for (String friendUserID : friendUserIDs) {
-            OpenImResponseDTO friend = isFriend(ownerUserID,friendUserID);
-            if (friend.getErrCode()==0){
+            OpenImResponseDTO friend = isFriend(ownerUserID, friendUserID);
+            if (friend.getErrCode() == 0) {
                 Object data = friend.getData();
                 cn.hutool.json.JSONObject jsonObject = JSONUtil.parseObj(data);
-                Boolean inUser1Friends = (Boolean)jsonObject.get("inUser1Friends");
-                Boolean inUser2Friends = (Boolean)jsonObject.get("inUser2Friends");
+                Boolean inUser1Friends = (Boolean) jsonObject.get("inUser1Friends");
+                Boolean inUser2Friends = (Boolean) jsonObject.get("inUser2Friends");
                 //如果不存在好友关系
-                if (!inUser1Friends&&!inUser2Friends){
+                if (!inUser1Friends && !inUser2Friends) {
                     newFriendIds.add(friendUserID);
-                    //friendUserIDs.remove(friendUserID);
                 } else {//存在单项关系则重新双向绑定
                     boolean check = false;
-                    if(!inUser1Friends){
+                    if (!inUser1Friends) {
                         //删除1
-                        deleteFriend(ownerUserID,friendUserID);
+                        deleteFriend(ownerUserID, friendUserID);
                         check = true;
-                    }else if(!inUser2Friends){
+                    } else if (!inUser2Friends) {
                         //删除2
-                        deleteFriend(friendUserID,ownerUserID);
+                        deleteFriend(friendUserID, ownerUserID);
                         check = true;
                     }
 
-                    if(check){
+                    if (check) {
                         newFriendIds.add(friendUserID);
                     }
                 }
             }
 
-            //加入IM绑定信息
-            fsImFriendshipService.addBindInfo(friendUserID , ownerUserID);
+            bindFriendUserIDs.add(friendUserID);
         }
-        if (newFriendIds.size()<=0){
+
+        return doImportFriend(ownerUserID, newFriendIds, bindFriendUserIDs);
+    }
+
+    private OpenImResponseDTO doImportFriend(String ownerUserID, List<String> importFriendIds,
+                                             List<String> bindFriendUserIDs) {
+        if (importFriendIds.isEmpty()) {
+            saveImFriendshipBindInfo(bindFriendUserIDs, ownerUserID);
             return null;
         }
+
         String adminToken = getAdminToken();
         JSONObject jsonObject = new JSONObject();
-        jsonObject.put("ownerUserID",ownerUserID);
-        jsonObject.put("friendUserIDs",newFriendIds);
-        String body = HttpRequest.post(IMConfig.URL+"/friend/import_friend")
+        jsonObject.put("ownerUserID", ownerUserID);
+        jsonObject.put("friendUserIDs", importFriendIds);
+        String body = HttpRequest.post(IMConfig.URL + "/friend/import_friend")
                 .header("operationID", String.valueOf(System.currentTimeMillis()))
                 .header("token", adminToken)
                 .body(jsonObject.toString())
                 .execute()
                 .body();
-        OpenImResponseDTO responseDTO= JSONUtil.toBean(body,OpenImResponseDTO.class);
+        OpenImResponseDTO responseDTO = JSONUtil.toBean(body, OpenImResponseDTO.class);
+        if (responseDTO == null || responseDTO.getErrCode() == null || responseDTO.getErrCode() != 0) {
+            return responseDTO;
+        }
+        saveImFriendshipBindInfo(bindFriendUserIDs, ownerUserID);
         return responseDTO;
     }
 
+    private void saveImFriendshipBindInfo(List<String> bindFriendUserIDs, String ownerUserID) {
+        if (!bindFriendUserIDs.isEmpty()) {
+            fsImFriendshipBindService.batchAddBindInfo(bindFriendUserIDs, ownerUserID);
+        }
+    }
+
     /**
      * 通用注册im
      * @param userId
@@ -1042,6 +1072,187 @@ public class OpenIMServiceImpl implements OpenIMService {
             throw new ServiceException("获取管理员token失败");
         }
     }
+
+    @Override
+    public List<String> batchEnsureAccountsRegistered(List<String> imUserIds, String type) {
+        if (CollectionUtils.isEmpty(imUserIds)) {
+            return Collections.emptyList();
+        }
+        String adminToken = getAdminToken();
+        if (StringUtil.isEmpty(adminToken)) {
+            throw new ServiceException("获取管理员token失败");
+        }
+
+        List<String> distinctIds = imUserIds.stream()
+                .filter(StringUtils::isNotEmpty)
+                .distinct()
+                .collect(Collectors.toList());
+        List<String> successIds = new ArrayList<>(distinctIds.size());
+
+        for (int i = 0; i < distinctIds.size(); i += IM_ACCOUNT_CHECK_BATCH_SIZE) {
+            List<String> chunk = distinctIds.subList(i, Math.min(i + IM_ACCOUNT_CHECK_BATCH_SIZE, distinctIds.size()));
+            Set<String> unregistered = queryUnregisteredUserIds(adminToken, chunk);
+            successIds.addAll(chunk.stream()
+                    .filter(id -> !unregistered.contains(id))
+                    .collect(Collectors.toList()));
+            if (!unregistered.isEmpty()) {
+                successIds.addAll(batchRegisterUserIds(adminToken, new ArrayList<>(unregistered), type));
+            }
+        }
+        return successIds.stream().distinct().collect(Collectors.toList());
+    }
+
+    private Set<String> queryUnregisteredUserIds(String adminToken, List<String> imUserIds) {
+        Set<String> unregistered = new HashSet<>();
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("checkUserIDs", imUserIds);
+        String body = HttpRequest.post(IMConfig.URL + "/user/account_check")
+                .header("operationID", String.valueOf(System.currentTimeMillis()))
+                .header("token", adminToken)
+                .body(requestBody.toString())
+                .execute()
+                .body();
+        JSONObject jsonObject = new JSONObject(body);
+        if (jsonObject.optInt("errCode", -1) != 0) {
+            throw new ServiceException("IM账号检测失败:" + jsonObject.optString("errDlt"));
+        }
+        JSONObject data = jsonObject.optJSONObject("data");
+        if (data == null) {
+            throw new ServiceException("IM账号检测返回为空");
+        }
+        JSONArray results = data.optJSONArray("results");
+        if (results == null) {
+            unregistered.addAll(imUserIds);
+            return unregistered;
+        }
+        Set<String> checkedIds = new HashSet<>();
+        for (int i = 0; i < results.length(); i++) {
+            JSONObject resultObj = results.getJSONObject(i);
+            String userId = resultObj.optString("userID");
+            checkedIds.add(userId);
+            if (resultObj.optInt("accountStatus", 0) == 0) {
+                unregistered.add(userId);
+            }
+        }
+        for (String imUserId : imUserIds) {
+            if (!checkedIds.contains(imUserId)) {
+                unregistered.add(imUserId);
+            }
+        }
+        return unregistered;
+    }
+
+    private List<String> batchRegisterUserIds(String adminToken, List<String> imUserIds, String type) {
+        List<String> successIds = new ArrayList<>();
+        for (int i = 0; i < imUserIds.size(); i += IM_USER_REGISTER_BATCH_SIZE) {
+            List<String> chunk = imUserIds.subList(i, Math.min(i + IM_USER_REGISTER_BATCH_SIZE, imUserIds.size()));
+            List<Object> users = buildRegisterUsers(chunk, type);
+            if (users.isEmpty()) {
+                continue;
+            }
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("users", users);
+            String registerBody = HttpRequest.post(IMConfig.URL + "/user/user_register")
+                    .header("operationID", String.valueOf(System.currentTimeMillis()))
+                    .header("token", adminToken)
+                    .body(requestBody.toString())
+                    .execute()
+                    .body();
+            OpenImResponseDTO registerDTO = JSONUtil.toBean(registerBody, OpenImResponseDTO.class);
+            if (registerDTO == null || registerDTO.getErrCode() == null || registerDTO.getErrCode() != 0) {
+                log.error("批量IM注册失败,type={},响应={}", type, registerBody);
+                throw new ServiceException("批量IM注册失败");
+            }
+            for (Object userObj : users) {
+                if (userObj instanceof Map) {
+                    Object userId = ((Map<?, ?>) userObj).get("userID");
+                    if (userId != null) {
+                        successIds.add(String.valueOf(userId));
+                    }
+                }
+            }
+        }
+        return successIds;
+    }
+
+    private List<Object> buildRegisterUsers(List<String> imUserIds, String type) {
+        List<Object> users = new ArrayList<>();
+        if ("1".equals(type)) {
+            List<Long> userIds = new ArrayList<>();
+            for (String imUserId : imUserIds) {
+                try {
+                    userIds.add(Long.parseLong(imUserId.replaceFirst("^U", "")));
+                } catch (NumberFormatException e) {
+                    log.warn("IM用户ID格式错误,跳过注册:{}", imUserId);
+                }
+            }
+            if (userIds.isEmpty()) {
+                return users;
+            }
+            Map<Long, FsUser> userMap = fsUserMapper.selectFsUserListByUserIds(userIds).stream()
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toMap(FsUser::getUserId, Function.identity(), (a, b) -> a));
+            for (String imUserId : imUserIds) {
+                Long userId;
+                try {
+                    userId = Long.parseLong(imUserId.replaceFirst("^U", ""));
+                } catch (NumberFormatException e) {
+                    continue;
+                }
+                FsUser fsUser = userMap.get(userId);
+                if (fsUser == null) {
+                    log.warn("会员不存在,跳过IM注册,userId={}", userId);
+                    continue;
+                }
+                HashMap<String, String> map = new HashMap<>();
+                map.put("userID", imUserId);
+                map.put("nickname", StringUtils.isEmpty(fsUser.getNickName()) ? "微信用户" : fsUser.getNickName());
+                map.put("faceURL", fsUser.getAvatar());
+                users.add(map);
+            }
+            return users;
+        }
+
+        for (String imUserId : imUserIds) {
+            HashMap<String, String> map = buildRegisterUserMap(imUserId, type);
+            if (map != null) {
+                users.add(map);
+            }
+        }
+        return users;
+    }
+
+    private HashMap<String, String> buildRegisterUserMap(String userId, String type) {
+        HashMap<String, String> map = new HashMap<>();
+        String s;
+        switch (type) {
+            case "2":
+                s = userId.replaceFirst("^C", "");
+                CompanyUser companyUser = companyUserMapper.selectCompanyUserByCompanyUserId(Long.parseLong(s));
+                if (companyUser == null) {
+                    log.error("销售用户不存在,跳过IM注册,userId={}", userId);
+                    return null;
+                }
+                map.put("userID", userId);
+                map.put("nickname", companyUser.getNickName());
+                map.put("faceURL", companyUser.getAvatar());
+                return map;
+            case "3":
+                s = userId.replaceFirst("^D", "");
+                FsDoctor fsDoctor = fsDoctorMapper.selectFsDoctorByDoctorId(Long.parseLong(s));
+                if (fsDoctor == null) {
+                    log.error("医生用户不存在,跳过IM注册,userId={}", userId);
+                    return null;
+                }
+                map.put("userID", userId);
+                map.put("nickname", fsDoctor.getDoctorName());
+                map.put("faceURL", fsDoctor.getAvatar());
+                return map;
+            default:
+                return null;
+        }
+    }
+
 //    @Async
     @Override
     public void checkAndImportFriend(Long companyUserId,String fsUserId) {

+ 1 - 1
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -461,6 +461,6 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     /**
      * 清除企微用户的server_id和ipad在线状态
      */
-    @Update("UPDATE qw_user SET server_id = NULL, ipad_status = NULL WHERE id = #{id}")
+    @Update("UPDATE qw_user SET server_id = NULL, ipad_status = NULL,server_status = NULL WHERE id = #{id}")
     int clearServerInfo(@Param("id") Long id);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/store/param/h5/FsUserPageListParam.java

@@ -2,6 +2,7 @@ package com.fs.store.param.h5;
 
 
 import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
@@ -39,6 +40,7 @@ public class FsUserPageListParam implements Serializable {
     private String registerEndTime;
 
     @ApiModelProperty(value = "标签")
+    @JsonDeserialize(using = TagIdsStringArrayDeserializer.class)
     private String[] tagIds;
 
     @ApiModelProperty(value = "tab序号,0全部;1今日新增;2今日完播;3未看过课")

+ 47 - 0
fs-service/src/main/java/com/fs/store/param/h5/TagIdsStringArrayDeserializer.java

@@ -0,0 +1,47 @@
+package com.fs.store.param.h5;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fs.common.utils.StringUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * tagIds 兼容反序列化:支持字符串/数字数组;忽略误传的对象(如选中会员行)
+ */
+public class TagIdsStringArrayDeserializer extends JsonDeserializer<String[]> {
+
+    @Override
+    public String[] deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+        JsonNode node = parser.getCodec().readTree(parser);
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isTextual()) {
+            String text = node.asText();
+            if (StringUtils.isBlank(text)) {
+                return null;
+            }
+            return text.split(",");
+        }
+        if (!node.isArray()) {
+            return null;
+        }
+        List<String> ids = new ArrayList<>();
+        for (JsonNode item : node) {
+            if (item == null || item.isNull()) {
+                continue;
+            }
+            if (item.isTextual() || item.isNumber()) {
+                ids.add(item.asText());
+            } else if (item.isObject() && item.hasNonNull("tagId")) {
+                ids.add(item.get("tagId").asText());
+            }
+        }
+        return ids.isEmpty() ? null : ids.toArray(new String[0]);
+    }
+}

+ 2 - 2
fs-service/src/main/resources/application-config-druid-yjb.yml

@@ -66,8 +66,8 @@ fs :
   jwt:
     # 加密秘钥
     secret: 7af48cb753c96945816b5fdaaa01e879
-    # token有效时长,7天,单位秒
-    expire: 31536000
+    # App端 JWT 有效时长,7天,单位秒
+    expire: 604800
     header: AppToken
 nuonuo:
   key: 10924508

+ 9 - 2
fs-service/src/main/resources/application-druid-yjb.yml

@@ -144,14 +144,21 @@ rocketmq:
         group: test-group
         access-key: ak1243b25nj17d4b2dc1a03 # 替换为实际的 accessKey
         secret-key: sk08a7ea1f9f4b0237 # 替换为实际的 secretKey
-# token配置
+# token配置(公司Web后台 fs-company 使用,App端不走此配置)
 token:
   # 令牌自定义标识
   header: Authorization
   # 令牌密钥
   secret: feeb79c778c1274dd0e4a709cd948718
-  # 令牌有效期(默认30分钟)
+  # 令牌有效期(分钟),Web后台会话;App端见 fs.jwt.expire
   expireTime: 180
+# App端 JWT 配置(fs-company-app / fs-user-app 使用)
+fs:
+  jwt:
+    secret: 7af48cb753c96945816b5fdaaa01e879
+    # 7天,单位秒
+    expire: 604800
+    header: AppToken
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: false
 openIM:

+ 30 - 0
fs-service/src/main/resources/db/fs_store_promotion_tier_type.sql

@@ -0,0 +1,30 @@
+-- ============================================================
+-- 阶梯满减活动 - 新增阶梯类型字段 tier_type(增量,可重复执行)
+-- 请先 USE 业务库再执行
+-- ============================================================
+
+SET @db_name = DATABASE();
+
+SET @sql_add_tier_type = (
+  SELECT IF(
+    EXISTS(
+      SELECT 1 FROM information_schema.COLUMNS
+      WHERE TABLE_SCHEMA = @db_name
+        AND TABLE_NAME = 'fs_store_promotion_activity'
+        AND COLUMN_NAME = 'tier_type'
+    ),
+    'SELECT ''skip: tier_type exists'' AS msg',
+    'ALTER TABLE `fs_store_promotion_activity` ADD COLUMN `tier_type` TINYINT(2) NOT NULL DEFAULT 1 COMMENT ''阶梯类型:1金额 2折扣'' AFTER `scope_type`'
+  )
+);
+PREPARE stmt FROM @sql_add_tier_type;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `fs_store_promotion_activity`
+SET `tier_type` = 1
+WHERE `tier_type` IS NULL OR `tier_type` = 0;
+
+ALTER TABLE `fs_store_promotion_tier`
+  MODIFY COLUMN `threshold_amount` DECIMAL(10,2) NOT NULL COMMENT '门槛金额(元)',
+  MODIFY COLUMN `discount_amount` DECIMAL(10,2) NOT NULL COMMENT '减扣金额(元)/折扣(折),含义由活动 tier_type 决定';

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

@@ -164,6 +164,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select u.*, d.dept_name, d.leader from company_user u
         left join company_dept d on u.dept_id = d.dept_id
         where u.del_flag = '0'
+        <if test="userId != null">
+            AND u.user_id = #{userId}
+        </if>
         <if test="userName != null and userName != ''">
             AND u.user_name like concat( #{userName}, '%')
         </if>

+ 143 - 0
fs-service/src/main/resources/mapper/his/FsImFriendshipMapper.xml

@@ -27,6 +27,141 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="status != null "> and status = #{status}</if>
         </where>
     </select>
+
+    <resultMap type="com.fs.his.vo.FsImFriendshipBindListVO" id="FsImFriendshipBindListVOResult">
+        <result property="id" column="id"/>
+        <result property="userId" column="user_id"/>
+        <result property="userNickName" column="user_nick_name"/>
+        <result property="userAvatar" column="user_avatar"/>
+        <result property="companyId" column="company_id"/>
+        <result property="companyName" column="company_name"/>
+        <result property="companyUserId" column="company_user_id"/>
+        <result property="companyUserNickName" column="company_user_nick_name"/>
+        <result property="status" column="status"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <select id="selectFsImFriendshipBindList" parameterType="com.fs.his.param.FsImFriendshipBindListParam"
+            resultMap="FsImFriendshipBindListVOResult">
+        select
+            f.id,
+            f.user_id,
+            fu.nick_name as user_nick_name,
+            fu.avatar as user_avatar,
+            cu.company_id,
+            c.company_name,
+            f.company_user_id,
+            cu.nick_name as company_user_nick_name,
+            f.status,
+            f.create_time
+        from fs_im_friendship f
+        inner join fs_user fu on fu.user_id = f.user_id
+        inner join company_user cu on cu.user_id = f.company_user_id
+        inner join company c on c.company_id = cu.company_id
+        <where>
+            <if test="currentCompanyId != null">
+                and cu.company_id = #{currentCompanyId}
+            </if>
+            <if test="currentCompanyUserId != null">
+                and f.company_user_id = #{currentCompanyUserId}
+            </if>
+            <if test="status != null">
+                and f.status = #{status}
+            </if>
+            <if test="userId != null and userId != ''">
+                and cast(fu.user_id as char) like concat('%', #{userId}, '%')
+            </if>
+            <if test="userNickName != null and userNickName != ''">
+                and fu.nick_name like concat('%', #{userNickName}, '%')
+            </if>
+            <if test="companyId != null and companyId != ''">
+                and cast(cu.company_id as char) like concat('%', #{companyId}, '%')
+            </if>
+            <if test="companyName != null and companyName != ''">
+                and c.company_name like concat('%', #{companyName}, '%')
+            </if>
+            <if test="companyUserId != null and companyUserId != ''">
+                and cast(f.company_user_id as char) like concat('%', #{companyUserId}, '%')
+            </if>
+            <if test="companyUserNickName != null and companyUserNickName != ''">
+                and cu.nick_name like concat('%', #{companyUserNickName}, '%')
+            </if>
+        </where>
+        order by f.create_time desc
+    </select>
+
+    <resultMap type="com.fs.his.vo.FsImFriendshipBindUserVO" id="FsImFriendshipBindUserVOResult">
+        <result property="userId" column="user_id"/>
+        <result property="nickName" column="nick_name"/>
+        <result property="avatar" column="avatar"/>
+    </resultMap>
+
+    <select id="selectBindUserList" parameterType="com.fs.his.param.FsImFriendshipBindUserQueryParam"
+            resultMap="FsImFriendshipBindUserVOResult">
+        select distinct fu.user_id, fu.nick_name, fu.avatar
+        from fs_user fu
+        <where>
+            fu.is_del = 0
+            <if test="userId != null">
+                and fu.user_id = #{userId}
+            </if>
+        </where>
+        order by fu.user_id desc
+    </select>
+
+    <resultMap type="com.fs.his.vo.FsImFriendshipBindExternalVO" id="FsImFriendshipBindExternalVOResult">
+        <result property="id" column="id"/>
+        <result property="name" column="name"/>
+        <result property="avatar" column="avatar"/>
+        <result property="externalUserId" column="external_user_id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="companyName" column="company_name"/>
+        <result property="companyUserId" column="company_user_id"/>
+        <result property="companyUserNickName" column="company_user_nick_name"/>
+        <result property="corpName" column="corp_name"/>
+    </resultMap>
+
+    <select id="selectBindExternalContactList" parameterType="com.fs.his.param.FsImFriendshipBindExternalQueryParam"
+            resultMap="FsImFriendshipBindExternalVOResult">
+        select distinct
+            ec.id,
+            ec.name,
+            ec.avatar,
+            ec.external_user_id,
+            ec.company_id,
+            c.company_name,
+            ec.company_user_id,
+            cu.nick_name as company_user_nick_name,
+            qc.corp_name
+        from qw_external_contact ec
+        left join company c on c.company_id = ec.company_id
+        left join company_user cu on cu.user_id = ec.company_user_id
+        left join qw_company qc on qc.corp_id = ec.corp_id
+        <where>
+            <if test="currentCompanyId != null">
+                and ec.company_id = #{currentCompanyId}
+            </if>
+            <if test="currentCompanyUserId != null">
+                and ec.company_user_id = #{currentCompanyUserId}
+            </if>
+            <if test="id != null and id != ''">
+                and cast(ec.id as char) like concat('%', #{id}, '%')
+            </if>
+            <if test="name != null and name != ''">
+                and ec.name like concat('%', #{name}, '%')
+            </if>
+            <if test="companyName != null and companyName != ''">
+                and c.company_name like concat('%', #{companyName}, '%')
+            </if>
+            <if test="companyUserNickName != null and companyUserNickName != ''">
+                and cu.nick_name like concat('%', #{companyUserNickName}, '%')
+            </if>
+            <if test="corpName != null and corpName != ''">
+                and qc.corp_name like concat('%', #{corpName}, '%')
+            </if>
+        </where>
+        order by ec.id desc
+    </select>
     
     <select id="selectFsImFriendshipById" parameterType="String" resultMap="FsImFriendshipResult">
         <include refid="selectFsImFriendshipVo"/>
@@ -55,6 +190,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
     </insert>
 
+    <insert id="batchInsertFsImFriendship">
+        insert into fs_im_friendship (user_id, company_user_id, create_time, status)
+        values
+        <foreach collection="list" item="item" separator=",">
+            (#{item.userId}, #{item.companyUserId}, #{item.createTime}, #{item.status})
+        </foreach>
+    </insert>
+
     <update id="updateFsImFriendship" parameterType="FsImFriendship">
         update fs_im_friendship
         <trim prefix="SET" suffixOverrides=",">

+ 8 - 0
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -2321,4 +2321,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <update id="updatePasswordByPhone">
         update fs_user set password = #{password} where phone = #{encryptPhone}
     </update>
+
+    <select id="selectFsUserListByUserIds" resultType="com.fs.his.domain.FsUser">
+        select user_id, nick_name, nickname, avatar, phone from fs_user where
+        user_id in
+        <foreach item="item" collection="userIds" index="index" separator="," close=")" open="(">
+            #{item}
+        </foreach>
+    </select>
 </mapper>

+ 10 - 1
fs-service/src/main/resources/mapper/hisStore/FsStoreOrderScrmMapper.xml

@@ -26,6 +26,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="deductionPrice"    column="deduction_price"    />
         <result property="couponId"    column="coupon_id"    />
         <result property="couponPrice"    column="coupon_price"    />
+        <result property="promotionActivityId"    column="promotion_activity_id"    />
+        <result property="promotionTierId"    column="promotion_tier_id"    />
+        <result property="promotionDiscountAmount"    column="promotion_discount_amount"    />
         <result property="paid"    column="paid"    />
         <result property="payTime"    column="pay_time"    />
         <result property="payType"    column="pay_type"    />
@@ -97,7 +100,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectFsStoreOrderVo">
-        select id, order_code,service_fee, extend_order_id,pay_order_id,bank_order_id, user_id,order_visit, real_name, user_phone, user_address, cart_id, freight_price, total_num, total_price, total_postage, pay_price, pay_postage,pay_delivery,pay_money, deduction_price, coupon_id, coupon_price, paid, pay_time, pay_type, create_time, update_time, status, refund_status, refund_reason_wap_img, refund_reason_wap_explain, refund_reason_time, refund_reason_wap, refund_reason, refund_price, delivery_sn, delivery_name, delivery_type, delivery_id, gain_integral, use_integral, pay_integral, back_integral, mark, is_del, remark, cost, verify_code, store_id, shipping_type, is_channel, is_remind, is_sys_del,is_prescribe,prescribe_id ,company_id,company_user_id,is_package,package_json,item_json,order_type,package_id,finish_time,delivery_status,delivery_pay_status,delivery_time,delivery_pay_time,delivery_pay_money,tui_money,tui_money_status,delivery_import_time,tui_user_id,tui_user_money_status,order_create_type,store_house_code,dept_id,is_edit_money,customer_id,is_pay_remain,delivery_send_time,certificates,schedule_id,combination_order_id,batch_number,is_divide,is_settled,is_divided from fs_store_order_scrm
+        select id, order_code,service_fee, extend_order_id,pay_order_id,bank_order_id, user_id,order_visit, real_name, user_phone, user_address, cart_id, freight_price, total_num, total_price, total_postage, pay_price, pay_postage,pay_delivery,pay_money, deduction_price, coupon_id, coupon_price, promotion_activity_id, promotion_tier_id, promotion_discount_amount, paid, pay_time, pay_type, create_time, update_time, status, refund_status, refund_reason_wap_img, refund_reason_wap_explain, refund_reason_time, refund_reason_wap, refund_reason, refund_price, delivery_sn, delivery_name, delivery_type, delivery_id, gain_integral, use_integral, pay_integral, back_integral, mark, is_del, remark, cost, verify_code, store_id, shipping_type, is_channel, is_remind, is_sys_del,is_prescribe,prescribe_id ,company_id,company_user_id,is_package,package_json,item_json,order_type,package_id,finish_time,delivery_status,delivery_pay_status,delivery_time,delivery_pay_time,delivery_pay_money,tui_money,tui_money_status,delivery_import_time,tui_user_id,tui_user_money_status,order_create_type,store_house_code,dept_id,is_edit_money,customer_id,is_pay_remain,delivery_send_time,certificates,schedule_id,combination_order_id,batch_number,is_divide,is_settled,is_divided from fs_store_order_scrm
     </sql>
 
     <select id="selectFsStoreOrderList" parameterType="FsStoreOrderScrm" resultMap="FsStoreOrderResult">
@@ -189,6 +192,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="deductionPrice != null">deduction_price,</if>
             <if test="couponId != null">coupon_id,</if>
             <if test="couponPrice != null">coupon_price,</if>
+            <if test="promotionActivityId != null">promotion_activity_id,</if>
+            <if test="promotionTierId != null">promotion_tier_id,</if>
+            <if test="promotionDiscountAmount != null">promotion_discount_amount,</if>
             <if test="paid != null">paid,</if>
             <if test="payTime != null">pay_time,</if>
             <if test="payType != null and payType != ''">pay_type,</if>
@@ -280,6 +286,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="deductionPrice != null">#{deductionPrice},</if>
             <if test="couponId != null">#{couponId},</if>
             <if test="couponPrice != null">#{couponPrice},</if>
+            <if test="promotionActivityId != null">#{promotionActivityId},</if>
+            <if test="promotionTierId != null">#{promotionTierId},</if>
+            <if test="promotionDiscountAmount != null">#{promotionDiscountAmount},</if>
             <if test="paid != null">#{paid},</if>
             <if test="payTime != null">#{payTime},</if>
             <if test="payType != null and payType != ''">#{payType},</if>

+ 165 - 0
fs-service/src/main/resources/mapper/hisStore/FsStorePromotionActivityMapper.xml

@@ -0,0 +1,165 @@
+<?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.hisStore.mapper.FsStorePromotionActivityMapper">
+
+    <resultMap type="com.fs.hisStore.domain.FsStorePromotionActivity" id="FsStorePromotionActivityResult">
+        <result property="id" column="id"/>
+        <result property="title" column="title"/>
+        <result property="storeId" column="store_id"/>
+        <result property="storeName" column="store_name"/>
+        <result property="startTime" column="start_time"/>
+        <result property="activityEndTime" column="end_time"/>
+        <result property="scopeType" column="scope_type"/>
+        <result property="tierType" column="tier_type"/>
+        <result property="isStackable" column="is_stackable"/>
+        <result property="isCapped" column="is_capped"/>
+        <result property="limitPerUser" column="limit_per_user"/>
+        <result property="status" column="status"/>
+        <result property="manualStatus" column="manual_status"/>
+        <result property="version" column="version"/>
+        <result property="activityRemark" column="remark"/>
+        <result property="isDel" column="is_del"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="displayStatus" column="display_status"/>
+        <result property="tierCount" column="tier_count"/>
+    </resultMap>
+
+    <sql id="displayStatusCase">
+        CASE
+            WHEN a.manual_status IS NULL THEN 0
+            WHEN a.manual_status = 0 THEN 4
+            WHEN a.manual_status = 1 AND NOW() &lt; a.start_time THEN 1
+            WHEN a.manual_status = 1 AND NOW() &gt;= a.start_time AND NOW() &lt;= a.end_time THEN 2
+            WHEN a.manual_status = 1 AND NOW() &gt; a.end_time THEN 3
+            ELSE 0
+        END
+    </sql>
+
+    <select id="selectFsStorePromotionActivityById" resultMap="FsStorePromotionActivityResult">
+        SELECT a.*, s.store_name,
+               (<include refid="displayStatusCase"/>) AS display_status,
+               IFNULL(tc.tier_count, 0) AS tier_count
+        FROM fs_store_promotion_activity a
+        LEFT JOIN fs_store_scrm s ON s.store_id = a.store_id
+        LEFT JOIN (
+            SELECT activity_id, COUNT(1) AS tier_count
+            FROM fs_store_promotion_tier
+            GROUP BY activity_id
+        ) tc ON tc.activity_id = a.id
+        WHERE a.id = #{id} AND a.is_del = 0
+    </select>
+
+    <select id="selectFsStorePromotionActivityList" resultMap="FsStorePromotionActivityResult">
+        SELECT a.*, s.store_name,
+               (<include refid="displayStatusCase"/>) AS display_status,
+               IFNULL(tc.tier_count, 0) AS tier_count
+        FROM fs_store_promotion_activity a
+        LEFT JOIN fs_store_scrm s ON s.store_id = a.store_id
+        LEFT JOIN (
+            SELECT activity_id, COUNT(1) AS tier_count
+            FROM fs_store_promotion_tier
+            GROUP BY activity_id
+        ) tc ON tc.activity_id = a.id
+        <where>
+            a.is_del = 0
+            <if test="title != null and title != ''">
+                AND a.title LIKE concat('%', #{title}, '%')
+            </if>
+            <if test="storeId != null">
+                AND a.store_id = #{storeId}
+            </if>
+            <if test="scopeType != null">
+                AND a.scope_type = #{scopeType}
+            </if>
+            <if test="displayStatus != null">
+                AND (<include refid="displayStatusCase"/>) = #{displayStatus}
+            </if>
+            <if test="params != null and params.beginTime != null and params.beginTime != ''">
+                AND a.end_time &gt;= #{params.beginTime}
+            </if>
+            <if test="params != null and params.endTime != null and params.endTime != ''">
+                AND a.start_time &lt;= #{params.endTime}
+            </if>
+        </where>
+        ORDER BY a.id DESC
+    </select>
+
+    <insert id="insertFsStorePromotionActivity" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO fs_store_promotion_activity
+        (title, store_id, start_time, end_time, scope_type, tier_type, is_stackable, is_capped, limit_per_user,
+         status, manual_status, version, remark, is_del, create_by, create_time, update_by, update_time)
+        VALUES
+        (#{title}, #{storeId}, #{startTime}, #{activityEndTime}, #{scopeType}, #{tierType}, #{isStackable}, #{isCapped}, #{limitPerUser},
+         #{status}, #{manualStatus}, #{version}, #{activityRemark}, #{isDel}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime})
+    </insert>
+
+    <update id="updateFsStorePromotionActivity">
+        UPDATE fs_store_promotion_activity
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="title != null and title != ''">title = #{title},</if>
+            <if test="storeId != null">store_id = #{storeId},</if>
+            <if test="startTime != null">start_time = #{startTime},</if>
+            <if test="activityEndTime != null">end_time = #{activityEndTime},</if>
+            <if test="scopeType != null">scope_type = #{scopeType},</if>
+            <if test="tierType != null">tier_type = #{tierType},</if>
+            <if test="isStackable != null">is_stackable = #{isStackable},</if>
+            <if test="isCapped != null">is_capped = #{isCapped},</if>
+            <if test="limitPerUser != null">limit_per_user = #{limitPerUser},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="manualStatus != null">manual_status = #{manualStatus},</if>
+            <if test="version != null">version = #{version},</if>
+            <if test="activityRemark != null">remark = #{activityRemark},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </trim>
+        WHERE id = #{id} AND is_del = 0
+    </update>
+
+    <update id="deleteFsStorePromotionActivityByIds">
+        UPDATE fs_store_promotion_activity SET is_del = 1, update_time = NOW()
+        WHERE id IN
+        <foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach>
+    </update>
+
+    <update id="updateManualStatus">
+        UPDATE fs_store_promotion_activity
+        SET manual_status = #{manualStatus}, status = 1, update_time = NOW()
+        WHERE id = #{id} AND is_del = 0
+    </update>
+
+    <update id="expireActivities">
+        UPDATE fs_store_promotion_activity
+        SET status = 3, update_time = NOW()
+        WHERE is_del = 0 AND manual_status = 1 AND status = 1 AND end_time &lt; NOW()
+    </update>
+
+    <select id="countConflictActivity" resultType="int">
+        SELECT COUNT(1) FROM fs_store_promotion_activity
+        WHERE is_del = 0 AND store_id = #{storeId}
+          AND manual_status = 1
+          AND start_time &lt;= #{endTime} AND end_time &gt;= #{startTime}
+        <if test="excludeId != null">AND id != #{excludeId}</if>
+    </select>
+
+    <select id="selectActivePromotionsByStoreId" resultMap="FsStorePromotionActivityResult">
+        SELECT a.*, s.store_name,
+               (<include refid="displayStatusCase"/>) AS display_status,
+               IFNULL(tc.tier_count, 0) AS tier_count
+        FROM fs_store_promotion_activity a
+        LEFT JOIN fs_store_scrm s ON s.store_id = a.store_id
+        LEFT JOIN (
+            SELECT activity_id, COUNT(1) AS tier_count
+            FROM fs_store_promotion_tier
+            GROUP BY activity_id
+        ) tc ON tc.activity_id = a.id
+        WHERE a.is_del = 0
+          AND a.store_id = #{storeId}
+          AND a.manual_status = 1
+          AND a.start_time &lt;= NOW()
+          AND a.end_time &gt;= NOW()
+        ORDER BY a.id DESC
+    </select>
+</mapper>

+ 43 - 0
fs-service/src/main/resources/mapper/hisStore/FsStorePromotionScopeMapper.xml

@@ -0,0 +1,43 @@
+<?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.hisStore.mapper.FsStorePromotionScopeMapper">
+
+    <resultMap type="com.fs.hisStore.domain.FsStorePromotionScope" id="FsStorePromotionScopeResult">
+        <result property="id" column="id"/>
+        <result property="activityId" column="activity_id"/>
+        <result property="scopeType" column="scope_type"/>
+        <result property="targetId" column="target_id"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <select id="selectByActivityId" resultMap="FsStorePromotionScopeResult">
+        SELECT * FROM fs_store_promotion_scope WHERE activity_id = #{activityId}
+    </select>
+
+    <select id="selectByActivityIds" resultMap="FsStorePromotionScopeResult">
+        SELECT id, activity_id, scope_type, target_id, create_time
+        FROM fs_store_promotion_scope
+        WHERE activity_id IN
+        <foreach collection="activityIds" item="activityId" open="(" separator="," close=")">
+            #{activityId}
+        </foreach>
+        ORDER BY activity_id ASC, id ASC
+    </select>
+
+    <insert id="insertFsStorePromotionScope" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO fs_store_promotion_scope (activity_id, scope_type, target_id, create_time)
+        VALUES (#{activityId}, #{scopeType}, #{targetId}, NOW())
+    </insert>
+
+    <insert id="batchInsertFsStorePromotionScope">
+        INSERT INTO fs_store_promotion_scope (activity_id, scope_type, target_id, create_time)
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.activityId}, #{item.scopeType}, #{item.targetId}, NOW())
+        </foreach>
+    </insert>
+
+    <delete id="deleteByActivityId">
+        DELETE FROM fs_store_promotion_scope WHERE activity_id = #{activityId}
+    </delete>
+</mapper>

+ 50 - 0
fs-service/src/main/resources/mapper/hisStore/FsStorePromotionTierMapper.xml

@@ -0,0 +1,50 @@
+<?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.hisStore.mapper.FsStorePromotionTierMapper">
+
+    <resultMap type="com.fs.hisStore.domain.FsStorePromotionTier" id="FsStorePromotionTierResult">
+        <result property="id" column="id"/>
+        <result property="activityId" column="activity_id"/>
+        <result property="sortOrder" column="sort_order"/>
+        <result property="thresholdAmount" column="threshold_amount"/>
+        <result property="discountAmount" column="discount_amount"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <select id="selectByActivityId" resultMap="FsStorePromotionTierResult">
+        SELECT * FROM fs_store_promotion_tier WHERE activity_id = #{activityId} ORDER BY sort_order ASC
+    </select>
+
+    <insert id="insertFsStorePromotionTier" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO fs_store_promotion_tier (activity_id, sort_order, threshold_amount, discount_amount, create_time, update_time)
+        VALUES (#{activityId}, #{sortOrder}, #{thresholdAmount}, #{discountAmount}, NOW(), NOW())
+    </insert>
+
+    <insert id="batchInsertFsStorePromotionTier">
+        INSERT INTO fs_store_promotion_tier (activity_id, sort_order, threshold_amount, discount_amount, create_time, update_time)
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.activityId}, #{item.sortOrder}, #{item.thresholdAmount}, #{item.discountAmount}, NOW(), NOW())
+        </foreach>
+    </insert>
+
+    <delete id="deleteByActivityId">
+        DELETE FROM fs_store_promotion_tier WHERE activity_id = #{activityId}
+    </delete>
+
+    <select id="selectByActivityIds" resultMap="FsStorePromotionTierResult">
+        SELECT
+        t.id,
+        t.activity_id,
+        t.sort_order,
+        t.threshold_amount,
+        t.discount_amount
+        FROM
+        fs_store_promotion_tier t  WHERE t.activity_id IN
+        <foreach item="item" collection="activityIds" separator="," close=")" open="(">
+            #{item}
+        </foreach>
+        ORDER BY sort_order ASC
+    </select>
+</mapper>

+ 41 - 0
fs-service/src/main/resources/mapper/hisStore/FsStorePromotionUsageMapper.xml

@@ -0,0 +1,41 @@
+<?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.hisStore.mapper.FsStorePromotionUsageMapper">
+
+    <select id="countEffectiveByActivityAndUser" resultType="int">
+        SELECT COUNT(1)
+        FROM fs_store_promotion_usage
+        WHERE activity_id = #{activityId}
+          AND user_id = #{userId}
+          AND usage_status = 1
+    </select>
+
+    <select id="countEffectiveByActivityIdsAndUser"
+            resultType="com.fs.hisStore.dto.FsStorePromotionUsageCountDTO">
+        SELECT activity_id AS activityId, COUNT(1) AS usedCount
+        FROM fs_store_promotion_usage
+        WHERE user_id = #{userId}
+          AND usage_status = 1
+          AND activity_id IN
+        <foreach collection="activityIds" item="activityId" open="(" separator="," close=")">
+            #{activityId}
+        </foreach>
+        GROUP BY activity_id
+    </select>
+
+    <insert id="insertFsStorePromotionUsage" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO fs_store_promotion_usage
+        (activity_id, user_id, order_id, order_amount, discount_amount, tier_id, usage_status, usage_time, create_time)
+        VALUES
+        (#{activityId}, #{userId}, #{orderId}, #{orderAmount}, #{discountAmount}, #{tierId}, #{usageStatus}, #{usageTime}, #{createTime})
+    </insert>
+
+    <update id="updateUsageStatusByOrderId">
+        UPDATE fs_store_promotion_usage
+        SET usage_status = #{usageStatus}
+        WHERE order_id = #{orderId}
+        <if test="fromStatus != null">
+            AND usage_status = #{fromStatus}
+        </if>
+    </update>
+</mapper>

+ 6 - 5
fs-user-app/src/main/java/com/fs/app/controller/store/StoreOrderScrmController.java

@@ -12,6 +12,7 @@ import com.alibaba.fastjson.TypeReference;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fs.app.annotation.Login;
 import com.fs.app.controller.AppBaseController;
+import com.fs.app.service.StoreOrderScrmAmountService;
 import com.fs.app.pay.constant.PaymentTypeConstant;
 import com.fs.common.core.domain.R;
 import com.fs.common.exception.CustomException;
@@ -113,6 +114,9 @@ public class StoreOrderScrmController extends AppBaseController {
     @Autowired
     private IFsStoreOrderScrmService orderService;
 
+    @Autowired
+    private StoreOrderScrmAmountService storeOrderAmountService;
+
     @Autowired
     private FsStoreOrderScrmMapper fsStoreOrderMapper;
 
@@ -357,18 +361,15 @@ public class StoreOrderScrmController extends AppBaseController {
     @ApiOperation("计算订单金额")
     @PostMapping("/computed")
     public R computed(@Validated @RequestBody FsStoreOrderComputedParam param, HttpServletRequest request){
-        FsStoreOrderComputeDTO dto=orderService.computedOrder(Long.parseLong(getUserId()),param);
+        FsStoreOrderComputeDTO dto = storeOrderAmountService.computedOrder(Long.parseLong(getUserId()), param);
         return R.ok().put("data",dto);
     }
 
     @Login
     @ApiOperation("计算订单金额")
     @PostMapping("/computedMultiStore")
-//    public R computed(@Validated @RequestBody FsStoreOrderComputedParam param, HttpServletRequest request){
     public R computedMultiStore(@Validated @RequestBody FsStoreOrderComputedGroupStoreParam param, HttpServletRequest request) {
-//        FsStoreOrderComputeDTO dto=orderService.computedOrder(Long.parseLong(getUserId()),param);
-//        return R.ok().put("data",dto);
-        List<FsStoreOrderComputeDTO> dtos = orderService.computedOrders(Long.parseLong(getUserId()), param);
+        List<FsStoreOrderComputeDTO> dtos = storeOrderAmountService.computedOrders(Long.parseLong(getUserId()), param);
         return R.ok().put("data", dtos);
     }
 

+ 28 - 0
fs-user-app/src/main/java/com/fs/app/service/StoreOrderScrmAmountService.java

@@ -0,0 +1,28 @@
+package com.fs.app.service;
+
+import com.fs.hisStore.dto.FsStoreOrderComputeDTO;
+import com.fs.hisStore.param.FsStoreOrderComputedGroupStoreParam;
+import com.fs.hisStore.param.FsStoreOrderComputedParam;
+import com.fs.hisStore.service.IFsStoreOrderScrmService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 小程序商城订单金额计算入口(满减/折扣在 fs-service computedOrder 内自动匹配,无需传活动 ID)
+ */
+@Service
+public class StoreOrderScrmAmountService {
+
+    @Autowired
+    private IFsStoreOrderScrmService orderService;
+
+    public FsStoreOrderComputeDTO computedOrder(Long userId, FsStoreOrderComputedParam param) {
+        return orderService.computedOrder(userId, param);
+    }
+
+    public List<FsStoreOrderComputeDTO> computedOrders(Long userId, FsStoreOrderComputedGroupStoreParam param) {
+        return orderService.computedOrders(userId, param);
+    }
+}

+ 2 - 1
fs-user-app/src/main/java/com/fs/app/utils/JwtUtils.java

@@ -21,7 +21,8 @@ public class JwtUtils {
 
 
     private String secret;
-    private long expire;
+    /** 默认7天(秒),防止配置未加载时 token 立即过期 */
+    private long expire = 604800L;
     private String header;
 
     /**