Jelajahi Sumber

红包新商户配置/取消skip-auth 放行逻辑

wangxy 16 jam lalu
induk
melakukan
37fc9f4be1
31 mengubah file dengan 1020 tambahan dan 328 penghapusan
  1. 0 3
      env.secrets.example
  2. 43 0
      fs-admin/src/main/java/com/fs/his/controller/FsCompanyController.java
  3. 17 0
      fs-admin/src/main/java/com/fs/his/param/CompanyRedPacketMerchantParam.java
  4. 0 7
      fs-company-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  5. 81 3
      fs-company/src/main/java/com/fs/user/FsUserAdminController.java
  6. 5 0
      fs-service/src/main/java/com/fs/company/domain/Company.java
  7. 3 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  8. 10 0
      fs-service/src/main/java/com/fs/company/service/ICompanyService.java
  9. 26 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java
  10. 5 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVO.java
  11. 18 5
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseCheckinReceiveServiceImpl.java
  12. 72 34
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  13. 5 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserCompanyUserTransferTaskMapper.java
  14. 10 0
      fs-service/src/main/java/com/fs/his/service/IFsStorePaymentService.java
  15. 15 0
      fs-service/src/main/java/com/fs/his/service/IFsUserCompanyUserTransferTaskService.java
  16. 80 1
      fs-service/src/main/java/com/fs/his/service/impl/FsExternalOrderServiceImpl.java
  17. 325 89
      fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java
  18. 221 0
      fs-service/src/main/java/com/fs/his/service/impl/FsUserCompanyUserTransferTaskServiceImpl.java
  19. 0 9
      fs-service/src/main/resources/application-druid-hdt.yml
  20. 2 0
      fs-service/src/main/resources/mapper/company/CompanyMapper.xml
  21. 1 0
      fs-service/src/main/resources/mapper/course/FsCourseRedPacketLogMapper.xml
  22. 6 0
      fs-service/src/main/resources/mapper/his/FsUserUserCompanyTransferTaskMapper.xml
  23. 0 49
      fs-user-app/src/main/java/com/fs/app/config/SkipAuthProperties.java
  24. 44 0
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController2.java
  25. 0 7
      fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  26. 16 0
      fs-user-app/src/main/java/com/fs/app/param/AppWxAuthParam.java
  27. 0 105
      fs-user-app/src/main/java/com/fs/app/utils/SkipAuthTicketUtils.java
  28. 4 15
      fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java
  29. 0 1
      fs-user-app/src/main/java/com/fs/framework/config/SecurityConfig.java
  30. 7 0
      fs-wx-ipad-task/src/main/resources/bat/hdt-6.bat
  31. 4 0
      pom.xml

+ 0 - 3
env.secrets.example

@@ -77,6 +77,3 @@ HDT_NUONUO_SECRET=
 HDT_TENCENT_COS_SECRET_ID=
 HDT_TENCENT_COS_SECRET_KEY=
 HDT_FEISHU_APP_SECRET=
-
-# fs-user-app X-Skip-Auth HMAC secret (C-01)
-SKIP_AUTH_SECRET=

+ 43 - 0
fs-admin/src/main/java/com/fs/his/controller/FsCompanyController.java

@@ -33,8 +33,11 @@ import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.framework.web.service.TokenService;
 import com.fs.his.mapper.FsDoctorMapper;
+import com.fs.his.domain.SysRedpacketConfigMore;
+import com.fs.his.param.CompanyRedPacketMerchantParam;
 import com.fs.his.param.CompanyRedPacketState;
 import com.fs.his.param.CompanyWatchPasswordState;
+import com.fs.his.service.ISysRedpacketConfigMoreService;
 import com.fs.his.vo.OptionsVO;
 import com.fs.system.service.ISysConfigService;
 import org.springframework.boot.context.properties.bind.BindResult;
@@ -89,6 +92,8 @@ public class FsCompanyController extends BaseController {
     private Environment environment;
     @Autowired
     private IFsCoursePlaySourceConfigService playSourceConfigService;
+    @Autowired
+    private ISysRedpacketConfigMoreService sysRedpacketConfigMoreService;
 
     /**
      * 查询诊所管理列表
@@ -457,6 +462,44 @@ public class FsCompanyController extends BaseController {
         return R.ok("修改成功");
     }
 
+    @PreAuthorize("@ss.hasPermi('his:company:edit')")
+    @PostMapping(value = "/changeRedPacketMerchant")
+    @Log(title = "销售公司红包商户配置", businessType = BusinessType.UPDATE)
+    public R changeRedPacketMerchant(@RequestBody CompanyRedPacketMerchantParam params) {
+        if (params == null || params.getCompanyId() == null) {
+            return R.error("参数不能为空");
+        }
+        Long configId = params.getRedPacketConfigId();
+        if (configId != null && configId > 0
+                && sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(configId) == null) {
+            return R.error("商户配置不存在,请先在红包多商户配置中维护");
+        }
+        try {
+            companyService.changeRedPacketMerchant(params.getCompanyId(),
+                    configId != null && configId > 0 ? configId : null);
+            return R.ok("修改成功");
+        } catch (Exception e) {
+            return R.error(e.getMessage() == null ? "修改失败" : e.getMessage());
+        }
+    }
+
+    @PreAuthorize("@ss.hasPermi('his:company:list')")
+    @GetMapping("/redPacketMerchantOptions")
+    public AjaxResult redPacketMerchantOptions() {
+        List<SysRedpacketConfigMore> list = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreList(new SysRedpacketConfigMore());
+        List<Map<String, Object>> options = new ArrayList<>();
+        if (list != null) {
+            for (SysRedpacketConfigMore item : list) {
+                Map<String, Object> option = new HashMap<>();
+                option.put("id", item.getId());
+                option.put("isNew", item.getIsNew());
+                option.put("mchId", item.getMchId());
+                options.add(option);
+            }
+        }
+        return AjaxResult.success(options);
+    }
+
     @PreAuthorize("@ss.hasPermi('his:company:edit')")
     @PostMapping(value = "/changeWatchPasswordEnabled")
     @Log(title = "销售公司口令看课开关", businessType = BusinessType.UPDATE)

+ 17 - 0
fs-admin/src/main/java/com/fs/his/param/CompanyRedPacketMerchantParam.java

@@ -0,0 +1,17 @@
+package com.fs.his.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+@Data
+public class CompanyRedPacketMerchantParam {
+
+    @NotNull(message = "公司ID不能为空")
+    @ApiModelProperty("销售公司ID")
+    private Long companyId;
+
+    @ApiModelProperty("红包多商户配置id,空则走全局当前商户")
+    private Long redPacketConfigId;
+}

+ 0 - 7
fs-company-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -26,7 +26,6 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
     @Autowired
     RedisCache redisCache;
     public static final String USER_KEY = "userId";
-    private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
@@ -42,12 +41,6 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
-        // 业务场景:请求头存在跳过认证标识,直接放行
-        String skipAuth = request.getHeader(SKIP_AUTH_HEADER);
-        if (StringUtils.isNotEmpty(skipAuth)) {
-            return true;
-        }
-
         //获取用户凭证
         String token = request.getHeader(jwtUtils.getHeader());
         if(StringUtils.isBlank(token)){

+ 81 - 3
fs-company/src/main/java/com/fs/user/FsUserAdminController.java

@@ -8,8 +8,10 @@ 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.exception.ServiceException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.cache.ICompanyUserCacheService;
 import com.fs.course.domain.FsUserCompanyUser;
 import com.fs.course.dto.BatchSendCourseDTO;
@@ -18,14 +20,17 @@ import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.course.service.IFsUserCourseService;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
-
 import com.fs.his.domain.FsExportTask;
 import com.fs.his.domain.FsUser;
+import com.fs.his.dto.FsUserTransferImportDTO;
 import com.fs.his.service.IFsExportTaskService;
+import com.fs.his.service.IFsUserCompanyUserTransferTaskService;
 import com.fs.his.service.IFsUserProjectTagService;
 import com.fs.his.service.IFsUserService;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.his.utils.RandomUtil;
+import com.fs.his.vo.FsUserCompanyUserTransferTaskDetailVO;
+import com.fs.his.vo.FsUserCompanyUserTransferTaskVO;
 import com.fs.im.dto.OpenImResponseDTO;
 import com.fs.im.service.OpenIMService;
 import com.fs.qw.domain.CustomerTransferApproval;
@@ -42,10 +47,10 @@ import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.Date;
-
-import static com.fs.his.utils.PhoneUtil.encryptPhone;
+import java.util.List;
 
 @Api(tags = "会员管理接口")
 @RestController
@@ -80,6 +85,9 @@ public class FsUserAdminController extends BaseController {
     @Autowired
     private IFsUserProjectTagService fsUserProjectTagService;
 
+    @Autowired
+    private IFsUserCompanyUserTransferTaskService userTransferTaskService;
+
     @PreAuthorize("@ss.hasPermi('user:fsUser:list')")
     @PostMapping("/list")
     @ApiOperation("会员列表(与移动端使用的相同查询)")
@@ -204,6 +212,76 @@ public class FsUserAdminController extends BaseController {
         return R.ok("转移申请已经提交,等待转移成功!");
     }
 
+    @ApiOperation("下载会员转移导入模板")
+    @PreAuthorize("@ss.hasPermi('company:user:transfer')")
+    @GetMapping("/importTemplate")
+    public AjaxResult importTemplate() {
+        ExcelUtil<FsUserTransferImportDTO> util = new ExcelUtil<>(FsUserTransferImportDTO.class);
+        return util.importTemplateExcel("会员转移模板");
+    }
+
+    @ApiOperation("转移导入会员数据(仅本司)")
+    @Log(title = "转移导入会员数据", businessType = BusinessType.IMPORT)
+    @PreAuthorize("@ss.hasPermi('company:user:transfer')")
+    @PostMapping("/importData")
+    public AjaxResult importData(MultipartFile file) {
+        ExcelUtil<FsUserTransferImportDTO> util = new ExcelUtil<>(FsUserTransferImportDTO.class);
+        List<FsUserTransferImportDTO> list;
+        try {
+            list = util.importExcel(file.getInputStream());
+        } catch (Exception e) {
+            throw new ServiceException("读取会员转移数据失败");
+        }
+        if (list == null || list.isEmpty()) {
+            throw new ServiceException("会员转移数据不能为空");
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        userTransferTaskService.transferImportDataForCompany(
+                list,
+                loginUser.getUser().getUserId(),
+                loginUser.getUser().getUserName(),
+                companyId);
+        return AjaxResult.success();
+    }
+
+    @ApiOperation("转移导入任务列表(仅本人提交)")
+    @PreAuthorize("@ss.hasPermi('company:user:transfer')")
+    @GetMapping("/transferTaskList")
+    public TableDataInfo transferTaskList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        startPage();
+        List<FsUserCompanyUserTransferTaskVO> list =
+                userTransferTaskService.getTransferTaskListBySubmitUserId(loginUser.getUser().getUserId());
+        return getDataTable(list);
+    }
+
+    @ApiOperation("转移导入任务详情")
+    @PreAuthorize("@ss.hasPermi('company:user:transfer')")
+    @GetMapping("/transferTaskDetails/{taskId}")
+    public TableDataInfo transferTaskDetails(@PathVariable Long taskId) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (!userTransferTaskService.isTaskOwnedBySubmitUser(taskId, loginUser.getUser().getUserId())) {
+            throw new ServiceException("无权查看该任务");
+        }
+        startPage();
+        List<FsUserCompanyUserTransferTaskDetailVO> list = userTransferTaskService.getAllTransferTaskDetails(taskId);
+        return getDataTable(list);
+    }
+
+    @ApiOperation("导出转移失败数据")
+    @PreAuthorize("@ss.hasPermi('company:user:transfer')")
+    @GetMapping("/downloadFailedData/{taskId}")
+    public AjaxResult downloadFailedData(@PathVariable Long taskId) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (!userTransferTaskService.isTaskOwnedBySubmitUser(taskId, loginUser.getUser().getUserId())) {
+            throw new ServiceException("无权下载该任务数据");
+        }
+        List<FsUserTransferImportDTO> list = userTransferTaskService.getTransferFailedData(taskId);
+        ExcelUtil<FsUserTransferImportDTO> util = new ExcelUtil<>(FsUserTransferImportDTO.class);
+        return util.exportExcel(list, "会员转移失败数据");
+    }
+
     /**
      * 获取用户详细信息
      */

+ 5 - 0
fs-service/src/main/java/com/fs/company/domain/Company.java

@@ -149,6 +149,11 @@ public class Company extends BaseEntity
      */
     private Integer openRedPacket;
 
+    /**
+     * 绑定的红包多商户配置id,空则走全局当前商户
+     */
+    private Long redPacketConfigId;
+
     private BigDecimal redPackageMoney;
 
     @TableField(exist = false)

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

@@ -151,6 +151,9 @@ public interface CompanyMapper
     @Update("update company set oms_code =#{omsCode} where company_id =#{companyId} ")
     void updateOmsCodeByCompanyIds(@Param("companyId") Long companyId,@Param("omsCode") String omsCode);
 
+    @Update("update company set red_packet_config_id = #{redPacketConfigId} where company_id = #{companyId}")
+    int updateRedPacketConfigId(@Param("companyId") Long companyId, @Param("redPacketConfigId") Long redPacketConfigId);
+
     @Select("select company_id,company_name from company where oms_code = #{omsCode}")
     List<Long> selectCompanyByOmsCode(@Param("omsCode") String omsCode);
 

+ 10 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyService.java

@@ -180,6 +180,16 @@ public interface ICompanyService
      */
     void changeRedPacketState(Long companyId, Integer state);
 
+    /**
+     * 绑定公司红包商户配置,configId为空则恢复走全局当前商户
+     */
+    void changeRedPacketMerchant(Long companyId, Long redPacketConfigId);
+
+    /**
+     * 公司绑定的红包多商户配置id,未绑定返回null
+     */
+    Long getCompanyRedPacketConfigId(Long companyId);
+
     /**
      * 修改公司口令看课开关
      */

+ 26 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java

@@ -1505,6 +1505,32 @@ public class CompanyServiceImpl implements ICompanyService
         }
     }
 
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void changeRedPacketMerchant(Long companyId, Long redPacketConfigId) {
+        if (companyId == null) {
+            throw new RuntimeException("参数错误");
+        }
+        Company company = companyMapper.selectCompanyById(companyId);
+        if (company == null) {
+            throw new RuntimeException("公司不存在");
+        }
+        companyMapper.updateRedPacketConfigId(companyId, redPacketConfigId);
+    }
+
+    @Override
+    public Long getCompanyRedPacketConfigId(Long companyId) {
+        if (companyId == null) {
+            return null;
+        }
+        Company company = companyMapper.selectCompanyById(companyId);
+        if (company == null) {
+            return null;
+        }
+        Long configId = company.getRedPacketConfigId();
+        return configId != null && configId > 0 ? configId : null;
+    }
+
     /**
      * 修改公司口令看课开关
      */

+ 5 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVO.java

@@ -100,6 +100,11 @@ public class CompanyVO implements Serializable
      */
     private Integer openRedPacket;
 
+    /**
+     * 绑定的红包多商户配置id,空则走全局当前商户
+     */
+    private Long redPacketConfigId;
+
     private BigDecimal redPackageMoney;
 
     private Integer liveShow;

+ 18 - 5
fs-service/src/main/java/com/fs/course/service/impl/FsCourseCheckinReceiveServiceImpl.java

@@ -511,11 +511,24 @@ public class FsCourseCheckinReceiveServiceImpl implements IFsCourseCheckinReceiv
 
             // 4. 准备发送红包参数(从sendRedPacketRewardFsUser方法)
             WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+            boolean useNewMerchant = false;
+            try {
+                useNewMerchant = paymentService.isNewMerchantChannel(
+                        paymentService.resolveActiveRedPacketConfig(config.getRedPacketMode(), receive.getCompanyId()));
+            } catch (Exception e) {
+                logger.warn("打卡红包解析商户通道失败,默认老商户: {}", e.getMessage());
+            }
             packetParam.setOpenId(user.getMpOpenId());
 
-            // 来源是小程序切换openId(从sendRedPacketRewardFsUser方法)
-            if (param.getSource() == 2) {
-                if (user.getMpOpenId() != null) {
+            // App + 新商户:app_open_id;小程序/新商户非App:小程序 openId
+            if (useNewMerchant && param.getSource() != null && param.getSource() == 3) {
+                if (StringUtils.isBlank(user.getAppOpenId())) {
+                    logger.error("App发红包缺少appOpenId,请先授权 - 用户ID: {}", user.getUserId());
+                    return false;
+                }
+                packetParam.setOpenId(user.getAppOpenId());
+            } else if (useNewMerchant || param.getSource() == 2) {
+                if (!useNewMerchant && user.getMpOpenId() != null) {
                     packetParam.setOpenId(user.getMpOpenId());
                 } else {
                     //查询是否绑定小程序
@@ -528,8 +541,8 @@ public class FsCourseCheckinReceiveServiceImpl implements IFsCourseCheckinReceiv
                     }
                 }
             }
-            //判断服务号配置是否存在
-            if (StringUtils.isNotEmpty(config.getMpAppId())) {
+            //判断服务号配置是否存在(仅老商户)
+            if (!useNewMerchant && StringUtils.isNotEmpty(config.getMpAppId())) {
                 packetParam.setMpAppId(config.getMpAppId());
             }
             // 5. 设置基本参数(从sendRedPacketRewardFsUser方法)

+ 72 - 34
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -30,6 +30,7 @@ import com.fs.company.util.CompanyRedPacketBalanceUtil;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.config.RedPacketConfig;
 import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePackageDTO;
@@ -1577,21 +1578,14 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
         // 准备发送红包参数
         WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
 
-        //判断是否走服务号openId发红包
-        if (config.getMiniAppAuthType() == 2 && user.getMpOpenId() != null && !isNewWxMerchant) {
-            packetParam.setOpenId(user.getMpOpenId());
-        } else {
-            //查询是否绑定小程序
-            FsUserWx fsUserWx = fsUserWxService.selectByAppIdAndUserId(param.getAppId(), user.getUserId(), 1);
-            if (fsUserWx == null) {
-                return R.error("openId参数错误,请清理缓存重新授权");
-            } else {
-                packetParam.setOpenId(fsUserWx.getOpenId());
-            }
+        boolean useNewMerchant = useNewMerchantOpenId(config, companyUser.getCompanyId());
+        R openIdErr = fillCourseRedPacketOpenId(packetParam, param, user, config, useNewMerchant);
+        if (openIdErr != null) {
+            return openIdErr;
         }
 
-        //判断服务号配置是否存在
-        if (config.getMiniAppAuthType() == 2 && StringUtils.isNotEmpty(config.getMpAppId())) {
+        //判断服务号配置是否存在(仅老商户传 mpAppId,供 Legacy 服务号改写)
+        if (!useNewMerchant && config.getMiniAppAuthType() == 2 && StringUtils.isNotEmpty(config.getMpAppId())) {
             packetParam.setMpAppId(config.getMpAppId());
         }
         packetParam.setAmount(amount);
@@ -1748,28 +1742,13 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
 
         // 准备发送红包参数
         WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
-        packetParam.setOpenId(user.getMpOpenId());
-        // 来源是小程序切换openId
-        if (param.getSource() == 2) {
-            //处理多小程序问题
-//            Company company = companyMapper.selectCompanyById(param.getCompanyId());
-//            if (company.getCourseMiniAppId()==null){
-//                return R.error("销售公司参数错误,未绑定小程序");
-//            }
-            if (user.getMpOpenId() != null && !isNewWxMerchant) {
-                packetParam.setOpenId(user.getMpOpenId());
-            } else {
-                //查询是否绑定小程序
-                FsUserWx fsUserWx = fsUserWxService.selectByAppIdAndUserId(param.getAppId(), user.getUserId(), 1);
-                if (fsUserWx == null) {
-                    return R.error("openId参数错误,请清理缓存重新授权");
-                } else {
-                    packetParam.setOpenId(fsUserWx.getOpenId());
-                }
-            }
+        boolean useNewMerchant = useNewMerchantOpenId(config, log.getCompanyId());
+        R openIdErr = fillCourseRedPacketOpenId(packetParam, param, user, config, useNewMerchant);
+        if (openIdErr != null) {
+            return openIdErr;
         }
-        //判断服务号配置是否存在
-        if (StringUtils.isNotEmpty(config.getMpAppId())) {
+        //判断服务号配置是否存在(仅老商户)
+        if (!useNewMerchant && StringUtils.isNotEmpty(config.getMpAppId())) {
             packetParam.setMpAppId(config.getMpAppId());
         }
         //组装发红包数据
@@ -1936,6 +1915,65 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
     /**
      * 获取红包开关
      */
+    /**
+     * 是否走新商户 openId(小程序):
+     * 1) 全局 isNewWxMerchant=true,或
+     * 2) 当前生效红包商户配置 isNew=1(多商户页切换 / redPacket.config)
+     */
+    private boolean useNewMerchantOpenId(CourseConfig courseConfig, Long companyId) {
+        if (Boolean.TRUE.equals(isNewWxMerchant)) {
+            return true;
+        }
+        try {
+            RedPacketConfig redPacketConfig = paymentService.resolveActiveRedPacketConfig(
+                    courseConfig == null ? 1 : courseConfig.getRedPacketMode(), companyId);
+            return paymentService.isNewMerchantChannel(redPacketConfig);
+        } catch (Exception e) {
+            logger.warn("解析红包商户通道失败,默认走老商户服务号 openId: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 组装看课发红包 openId:
+     * App(source=3)+新商户 → fs_user.app_open_id
+     * 小程序/新商户非App → fs_user_wx
+     * 老商户(含 H5)→ mpOpenId(服务号,不再查 FsUserWx)
+     *
+     * @return 失败时返回 R.error,成功返回 null
+     */
+    private R fillCourseRedPacketOpenId(WxSendRedPacketParam packetParam, FsCourseSendRewardUParam param,
+                                        FsUser user, CourseConfig config, boolean useNewMerchant) {
+        // App + 新商户:走 app_open_id
+        if (useNewMerchant && param.getSource() != null && param.getSource() == 3) {
+            if (StringUtils.isBlank(user.getAppOpenId())) {
+                return R.error("请先完成微信授权");
+            }
+            packetParam.setOpenId(user.getAppOpenId());
+            return null;
+        }
+        // 新商户非App,或小程序来源:查 fs_user_wx 小程序 openId
+        if (useNewMerchant || (param.getSource() != null && param.getSource() == 2)) {
+            // 老商户 + 小程序来源,若仍配置走服务号则用 mpOpenId
+            if (!useNewMerchant && user.getMpOpenId() != null && !isNewWxMerchant) {
+                packetParam.setOpenId(user.getMpOpenId());
+                return null;
+            }
+            FsUserWx fsUserWx = fsUserWxService.selectByAppIdAndUserId(param.getAppId(), user.getUserId(), 1);
+            if (fsUserWx == null || StringUtils.isBlank(fsUserWx.getOpenId())) {
+                return R.error("openId参数错误,请清理缓存重新授权");
+            }
+            packetParam.setOpenId(fsUserWx.getOpenId());
+            return null;
+        }
+        // 老商户 H5/App 等:只用服务号 mpOpenId
+        if (StringUtils.isBlank(user.getMpOpenId())) {
+            return R.error("openId参数错误,请重新授权服务号");
+        }
+        packetParam.setOpenId(user.getMpOpenId());
+        return null;
+    }
+
     private boolean getRedPacketState(Long companyId) {
         Company company = companyMapper.selectCompanyById(companyId);
 

+ 5 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserCompanyUserTransferTaskMapper.java

@@ -12,4 +12,9 @@ public interface FsUserCompanyUserTransferTaskMapper extends BaseMapper<FsUserCo
      * 获取导入会员数据列表
      */
     List<FsUserCompanyUserTransferTaskVO> getAllTransferTaskList();
+
+    /**
+     * 按提交人查询导入任务
+     */
+    List<FsUserCompanyUserTransferTaskVO> getTransferTaskListBySubmitUserId(Long submitUserId);
 }

+ 10 - 0
fs-service/src/main/java/com/fs/his/service/IFsStorePaymentService.java

@@ -101,6 +101,16 @@ public interface IFsStorePaymentService
 
     R sendRedPacketDeduction(WxSendRedPacketParam param);
 
+    /**
+     * 解析当前生效的红包商户配置(mode=1 优先 Redis 多商户;mode=2 分公司配置)
+     */
+    com.fs.course.config.RedPacketConfig resolveActiveRedPacketConfig(Integer redPacketMode, Long companyId);
+
+    /**
+     * 是否走新商户「商家转账」通道(isNew=1)
+     */
+    boolean isNewMerchantChannel(com.fs.course.config.RedPacketConfig config);
+
     String transferNotify(String notifyData, HttpServletRequest request);
 
     /**

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

@@ -16,11 +16,21 @@ public interface IFsUserCompanyUserTransferTaskService extends IService<FsUserCo
      */
     void transferImportData(List<FsUserTransferImportDTO> list, SysUser user);
 
+    /**
+     * 销售后台转移导入(仅本司,项目不变)——独立方法,不影响总后台
+     */
+    void transferImportDataForCompany(List<FsUserTransferImportDTO> list, Long submitUserId, String submitUserName, Long companyId);
+
     /**
      * 获取导入会员数据列表
      */
     List<FsUserCompanyUserTransferTaskVO> getAllTransferTaskList();
 
+    /**
+     * 按提交人查询导入任务(销售端)
+     */
+    List<FsUserCompanyUserTransferTaskVO> getTransferTaskListBySubmitUserId(Long submitUserId);
+
     /**
      * 获取导入会员数据详情
      */
@@ -30,4 +40,9 @@ public interface IFsUserCompanyUserTransferTaskService extends IService<FsUserCo
      * 获取转移失败数据
      */
     List<FsUserTransferImportDTO> getTransferFailedData(Long taskId);
+
+    /**
+     * 校验任务是否属于指定提交人
+     */
+    boolean isTaskOwnedBySubmitUser(Long taskId, Long submitUserId);
 }

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

@@ -24,9 +24,11 @@ import com.fs.erp.domain.ErpOrder;
 import com.fs.erp.domain.ErpOrderItem;
 import com.fs.erp.domain.ErpOrderPayment;
 import com.fs.erp.domain.ErpOrderQuery;
+import com.fs.erp.dto.CommonResponse;
 import com.fs.erp.dto.ErpOrderQueryRequert;
 import com.fs.erp.dto.ErpOrderQueryResponse;
 import com.fs.erp.dto.ErpOrderResponseDTO;
+import com.fs.erp.dto.OrderCancelRequestDTO;
 import com.fs.erp.dto.OrderItemDTO;
 import com.fs.erp.dto.PaymentDTO;
 import com.fs.erp.dto.ShopOrderDTO;
@@ -983,9 +985,26 @@ public class FsExternalOrderServiceImpl implements IFsExternalOrderService {
         if (order == null) {
             return R.error("订单不存在");
         }
-        if (order.getStatus() == 4) {
+        if (order.getStatus() != null && order.getStatus() == -3) {
+            return R.error("订单已取消");
+        }
+        if (order.getStatus() != null && order.getStatus() == 4) {
             return R.error("已完成订单无法取消");
         }
+        // 本地已是发货中/已发货时直接拒绝(与聚水潭状态一致时无需再查)
+        if (order.getStatus() != null && (order.getStatus() == 3 || order.getStatus() == 5)) {
+            return R.error("订单已发货或发货中,不能取消");
+        }
+
+        // 已推送聚水潭:先查状态,发货中/已发货禁止取消;否则调取消接口
+        if (StringUtils.isNotBlank(order.getExtendOrderId())) {
+            R jstResult = cancelExternalOrderOnJst(order);
+            Object code = jstResult.get("code");
+            if (!(code instanceof Integer) || ((Integer) code) != 200) {
+                return jstResult;
+            }
+        }
+
         FsExternalOrder updateOrder = new FsExternalOrder();
         updateOrder.setOrderId(orderId);
         updateOrder.setStatus(-3);
@@ -996,6 +1015,66 @@ public class FsExternalOrderServiceImpl implements IFsExternalOrderService {
         return R.error("取消订单失败");
     }
 
+    /**
+     * 已推送聚水潭的外部订单:查询状态后取消。
+     * Sent/Delivering 禁止取消;已 Cancelled 视为成功;其余调用取消接口。
+     */
+    private R cancelExternalOrderOnJst(FsExternalOrder order) {
+        if (order.getStoreId() == null) {
+            return R.error("订单店铺为空,无法同步取消聚水潭订单");
+        }
+        FsStore fsStore = fsStoreMapper.selectFsStoreByShopCode(String.valueOf(order.getStoreId()));
+        if (fsStore == null || StringUtils.isBlank(fsStore.getShopCode())) {
+            return R.error("店铺不存在或店铺编码为空,无法同步取消聚水潭订单");
+        }
+
+        Long oId;
+        try {
+            oId = Long.valueOf(order.getExtendOrderId().trim());
+        } catch (NumberFormatException e) {
+            return R.error("聚水潭订单号无效,无法取消");
+        }
+
+        try {
+            ErpOrderQueryRequert request = new ErpOrderQueryRequert();
+            request.setShop_id(fsStore.getShopCode());
+            request.setO_ids(Collections.singletonList(oId));
+            ErpOrderQueryResponse response = queryJstOrderWithRetry(request, order.getOrderCode());
+            if (response == null || CollectionUtils.isEmpty(response.getOrders())) {
+                return R.error("查询聚水潭订单失败,请稍后重试");
+            }
+
+            ErpOrderQuery jstOrder = response.getOrders().get(0);
+            String jstStatus = jstOrder.getStatus();
+            log.info("取消外部订单前查询聚水潭状态: orderCode={}, oId={}, status={}",
+                    order.getOrderCode(), oId, jstStatus);
+
+            if (ErpQueryOrderStatusEnum.SENT.getCode().equals(jstStatus)
+                    || ErpQueryOrderStatusEnum.DELIVERING.getCode().equals(jstStatus)) {
+                return R.error("聚水潭订单已发货或发货中,不能取消");
+            }
+            if (ErpQueryOrderStatusEnum.CANCELLED.getCode().equals(jstStatus)) {
+                return R.ok("聚水潭订单已取消");
+            }
+
+            OrderCancelRequestDTO cancelRequest = new OrderCancelRequestDTO();
+            cancelRequest.setOIds(Collections.singletonList(oId.intValue()));
+            cancelRequest.setCancelType("后台取消");
+            cancelRequest.setRemark("外部订单后台取消");
+            CommonResponse<?> cancelResp = jstErpHttpService.cancel(cancelRequest);
+            log.info("外部订单取消聚水潭结果: orderCode={}, resp={}", order.getOrderCode(), JSON.toJSONString(cancelResp));
+            if (cancelResp == null || cancelResp.getCode() == null || cancelResp.getCode() != 0) {
+                String msg = cancelResp != null && StringUtils.isNotBlank(cancelResp.getMsg())
+                        ? cancelResp.getMsg() : "聚水潭取消订单失败";
+                return R.error(msg);
+            }
+            return R.ok("聚水潭取消成功");
+        } catch (Exception e) {
+            log.error("同步取消聚水潭外部订单异常: {}", order.getOrderCode(), e);
+            return R.error("同步取消聚水潭失败:" + (e.getMessage() != null ? e.getMessage() : "请稍后重试"));
+        }
+    }
+
     /**
      * 检查外部订单发货权限
      * 如果店铺或商品的连锁品牌、商品来源类型未配置,则不校验

+ 325 - 89
fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java

@@ -21,6 +21,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.fs.common.annotation.DataScope;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.core.text.Convert;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.ServletUtils;
@@ -38,6 +39,7 @@ import com.fs.company.service.ICompanyUserService;
 import com.fs.company.vo.FsStorePaymentStatisticsVO;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.core.config.WxMaConfiguration;
+import com.fs.core.config.WxOpenProperties;
 import com.fs.core.config.WxPayProperties;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.course.config.RedPacketConfig;
@@ -176,6 +178,8 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
     @Autowired
     private FsUserWxMapper userWxMapper;
     @Autowired
+    private WxOpenProperties wxOpenProperties;
+    @Autowired
     private ICompanyUserService companyUserService;
     @Autowired
     private ICompanyService companyService;
@@ -210,6 +214,9 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
     @Autowired
     private ICompanyConfigService companyConfigService;
 
+    @Autowired
+    private ISysRedpacketConfigMoreService sysRedpacketConfigMoreService;
+
     @Autowired
     private RedissonClient redissonClient;
 
@@ -634,36 +641,13 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
                 return R.error("[红包领取] 当前用户当前已经领取红包已经达到限制!");
             }
 
-            String json;
-            RedPacketConfig config = new RedPacketConfig();
-            // 根据红包模式获取配置
-            switch (param.getRedPacketMode()){
-                case 1:
-                    json = configService.selectConfigByKey("redPacket.config");
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                case 2:
-                    json = companyConfigService.selectRedPacketConfigByKey(param.getCompanyId());
-                    //如果分公司配置为空就走总后台的配置
-                    if (StringUtils.isEmpty(json)){
-                        json = configService.selectConfigByKey("redPacket.config");
-                    }
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                default:
-                    throw new UnsupportedOperationException("当前红包模式不支持!");
-            }
-            //H5的用公众号的appid发,小程序的用小程序的appid来发
-            if (param.getSource()==2){
-                // 传参appId为空时,仍然使用配置里面的
-                String appId = StringUtils.isBlank(param.getAppId()) ? config.getMiniappId() : param.getAppId();
-                config.setAppId(appId);
-            }
+            RedPacketConfig config = resolveActiveRedPacketConfig(param.getRedPacketMode(), param.getCompanyId());
+            applyMerchantChannelIdentity(param, config);
             logger.info("最终传参 {}",config);
             //组合返回参数
             R result = new R();
             // 根据 isNew 判断使用哪种发红包方式
-            if (config.getIsNew() != null && config.getIsNew() == 1) {
+            if (isNewMerchantChannel(config)) {
                 result = sendRedPacketV3Internal(param, config);
             } else {
                 result= sendRedPacketLegacyInternal(param, config);
@@ -677,6 +661,15 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             return result;
         }catch (Exception e){
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
+            if (e instanceof CustomException) {
+                return R.error(e.getMessage());
+            }
+            if (isMerchantBalanceInsufficient(e)) {
+                if (param.getRedPacketMode() != null && param.getRedPacketMode() == 1) {
+                    redisCache.incr("sys_config:redPacket.config.newCount", 1L);
+                }
+                return R.error("[红包领取] 账户余额不足,请联系管理员!");
+            }
             throw new RuntimeException(e);
         }finally {
             if (lock.isHeldByCurrentThread()) {
@@ -756,32 +749,13 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             redPacketLog.setAccBalanceBefore(companyMoney);
             redPacketLog.setSource(param.getSource());
 
-            String json;
-            RedPacketConfig config = new RedPacketConfig();
-            // 根据红包模式获取配置
-            switch (param.getRedPacketMode()){
-                case 1:
-                    json = configService.selectConfigByKey("redPacket.config");
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                case 2:
-                    json = companyConfigService.selectRedPacketConfigByKey(param.getCompanyId());
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                default:
-                    throw new UnsupportedOperationException("当前红包模式不支持!");
-            }
-            //H5的用公众号的appid发,小程序的用小程序的appid来发
-            if (param.getSource()==2){
-                // 传参appId为空时,仍然使用配置里面的
-                String appId = StringUtils.isBlank(param.getAppId()) ? config.getMiniappId() : param.getAppId();
-                config.setAppId(appId);
-            }
+            RedPacketConfig config = resolveActiveRedPacketConfig(param.getRedPacketMode(), param.getCompanyId());
+            applyMerchantChannelIdentity(param, config);
             logger.info("最终传参 {}",config);
             //组合返回参数
             R result = new R();
             // 根据 isNew 判断使用哪种发红包方式
-            if (config.getIsNew() != null && config.getIsNew() == 1) {
+            if (isNewMerchantChannel(config)) {
                 result = sendRedPacketV3Internal(param, config);
             } else {
                 result= sendRedPacketLegacyInternal(param, config);
@@ -811,6 +785,15 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
                 redPacketLog.setStatus(2);
                 redPacketLog.setErrorMsg(ExceptionUtils.getMessage(e));
             }
+            if (e instanceof CustomException) {
+                return R.error(e.getMessage());
+            }
+            if (isMerchantBalanceInsufficient(e)) {
+                if (param.getRedPacketMode() != null && param.getRedPacketMode() == 1) {
+                    redisCache.incr("sys_config:redPacket.config.newCount", 1L);
+                }
+                return R.error("[红包领取] 账户余额不足,请联系管理员!");
+            }
             throw new RuntimeException(e);
         }finally {
             if (lock.isHeldByCurrentThread()) {
@@ -822,6 +805,167 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
         }
     }
 
+    /**
+     * 判断是否商户运营账户资金不足(含 RuntimeException 包装场景)
+     */
+    private boolean isMerchantBalanceInsufficient(Throwable e) {
+        Throwable cur = e;
+        while (cur != null) {
+            if (cur instanceof WxPayException) {
+                String customErrorMsg = ((WxPayException) cur).getCustomErrorMsg();
+                if (null != customErrorMsg && customErrorMsg.startsWith("商户运营账户资金不足")) {
+                    return true;
+                }
+            }
+            String msg = cur.getMessage();
+            if (msg != null && (msg.contains("商户运营账户资金不足") || msg.contains("商家余额不足"))) {
+                return true;
+            }
+            cur = cur.getCause();
+        }
+        return false;
+    }
+
+    @Override
+    public RedPacketConfig resolveActiveRedPacketConfig(Integer redPacketMode, Long companyId) {
+        // 公司绑定了具体多商户配置:不改红包模式,直接读该条配置
+        Long configId = companyService.getCompanyRedPacketConfigId(companyId);
+        if (configId != null) {
+            RedPacketConfig bindConfig = loadRedPacketConfigByMoreId(configId);
+            if (bindConfig == null) {
+                throw new CustomException("该公司绑定的红包商户配置不存在,请重新在公司管理中选择");
+            }
+            logger.info("公司{}走绑定商户发红包 configId={} mchId={} isNew={}",
+                    companyId, configId, bindConfig.getMchId(), bindConfig.getIsNew());
+            return bindConfig;
+        }
+        String json;
+        int mode = redPacketMode == null ? 1 : redPacketMode;
+        switch (mode) {
+            case 1:
+                // 未绑定:走多商户页当前商户;Redis 为空再回落库表
+                json = Convert.toStr(redisCache.getCacheObject("sys_config:redPacket.config.new"));
+                if (StringUtils.isEmpty(json)) {
+                    json = configService.selectConfigByKey("redPacket.config");
+                }
+                break;
+            case 2:
+                json = companyConfigService.selectRedPacketConfigByKey(companyId);
+                if (StringUtils.isEmpty(json)) {
+                    json = configService.selectConfigByKey("redPacket.config");
+                }
+                break;
+            default:
+                throw new UnsupportedOperationException("当前红包模式不支持!");
+        }
+        if (StringUtils.isEmpty(json)) {
+            throw new CustomException("红包商户配置为空,请检查多商户配置");
+        }
+        return JSONUtil.toBean(json, RedPacketConfig.class);
+    }
+
+    private RedPacketConfig loadRedPacketConfigByMoreId(Long configId) {
+        if (configId == null) {
+            return null;
+        }
+        SysRedpacketConfigMore more = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(configId);
+        if (more == null) {
+            return null;
+        }
+        RedPacketConfig config = new RedPacketConfig();
+        BeanUtils.copyProperties(more, config);
+        return config;
+    }
+
+    @Override
+    public boolean isNewMerchantChannel(RedPacketConfig config) {
+        return config != null && config.getIsNew() != null && config.getIsNew() == 1;
+    }
+
+    /**
+     * 按商户类型设置发红包身份:
+     * 新商户(isNew=1):
+     *   - App(source=3):开放平台移动应用 appId + fs_user.app_open_id
+     *   - 其他:小程序 appId + 小程序 openId,禁止服务号改写
+     * 老商户:保持原逻辑(小程序来源可先切 miniappId,Legacy 再按需改回服务号)
+     */
+    private void applyMerchantChannelIdentity(WxSendRedPacketParam param, RedPacketConfig config) {
+        if (isNewMerchantChannel(config)) {
+            // App 新商户:必须用移动应用 appId + app_open_id
+            if (param.getSource() != null && param.getSource() == 3) {
+                String appAppId = wxOpenProperties != null ? wxOpenProperties.getAppId() : null;
+                if (StringUtils.isBlank(appAppId)) {
+                    throw new CustomException("新商户App发红包缺少开放平台appId,请检查wx.open配置");
+                }
+                config.setAppId(appAppId);
+                param.setAppId(appAppId);
+                String appOpenId = resolveAppOpenId(param);
+                if (StringUtils.isBlank(appOpenId)) {
+                    throw new CustomException("请先完成微信授权");
+                }
+                param.setOpenId(appOpenId);
+                param.setMpAppId(null);
+                logger.info("新商户App发红包身份 appId={}, openId={}", appAppId, appOpenId);
+                return;
+            }
+            String miniAppId = StringUtils.isNotBlank(param.getAppId()) ? param.getAppId() : config.getMiniappId();
+            if (StringUtils.isBlank(miniAppId)) {
+                throw new CustomException("新商户发红包缺少小程序appId,请检查商户配置或请求参数");
+            }
+            config.setAppId(miniAppId);
+            String miniOpenId = resolveMiniappOpenId(param, miniAppId);
+            if (StringUtils.isBlank(miniOpenId)) {
+                throw new CustomException("openId参数错误,请清理缓存重新授权小程序");
+            }
+            param.setOpenId(miniOpenId);
+            // 新商户禁止服务号改写
+            param.setMpAppId(null);
+            logger.info("新商户发红包身份 appId={}, openId={}", miniAppId, miniOpenId);
+            return;
+        }
+        // 老商户:H5用公众号appId,小程序来源先切小程序appId(Legacy 有 mp 时可再改回服务号)
+        if (param.getSource() != null && param.getSource() == 2) {
+            String appId = StringUtils.isBlank(param.getAppId()) ? config.getMiniappId() : param.getAppId();
+            config.setAppId(appId);
+        }
+    }
+
+    private String resolveAppOpenId(WxSendRedPacketParam param) {
+        FsUser user = param.getUser();
+        if (user != null && StringUtils.isNotBlank(user.getAppOpenId())) {
+            return user.getAppOpenId();
+        }
+        if (StringUtils.isNotBlank(param.getOpenId())) {
+            return param.getOpenId();
+        }
+        return null;
+    }
+
+    private String resolveMiniappOpenId(WxSendRedPacketParam param, String miniAppId) {
+        FsUser user = param.getUser();
+        if (user == null) {
+            return param.getOpenId();
+        }
+        if (StringUtils.isNotBlank(miniAppId)) {
+            Wrapper<FsUserWx> queryWrapper = Wrappers.<FsUserWx>lambdaQuery()
+                    .eq(FsUserWx::getFsUserId, user.getUserId())
+                    .eq(FsUserWx::getAppId, miniAppId)
+                    .last("limit 1");
+            FsUserWx fsUserWx = userWxMapper.selectOne(queryWrapper);
+            if (fsUserWx != null && StringUtils.isNotBlank(fsUserWx.getOpenId())) {
+                return fsUserWx.getOpenId();
+            }
+        }
+        if (StringUtils.isNotBlank(user.getCourseMaOpenId())) {
+            return user.getCourseMaOpenId();
+        }
+        if (StringUtils.isNotBlank(param.getOpenId())
+                && (user.getMpOpenId() == null || !param.getOpenId().equals(user.getMpOpenId()))) {
+            return param.getOpenId();
+        }
+        return null;
+    }
+
     private static long getExpireSeconds() {
         Calendar calendar = Calendar.getInstance();
         calendar.add(Calendar.DAY_OF_YEAR, 1);
@@ -880,8 +1024,11 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
     // 内部方法:处理旧版本的发红包逻辑
     private R sendRedPacketLegacyInternal(WxSendRedPacketParam param, RedPacketConfig config) {
         //如果服务号的配置存在,小程序红包接口可以使用服务号来发红包,重新赋值
-        //仅老商户支持
-        if (param.getUser().getMpOpenId()!=null && StringUtils.isNotEmpty(param.getMpAppId())){
+        //仅老商户支持(新商户 isNew=1 禁止改写)
+        if (!isNewMerchantChannel(config)
+                && param.getUser() != null
+                && param.getUser().getMpOpenId()!=null
+                && StringUtils.isNotEmpty(param.getMpAppId())){
             config.setAppId(param.getMpAppId());
             param.setOpenId(param.getUser().getMpOpenId());
         }
@@ -929,19 +1076,8 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
     public String transferNotify(String notifyData, HttpServletRequest request) {
         logger.info("zyp \n【收到转账回调】:{}",notifyData);
         try {
-            String json = configService.selectConfigByKey("redPacket.config");
-            RedPacketConfig config = JSONUtil.toBean(json, RedPacketConfig.class);
-            //创建微信订单
-            WxPayConfig payConfig = new WxPayConfig();
-            BeanUtils.copyProperties(config,payConfig);
-            WxPayService wxPayService = new WxPayServiceImpl();
-            wxPayService.setConfig(payConfig);
-            SignatureHeader signatureHeader = new SignatureHeader();
-            signatureHeader.setTimeStamp(request.getHeader("Wechatpay-Timestamp"));
-            signatureHeader.setNonce(request.getHeader("Wechatpay-Nonce"));
-            signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
-            signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
-            WxPayTransferBatchesNotifyV3Result result = wxPayService.parseTransferBatchesNotifyV3Result(notifyData,signatureHeader);
+            SignatureHeader signatureHeader = buildSignatureHeader(request);
+            WxPayTransferBatchesNotifyV3Result result = parseBatchesNotifyWithFallback(notifyData, signatureHeader);
             logger.info("到零钱回调:{}",result.getResult());
             if (result.getResult().getBatchStatus().equals("FINISHED") && result.getResult().getFailNum()==0) {
                 R r = redPacketLogService.syncRedPacket(result.getResult().getOutBatchNo(),result.getResult().getBatchId());
@@ -954,8 +1090,8 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             }else {
                 return WxPayNotifyResponse.fail("");
             }
-        } catch (WxPayException e) {
-            logger.error("zyp \n【转账回调异常】:{}", e.getReturnMsg());
+        } catch (Exception e) {
+            logger.error("zyp \n【转账回调异常】msg={}", e.getMessage(), e);
             return WxPayNotifyResponse.fail(e.getMessage());
         }
     }
@@ -1093,40 +1229,140 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
 
     @Override
     public String v3TransferNotify(String notifyData, HttpServletRequest request) {
-        logger.info("zyp \n【收到转账回调V3】:{}",notifyData);
+        logger.info("zyp \n【收到转账回调V3】:{}", notifyData);
         try {
-            String json = configService.selectConfigByKey("redPacket.config");
-            RedPacketConfig config = JSONUtil.toBean(json, RedPacketConfig.class);
-            //创建微信订单
-            WxPayConfig payConfig = new WxPayConfig();
-            BeanUtils.copyProperties(config,payConfig);
-            WxPayService wxPayService = new WxPayServiceImpl();
-            wxPayService.setConfig(payConfig);
-            SignatureHeader signatureHeader = new SignatureHeader();
-            signatureHeader.setTimeStamp(request.getHeader("Wechatpay-Timestamp"));
-            signatureHeader.setNonce(request.getHeader("Wechatpay-Nonce"));
-            signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
-            signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
-            TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData,signatureHeader);
-            logger.info("到零钱回调1:{}",result.getResult());
-            if (result.getResult().getState().equals("SUCCESS")) {
-                R r = redPacketLogService.syncRedPacket(result.getResult().getOutBillNo(),result.getResult().getTransferBillNo());
-                logger.info("result:{}",r);
-                if (r.get("code").equals(200)){
+            SignatureHeader signatureHeader = buildSignatureHeader(request);
+            logger.info("V3回调请求头 timestamp={}, nonce={}, serial={}",
+                    signatureHeader.getTimeStamp(), signatureHeader.getNonce(), signatureHeader.getSerial());
+            TransferBillsNotifyResult result = parseTransferBillsNotifyWithFallback(notifyData, signatureHeader);
+            logger.info("到零钱回调1:{}", result.getResult());
+            if (result.getResult() != null && "SUCCESS".equals(result.getResult().getState())) {
+                R r = redPacketLogService.syncRedPacket(result.getResult().getOutBillNo(), result.getResult().getTransferBillNo());
+                logger.info("result:{}", r);
+                if (r.get("code").equals(200)) {
                     return WxPayNotifyResponse.success("处理成功");
-                }else {
+                } else {
                     return WxPayNotifyResponse.fail("");
                 }
-            }else {
+            } else {
+                logger.warn("V3转账回调非SUCCESS,不更新红包状态 state={}",
+                        result.getResult() == null ? null : result.getResult().getState());
                 return WxPayNotifyResponse.fail("");
             }
-        } catch (WxPayException e) {
-            e.printStackTrace();
-            logger.error("zyp \n【转账回调异常】:{}", e.getReturnMsg());
+        } catch (Exception e) {
+            // 原先只捕 WxPayException,且 getReturnMsg() 常为 null,导致只看到「收到转账回调V3」后续全无
+            logger.error("zyp \n【转账回调异常V3】msg={}", e.getMessage(), e);
             return WxPayNotifyResponse.fail(e.getMessage());
         }
     }
 
+    private SignatureHeader buildSignatureHeader(HttpServletRequest request) {
+        SignatureHeader signatureHeader = new SignatureHeader();
+        signatureHeader.setTimeStamp(request.getHeader("Wechatpay-Timestamp"));
+        signatureHeader.setNonce(request.getHeader("Wechatpay-Nonce"));
+        signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
+        signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
+        return signatureHeader;
+    }
+
+    /**
+     * 灰度期间老/新商户回调并存:先 Redis 多商户,再 redPacket.config
+     */
+    private WxPayTransferBatchesNotifyV3Result parseBatchesNotifyWithFallback(String notifyData, SignatureHeader signatureHeader) throws WxPayException {
+        Exception last = null;
+        java.util.List<RedPacketConfig> candidates = listNotifyCandidateConfigs();
+        logger.info("批次回调验签候选商户数={} mchIds={}", candidates.size(),
+                candidates.stream().map(RedPacketConfig::getMchId).collect(java.util.stream.Collectors.toList()));
+        for (RedPacketConfig config : candidates) {
+            try {
+                WxPayConfig payConfig = new WxPayConfig();
+                BeanUtils.copyProperties(config, payConfig);
+                WxPayService wxPayService = new WxPayServiceImpl();
+                wxPayService.setConfig(payConfig);
+                return wxPayService.parseTransferBatchesNotifyV3Result(notifyData, signatureHeader);
+            } catch (Exception e) {
+                last = e;
+                logger.warn("转账回调验签失败,尝试下一商户配置 mchId={}, err={}", config.getMchId(), e.getMessage());
+            }
+        }
+        if (last instanceof WxPayException) {
+            throw (WxPayException) last;
+        }
+        if (last != null) {
+            throw new WxPayException("批次回调验签全部失败: " + last.getMessage());
+        }
+        throw new WxPayException("无可用的红包商户配置用于回调验签");
+    }
+
+    private TransferBillsNotifyResult parseTransferBillsNotifyWithFallback(String notifyData, SignatureHeader signatureHeader) throws WxPayException {
+        Exception last = null;
+        // V3 单据回调优先用新商户配置验签,避免先拿老商户 apiV3Key 解密失败后日志不清晰
+        java.util.List<RedPacketConfig> candidates = listNotifyCandidateConfigs();
+        candidates.sort((a, b) -> {
+            int aNew = (a.getIsNew() != null && a.getIsNew() == 1) ? 0 : 1;
+            int bNew = (b.getIsNew() != null && b.getIsNew() == 1) ? 0 : 1;
+            return Integer.compare(aNew, bNew);
+        });
+        logger.info("V3单据回调验签候选商户数={} mchIds={}", candidates.size(),
+                candidates.stream().map(RedPacketConfig::getMchId).collect(java.util.stream.Collectors.toList()));
+        for (RedPacketConfig config : candidates) {
+            try {
+                WxPayConfig payConfig = new WxPayConfig();
+                BeanUtils.copyProperties(config, payConfig);
+                WxPayService wxPayService = new WxPayServiceImpl();
+                wxPayService.setConfig(payConfig);
+                TransferBillsNotifyResult parsed = wxPayService.parseTransferBillsNotifyV3Result(notifyData, signatureHeader);
+                logger.info("V3单据回调验签成功 mchId={}", config.getMchId());
+                return parsed;
+            } catch (Exception e) {
+                last = e;
+                logger.warn("V3转账回调验签失败,尝试下一商户配置 mchId={}, isNew={}, err={}",
+                        config.getMchId(), config.getIsNew(), e.getMessage());
+            }
+        }
+        if (last instanceof WxPayException) {
+            throw (WxPayException) last;
+        }
+        if (last != null) {
+            throw new WxPayException("V3单据回调验签全部失败: " + last.getMessage());
+        }
+        throw new WxPayException("无可用的红包商户配置用于回调验签");
+    }
+
+    private java.util.List<RedPacketConfig> listNotifyCandidateConfigs() {
+        java.util.List<RedPacketConfig> list = new java.util.ArrayList<>();
+        java.util.Set<String> mchIds = new java.util.HashSet<>();
+        // 多商户表优先(公司绑定的新/老商户都在这里),避免被 Redis 同 mchId 占坑跳过
+        List<SysRedpacketConfigMore> moreList = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreList(new SysRedpacketConfigMore());
+        if (moreList != null) {
+            for (SysRedpacketConfigMore more : moreList) {
+                if (more == null || StringUtils.isEmpty(more.getMchId())) {
+                    continue;
+                }
+                if (mchIds.add(more.getMchId())) {
+                    RedPacketConfig config = new RedPacketConfig();
+                    BeanUtils.copyProperties(more, config);
+                    list.add(config);
+                }
+            }
+        }
+        String redisJson = Convert.toStr(redisCache.getCacheObject("sys_config:redPacket.config.new"));
+        if (StringUtils.isNotEmpty(redisJson)) {
+            RedPacketConfig redisConfig = JSONUtil.toBean(redisJson, RedPacketConfig.class);
+            if (redisConfig != null && StringUtils.isNotEmpty(redisConfig.getMchId()) && mchIds.add(redisConfig.getMchId())) {
+                list.add(redisConfig);
+            }
+        }
+        String oldJson = configService.selectConfigByKey("redPacket.config");
+        if (StringUtils.isNotEmpty(oldJson)) {
+            RedPacketConfig oldConfig = JSONUtil.toBean(oldJson, RedPacketConfig.class);
+            if (oldConfig != null && StringUtils.isNotEmpty(oldConfig.getMchId()) && mchIds.add(oldConfig.getMchId())) {
+                list.add(oldConfig);
+            }
+        }
+        return list;
+    }
+
     @Override
     @Transactional
     public R sendRedPacketTest(WxSendRedPacketParam param) {

+ 221 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsUserCompanyUserTransferTaskServiceImpl.java

@@ -225,6 +225,213 @@ public class FsUserCompanyUserTransferTaskServiceImpl extends ServiceImpl<FsUser
         baseMapper.updateById(transferTask);
     }
 
+    /**
+     * 销售后台转移导入(仅本司,项目不变)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void transferImportDataForCompany(List<FsUserTransferImportDTO> list, Long submitUserId, String submitUserName, Long companyId) {
+        if (companyId == null) {
+            throw new ServiceException("公司ID不能为空");
+        }
+        FsUserCompanyUserTransferTask transferTask = new FsUserCompanyUserTransferTask();
+        transferTask.setStatus(0);
+        transferTask.setCreateTime(LocalDateTime.now());
+        transferTask.setTotalCount(list.size());
+        transferTask.setSubmitUserId(submitUserId);
+        transferTask.setSubmitUserName(submitUserName);
+        baseMapper.insert(transferTask);
+
+        Set<Long> companyUserIds = list.stream()
+                .flatMap(d -> Stream.of(d.getOldCompanyUserId(), d.getNewCompanyUserId()))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        List<CompanyUser> companyUsers = companyUserMapper.selectCompanyUserByIds(companyUserIds);
+        if (companyUsers.isEmpty()) {
+            throw new ServiceException("销售列表不能为空");
+        }
+
+        Map<Long, CompanyUser> companyUserMap = companyUsers.stream()
+                .filter(Objects::nonNull)
+                .filter(cu -> cu.getUserId() != null)
+                .collect(Collectors.toMap(CompanyUser::getUserId, Function.identity(), (a, b) -> a));
+
+        Set<Long> companyIds = companyUsers.stream()
+                .map(CompanyUser::getCompanyId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        List<Company> companies = companyMapper.selectCompanyByIds3(companyIds);
+        if (companies.isEmpty()) {
+            throw new ServiceException("销售对应公司不存在");
+        }
+
+        Map<Long, Company> companyMap = companies.stream()
+                .filter(Objects::nonNull)
+                .filter(c -> c.getCompanyId() != null)
+                .collect(Collectors.toMap(Company::getCompanyId, Function.identity(), (a, b) -> a));
+
+        List<Long> userIds = list.stream().map(FsUserTransferImportDTO::getUserId).collect(Collectors.toList());
+        List<FsUser> users = userMapper.findUsersByIdsV2(userIds);
+        if (users.isEmpty()) {
+            throw new ServiceException("转移会员不能为空");
+        }
+
+        Map<Long, FsUser> userMap = users.stream()
+                .filter(Objects::nonNull)
+                .filter(u -> u.getUserId() != null)
+                .collect(Collectors.toMap(FsUser::getUserId, Function.identity(), (a, b) -> a));
+
+        List<DictVO> dictVOS = sysDictDataMapper.selectDictDataListByType("sys_course_project");
+        Map<Long, String> dictMap = dictVOS.stream()
+                .filter(Objects::nonNull)
+                .filter(d -> StringUtils.isNotBlank(d.getDictValue()))
+                .collect(Collectors.toMap(d -> Long.valueOf(d.getDictValue()), DictVO::getDictLabel, (a, b) -> a));
+
+        List<FsUserCompanyUser> updateRelations = new ArrayList<>();
+        List<FsUserCompanyUserTransferTaskDetail> details = new ArrayList<>();
+        for (FsUserTransferImportDTO fsUserTransferImportDTO : list) {
+            FsUserCompanyUserTransferTaskDetail detail = new FsUserCompanyUserTransferTaskDetail();
+            detail.setTaskId(transferTask.getId());
+            detail.setCreateTime(LocalDateTime.now());
+
+            try {
+                boolean flag = true;
+                StringBuilder reason = new StringBuilder();
+
+                Long oldCompanyUserId = fsUserTransferImportDTO.getOldCompanyUserId();
+                CompanyUser oldCompanyUser = oldCompanyUserId == null ? null : companyUserMap.get(oldCompanyUserId);
+                if (oldCompanyUser != null) {
+                    Company company = companyMap.get(oldCompanyUser.getCompanyId());
+                    if (company != null) {
+                        detail.setOldCompanyId(company.getCompanyId());
+                        detail.setOldCompanyName(company.getCompanyName());
+                    } else {
+                        flag = false;
+                        reason.append("原销售公司不存在[").append(oldCompanyUser.getCompanyId()).append("],");
+                    }
+                    detail.setOldCompanyUserId(oldCompanyUser.getUserId());
+                    detail.setOldCompanyUserName(oldCompanyUser.getUserName());
+                    if (!Objects.equals(oldCompanyUser.getCompanyId(), companyId)) {
+                        flag = false;
+                        reason.append("原销售不属于当前公司,");
+                    }
+                } else {
+                    flag = false;
+                    reason.append("原销售不存在[").append(oldCompanyUserId).append("],");
+                }
+
+                Long userId = fsUserTransferImportDTO.getUserId();
+                FsUser fsUser = userId == null ? null : userMap.get(userId);
+                if (fsUser != null) {
+                    detail.setUserId(fsUser.getUserId());
+                    detail.setUserName(fsUser.getNickName());
+                } else {
+                    flag = false;
+                    reason.append("会员不存在[").append(userId).append("],");
+                }
+
+                Long newCompanyUserId = fsUserTransferImportDTO.getNewCompanyUserId();
+                CompanyUser newCompanyUser = newCompanyUserId == null ? null : companyUserMap.get(newCompanyUserId);
+                if (newCompanyUser != null) {
+                    Company company = companyMap.get(newCompanyUser.getCompanyId());
+                    if (company != null) {
+                        detail.setNewCompanyId(company.getCompanyId());
+                        detail.setNewCompanyName(company.getCompanyName());
+                    } else {
+                        flag = false;
+                        reason.append("新归属销售公司不存在[").append(newCompanyUser.getCompanyId()).append("],");
+                    }
+                    detail.setNewCompanyUserId(newCompanyUser.getUserId());
+                    detail.setNewCompanyUserName(newCompanyUser.getUserName());
+                    if (!Objects.equals(newCompanyUser.getCompanyId(), companyId)) {
+                        flag = false;
+                        reason.append("新归属销售不属于当前公司,");
+                    }
+                } else {
+                    flag = false;
+                    reason.append("新归属销售不存在[").append(newCompanyUserId).append("],");
+                }
+
+                if (flag && detail.getOldCompanyId() != null && detail.getNewCompanyId() != null
+                        && !Objects.equals(detail.getOldCompanyId(), detail.getNewCompanyId())) {
+                    flag = false;
+                    reason.append("原销售与新销售必须属于同一公司,");
+                }
+
+                if (flag && Objects.equals(detail.getOldCompanyUserId(), detail.getNewCompanyUserId())) {
+                    flag = false;
+                    reason.append("原销售与新销售不能相同,");
+                }
+
+                if (flag) {
+                    FsUserCompanyUser params = new FsUserCompanyUser();
+                    params.setCompanyId(detail.getOldCompanyId());
+                    params.setCompanyUserId(detail.getOldCompanyUserId());
+                    params.setUserId(detail.getUserId());
+                    List<FsUserCompanyUser> fsUserCompanyUsers = userCompanyUserService.selectFsUserCompanyUserList(params);
+                    if (!fsUserCompanyUsers.isEmpty()) {
+                        int matched = 0;
+                        for (FsUserCompanyUser u : fsUserCompanyUsers) {
+                            if (!Objects.equals(u.getCompanyId(), companyId)) {
+                                continue;
+                            }
+                            matched++;
+                            u.setCompanyId(detail.getNewCompanyId());
+                            u.setCompanyUserId(detail.getNewCompanyUserId());
+                            u.setUpdateTime(LocalDateTime.now());
+                            updateRelations.add(u);
+
+                            FsUserCompanyUserTransferTaskDetail copyDetail = new FsUserCompanyUserTransferTaskDetail();
+                            BeanUtils.copyProperties(detail, copyDetail);
+                            copyDetail.setFinishTime(LocalDateTime.now());
+                            copyDetail.setStatus(1);
+                            copyDetail.setProjectId(u.getProjectId());
+                            copyDetail.setProjectName(dictMap.get(u.getProjectId()));
+                            details.add(copyDetail);
+                        }
+                        if (matched == 0) {
+                            reason.append("会员[").append(detail.getUserId()).append("]在当前公司下与销售[")
+                                    .append(detail.getOldCompanyUserId()).append("]无归属关系,");
+                            detail.setStatus(2);
+                            detail.setReason(StringUtils.removeEnd(reason.toString(), ","));
+                            detail.setFinishTime(LocalDateTime.now());
+                            details.add(detail);
+                        }
+                    } else {
+                        reason.append("会员[").append(detail.getUserId()).append("]与销售[").append(detail.getOldCompanyUserId()).append("]归属关系不正确,");
+                        detail.setStatus(2);
+                        detail.setReason(StringUtils.removeEnd(reason.toString(), ","));
+                        detail.setFinishTime(LocalDateTime.now());
+                        details.add(detail);
+                    }
+                } else {
+                    detail.setStatus(2);
+                    detail.setReason(StringUtils.removeEnd(reason.toString(), ","));
+                    detail.setFinishTime(LocalDateTime.now());
+                    details.add(detail);
+                }
+            } catch (Exception e) {
+                detail.setStatus(2);
+                detail.setReason(e.getMessage());
+                detail.setFinishTime(LocalDateTime.now());
+                details.add(detail);
+            }
+        }
+
+        if (!details.isEmpty()) {
+            transferTaskDetailService.saveBatch(details);
+        }
+        if (!updateRelations.isEmpty()) {
+            userCompanyUserService.updateBatchById(updateRelations);
+        }
+
+        transferTask.setSuccessCount((int) details.stream().filter(d -> d.getStatus() == 1).count());
+        transferTask.setFailCount((int) details.stream().filter(d -> d.getStatus() == 2).count());
+        transferTask.setStatus(1);
+        transferTask.setFinishTime(LocalDateTime.now());
+        baseMapper.updateById(transferTask);
+    }
+
     /**
      * 获取导入会员数据列表
      */
@@ -233,6 +440,11 @@ public class FsUserCompanyUserTransferTaskServiceImpl extends ServiceImpl<FsUser
         return baseMapper.getAllTransferTaskList();
     }
 
+    @Override
+    public List<FsUserCompanyUserTransferTaskVO> getTransferTaskListBySubmitUserId(Long submitUserId) {
+        return baseMapper.getTransferTaskListBySubmitUserId(submitUserId);
+    }
+
     /**
      * 获取导入会员数据详情
      */
@@ -248,4 +460,13 @@ public class FsUserCompanyUserTransferTaskServiceImpl extends ServiceImpl<FsUser
     public List<FsUserTransferImportDTO> getTransferFailedData(Long taskId) {
         return transferTaskDetailService.getTransferFailedData(taskId);
     }
+
+    @Override
+    public boolean isTaskOwnedBySubmitUser(Long taskId, Long submitUserId) {
+        if (taskId == null || submitUserId == null) {
+            return false;
+        }
+        FsUserCompanyUserTransferTask task = baseMapper.selectById(taskId);
+        return task != null && Objects.equals(task.getSubmitUserId(), submitUserId);
+    }
 }

+ 0 - 9
fs-service/src/main/resources/application-druid-hdt.yml

@@ -212,12 +212,3 @@ isNewWxMerchant: false
 # 聚水潭API配置
 jst:
   shop_code: "21602201"
-
-# C-01 X-Skip-Auth 加固:HMAC 短期票据 + 接口路径白名单(头非空不再直接放行)
-# 调用方:X-Skip-Auth = SkipAuthTicketUtils.create(userId, ttlSeconds, secret)
-# 格式:userId.expireAtMs.hmacHex
-skip-auth:
-  secret: ${SKIP_AUTH_SECRET:}
-  ticket-max-ttl-seconds: 300
-  # 按实际内部调用接口补充路径前缀;未配置则拒绝一切 Skip-Auth
-  allowed-path-prefixes: []

+ 2 - 0
fs-service/src/main/resources/mapper/company/CompanyMapper.xml

@@ -46,6 +46,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="maxPadNum"    column="max_pad_num"    />
         <result property="deptId"   column="dept_id" />
         <result property="openRedPacket"   column="open_red_packet" />
+        <result property="redPacketConfigId"  column="red_packet_config_id" />
         <result property="sipExtNumIds"   column="sip_ext_num_ids" />
     </resultMap>
 
@@ -220,6 +221,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="maxPadNum != null">max_pad_num = #{maxPadNum},</if>
             <if test="deptId != null">dept_id = #{deptId},</if>
             <if test="openRedPacket != null">open_red_packet = #{openRedPacket},</if>
+            <if test="redPacketConfigId != null">red_packet_config_id = #{redPacketConfigId},</if>
             <if test="redPackageMoney != null">red_package_money = #{redPackageMoney},</if>
             <if test="sipExtNumIds != null">sip_ext_num_ids = #{sipExtNumIds},</if>
         </trim>

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

@@ -153,6 +153,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="watchLogId != null">watch_log_id = #{watchLogId},</if>
             <if test="periodId != null">period_id = #{periodId},</if>
             <if test="remark != null">remark = #{remark},</if>
+            <if test="result != null">result = #{result},</if>
             <if test="batchId != null">batch_id = #{batchId},</if>
             <if test="appId != null">app_id = #{appId},</if>
         </trim>

+ 6 - 0
fs-service/src/main/resources/mapper/his/FsUserUserCompanyTransferTaskMapper.xml

@@ -7,4 +7,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="getAllTransferTaskList" resultType="com.fs.his.vo.FsUserCompanyUserTransferTaskVO">
         select * from fs_user_company_user_transfer_task order by create_time desc
     </select>
+
+    <select id="getTransferTaskListBySubmitUserId" resultType="com.fs.his.vo.FsUserCompanyUserTransferTaskVO">
+        select * from fs_user_company_user_transfer_task
+        where submit_user_id = #{submitUserId}
+        order by create_time desc
+    </select>
 </mapper>

+ 0 - 49
fs-user-app/src/main/java/com/fs/app/config/SkipAuthProperties.java

@@ -1,49 +0,0 @@
-package com.fs.app.config;
-
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.stereotype.Component;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * X-Skip-Auth 加固配置(仅 fs-user-app)。
- * 未配置 secret / 接口路径白名单时,拒绝一切 Skip-Auth。
- */
-@Component
-@ConfigurationProperties(prefix = "skip-auth")
-public class SkipAuthProperties {
-
-    /** HMAC 密钥,建议环境变量 SKIP_AUTH_SECRET */
-    private String secret = "";
-
-    /** 票据最大有效期(秒) */
-    private long ticketMaxTtlSeconds = 300;
-
-    /** 允许使用 Skip-Auth 的接口路径前缀,如 /app/course/ */
-    private List<String> allowedPathPrefixes = new ArrayList<>();
-
-    public String getSecret() {
-        return secret;
-    }
-
-    public void setSecret(String secret) {
-        this.secret = secret;
-    }
-
-    public long getTicketMaxTtlSeconds() {
-        return ticketMaxTtlSeconds;
-    }
-
-    public void setTicketMaxTtlSeconds(long ticketMaxTtlSeconds) {
-        this.ticketMaxTtlSeconds = ticketMaxTtlSeconds;
-    }
-
-    public List<String> getAllowedPathPrefixes() {
-        return allowedPathPrefixes;
-    }
-
-    public void setAllowedPathPrefixes(List<String> allowedPathPrefixes) {
-        this.allowedPathPrefixes = allowedPathPrefixes != null ? allowedPathPrefixes : new ArrayList<>();
-    }
-}

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

@@ -622,6 +622,50 @@ public class AppLoginController2 extends AppBaseController {
 //        return R.error("用户不存在!");
 //    }
 
+    /**
+     * App 微信授权:用移动应用 code 换 openId,写入 fs_user.app_open_id(新商户发红包用)
+     */
+    @Login
+    @ApiOperation("App微信授权写入appOpenId")
+    @PostMapping("/authAppOpenId")
+    public R authAppOpenId(@Validated @RequestBody AppWxAuthParam param) {
+        String userIdStr = getUserId();
+        if (StringUtils.isEmpty(userIdStr)) {
+            return R.error(403, "未登录");
+        }
+        Long userId = Long.parseLong(userIdStr);
+        try {
+            logger.info("App授权appOpenId userId={}, code={}", userId, param.getCode());
+            Map result = WxUtil.getAccessToken(param.getCode(), openProperties.getAppId(), openProperties.getSecret());
+            if (result == null || !result.containsKey("openid")) {
+                return R.error("微信授权失败");
+            }
+            String openid = result.get("openid").toString();
+            Object unionObj = result.get("unionid");
+            String unionid = unionObj == null ? null : unionObj.toString();
+
+            FsUser update = new FsUser();
+            update.setUserId(userId);
+            update.setAppOpenId(openid);
+            if (StringUtils.isNotEmpty(unionid)) {
+                FsUser exist = userService.selectFsUserByUserId(userId);
+                if (exist != null && StringUtils.isEmpty(exist.getUnionId())) {
+                    update.setUnionId(unionid);
+                }
+            }
+            if (userService.updateFsUser(update) <= 0) {
+                return R.error("授权保存失败");
+            }
+            Map<String, Object> data = new HashMap<>();
+            data.put("appOpenId", openid);
+            data.put("appId", openProperties.getAppId());
+            return R.ok(data);
+        } catch (Exception e) {
+            logger.error("App授权appOpenId失败 userId={}: {}", userId, e.getMessage(), e);
+            return R.error("微信授权失败");
+        }
+    }
+
     /**
      * 退出清除jpushId
      */

+ 0 - 7
fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -30,7 +30,6 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
     @Autowired
     FsUserMapper fsUserMapper;
     public static final String USER_KEY = "userId";
-    private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
@@ -46,12 +45,6 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
-        // 业务场景:请求头存在跳过认证标识,直接放行(白名单/票据加固待接口清单确认后再启用)
-        String skipAuth = request.getHeader(SKIP_AUTH_HEADER);
-        if (StringUtils.isNotEmpty(skipAuth)) {
-            return true;
-        }
-
         //获取用户凭证
         String token = request.getHeader(jwtUtils.getHeader());
         if(StringUtils.isBlank(token)){

+ 16 - 0
fs-user-app/src/main/java/com/fs/app/param/AppWxAuthParam.java

@@ -0,0 +1,16 @@
+package com.fs.app.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+/**
+ * App 微信授权(写入 fs_user.app_open_id)
+ */
+@Data
+public class AppWxAuthParam implements Serializable {
+
+    @NotBlank(message = "code不能为空")
+    private String code;
+}

+ 0 - 105
fs-user-app/src/main/java/com/fs/app/utils/SkipAuthTicketUtils.java

@@ -1,105 +0,0 @@
-package com.fs.app.utils;
-
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.util.Locale;
-
-/**
- * X-Skip-Auth 短期票据:{@code userId.expireAtMs.hmacHex}
- * <p>
- * hmac = HMAC-SHA256(secret, userId + "." + expireAtMs) 的小写 hex。
- */
-public final class SkipAuthTicketUtils {
-
-    private static final String HMAC_ALG = "HmacSHA256";
-
-    private SkipAuthTicketUtils() {
-    }
-
-    /**
-     * 内部调用方签发票据。
-     *
-     * @param userId     代操作的用户 ID
-     * @param ttlSeconds 有效期秒数
-     * @param secret     共享密钥
-     */
-    public static String create(long userId, long ttlSeconds, String secret) {
-        if (secret == null || secret.trim().isEmpty()) {
-            throw new IllegalArgumentException("skip-auth secret 未配置");
-        }
-        if (ttlSeconds <= 0) {
-            throw new IllegalArgumentException("ttlSeconds 必须大于 0");
-        }
-        long expireAtMs = System.currentTimeMillis() + ttlSeconds * 1000L;
-        String payload = userId + "." + expireAtMs;
-        return payload + "." + hmacHex(secret, payload);
-    }
-
-    /**
-     * 校验票据,成功返回 userId;失败抛 IllegalArgumentException。
-     *
-     * @param ticket           请求头值
-     * @param secret           共享密钥
-     * @param maxTtlSeconds    允许的最大有效期(防止签发过长)
-     */
-    public static long verifyAndGetUserId(String ticket, String secret, long maxTtlSeconds) {
-        if (ticket == null || ticket.trim().isEmpty()) {
-            throw new IllegalArgumentException("票据为空");
-        }
-        if (secret == null || secret.trim().isEmpty()) {
-            throw new IllegalArgumentException("skip-auth secret 未配置");
-        }
-        String[] parts = ticket.trim().split("\\.");
-        if (parts.length != 3) {
-            throw new IllegalArgumentException("票据格式错误");
-        }
-        final long userId;
-        final long expireAtMs;
-        try {
-            userId = Long.parseLong(parts[0]);
-            expireAtMs = Long.parseLong(parts[1]);
-        } catch (NumberFormatException e) {
-            throw new IllegalArgumentException("票据字段非法");
-        }
-        long now = System.currentTimeMillis();
-        if (expireAtMs < now) {
-            throw new IllegalArgumentException("票据已过期");
-        }
-        // 剩余有效期不得超过配置的最大 TTL(防止签发过长票据)
-        if (maxTtlSeconds > 0 && expireAtMs - now > maxTtlSeconds * 1000L) {
-            throw new IllegalArgumentException("票据有效期超出允许范围");
-        }
-        String payload = parts[0] + "." + parts[1];
-        String expect = hmacHex(secret, payload);
-        if (!constantTimeEquals(expect, parts[2].toLowerCase(Locale.ROOT))) {
-            throw new IllegalArgumentException("票据签名无效");
-        }
-        return userId;
-    }
-
-    private static String hmacHex(String secret, String payload) {
-        try {
-            Mac mac = Mac.getInstance(HMAC_ALG);
-            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALG));
-            byte[] raw = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
-            StringBuilder sb = new StringBuilder(raw.length * 2);
-            for (byte b : raw) {
-                sb.append(String.format(Locale.ROOT, "%02x", b));
-            }
-            return sb.toString();
-        } catch (Exception e) {
-            throw new IllegalStateException("HMAC 计算失败", e);
-        }
-    }
-
-    private static boolean constantTimeEquals(String a, String b) {
-        if (a == null || b == null) {
-            return false;
-        }
-        return MessageDigest.isEqual(
-                a.getBytes(StandardCharsets.UTF_8),
-                b.getBytes(StandardCharsets.UTF_8));
-    }
-}

+ 4 - 15
fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

@@ -51,7 +51,6 @@ public class UserOperationLogAspect {
 
     private final ObjectMapper objectMapper = new ObjectMapper();
     private static final ThreadLocal<FsUserOperationLog> LOG_HOLDER = new ThreadLocal<>();
-    private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
     @Pointcut("@annotation(com.fs.app.annotation.UserOperationLog)")
     public void logPointcut() {}
@@ -117,20 +116,10 @@ public class UserOperationLogAspect {
 
             //用户
             Long userId = null;
-            // 优先从 X-Skip-Auth 请求头取 userId(跳过token校验场景)
-            String skipAuth = ServletUtils.getRequest().getHeader(SKIP_AUTH_HEADER);
-            if (StringUtils.isNotEmpty(skipAuth)) {
-                try {
-                    userId = Long.valueOf(skipAuth);
-                } catch (NumberFormatException ne) {
-                    log.info("X-Skip-Auth 请求头不是有效的userId: {}", skipAuth);
-                }
-            } else {
-                try {
-                    userId = Long.valueOf(jwtUtils.getClaimByToken(ServletUtils.getRequest().getHeader("APPToken")).getSubject().toString());
-                } catch (Exception ie) {
-                    log.info("获取用户id失败");
-                }
+            try {
+                userId = Long.valueOf(jwtUtils.getClaimByToken(ServletUtils.getRequest().getHeader("APPToken")).getSubject().toString());
+            } catch (Exception ie) {
+                log.info("获取用户id失败");
             }
             if (userId == null) {
                 LOG_HOLDER.set(operationLog);

+ 0 - 1
fs-user-app/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -13,7 +13,6 @@ import org.springframework.security.web.firewall.StrictHttpFirewall;
  * <p>
  * App 业务鉴权由 {@code @Login} + AuthorizationInterceptor 完成,不走 Spring Security JWT。
  * 因此此处放行全部 HTTP,敏感接口必须打 {@code @Login}。
- * 内部业务可通过 {@code X-Skip-Auth} 请求头跳过 Token 校验(仅限受信调用方)。
  */
 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter

+ 7 - 0
fs-wx-ipad-task/src/main/resources/bat/hdt-6.bat

@@ -0,0 +1,7 @@
+@echo off
+CLS
+color 0a
+TITLE fs-wx-ipad-task-6
+if exist C:\Tools\jar\load_env.bat call C:\Tools\jar\load_env.bat
+java -Xms256m -Xmx512m -XX:MaxMetaspaceSize=256m -jar C:\Tools\jar\fs-wx-ipad-task.jar --spring.profiles.active=druid-hdt,config-druid-hdt,common --server.port=9006 --group-no=6 --cloud_host.volcengineUrl=https://bdhdtvolcengine.ylrztop.com --cloud_host.spaceName=bdhdt-2114522511
+pause

+ 4 - 0
pom.xml

@@ -282,6 +282,10 @@
         <module>fs-repeat-api</module>
         <module>fs-ipad-task</module>
         <module>fs-websocket</module>
+        <module>fs-ai-call-task</module>
+        <module>fs-cid-workflow</module>
+        <module>fs-wx-task</module>
+        <module>fs-wx-ipad-task</module>
     </modules>
 
     <packaging>pom</packaging>