瀏覽代碼

销售易

wjj 1 天之前
父節點
當前提交
3ba80b50c5
共有 67 個文件被更改,包括 5333 次插入106 次删除
  1. 58 0
      fs-admin/src/main/java/com/fs/his/task/Task.java
  2. 1 0
      fs-admin/src/main/java/com/fs/web/controller/system/SysConfigController.java
  3. 30 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  4. 5 1
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  5. 18 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java
  6. 5 0
      fs-company/src/main/java/com/fs/company/controller/course/FsCoursePlaySourceConfigController.java
  7. 28 0
      fs-company/src/main/java/com/fs/company/utils/CompanyAccessUtils.java
  8. 96 0
      fs-company/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java
  9. 61 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiOAuthCallbackController.java
  10. 108 58
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  11. 9 0
      fs-service/src/main/java/com/common/RedisKeyCommon.java
  12. 9 0
      fs-service/src/main/java/com/fs/company/domain/CompanyUser.java
  13. 6 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java
  14. 12 0
      fs-service/src/main/java/com/fs/company/param/CourseAppIdParam.java
  15. 5 0
      fs-service/src/main/java/com/fs/company/service/ICompanyUserService.java
  16. 37 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyUserServiceImpl.java
  17. 10 0
      fs-service/src/main/java/com/fs/company/vo/CompanyUserQwListVO.java
  18. 14 0
      fs-service/src/main/java/com/fs/company/vo/CourserAppIdVO.java
  19. 5 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  20. 10 0
      fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java
  21. 3 0
      fs-service/src/main/java/com/fs/course/mapper/FsCoursePlaySourceConfigMapper.java
  22. 12 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  23. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsCoursePlaySourceConfigService.java
  24. 5 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCoursePlaySourceConfigServiceImpl.java
  25. 35 2
      fs-service/src/main/java/com/fs/kdzl/service/test.java
  26. 2 0
      fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java
  27. 2 0
      fs-service/src/main/java/com/fs/qw/vo/QwSopTempSetting.java
  28. 13 0
      fs-service/src/main/java/com/fs/sop/domain/QwSopLogs.java
  29. 8 0
      fs-service/src/main/java/com/fs/sop/mapper/QwSopLogsMapper.java
  30. 182 43
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  31. 59 0
      fs-service/src/main/java/com/fs/system/domain/vo/WatchLogKeyVo.java
  32. 14 0
      fs-service/src/main/java/com/fs/system/domain/vo/WatchLogScanBounds.java
  33. 1825 0
      fs-service/src/main/java/com/fs/system/service/ReplaceMiniAppHandleService.java
  34. 59 0
      fs-service/src/main/java/com/fs/system/support/MiniAppReplaceSupport.java
  35. 92 0
      fs-service/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java
  36. 61 0
      fs-service/src/main/java/com/fs/xiaoshouyi/config/XiaoShouYiProperties.java
  37. 29 0
      fs-service/src/main/java/com/fs/xiaoshouyi/constant/XiaoShouYiRedisKey.java
  38. 131 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialRequest.java
  39. 32 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialResponse.java
  40. 32 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialWithUploadResponse.java
  41. 22 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/FullProcessResult.java
  42. 17 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/GenerateLinkRequest.java
  43. 33 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/GenerateLinkResponse.java
  44. 15 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialFileInfo.java
  45. 145 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialItem.java
  46. 20 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialPageData.java
  47. 10 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialTrackItem.java
  48. 97 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/QueryMaterialRequest.java
  49. 20 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/QueryMaterialResponse.java
  50. 19 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/SendConfirmResponse.java
  51. 39 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/TokenInfo.java
  52. 30 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/TokenResponse.java
  53. 9 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/TrackLinkInfo.java
  54. 56 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/UploadMaterialFileResponse.java
  55. 50 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/XiaoShouYiAuthStatusVO.java
  56. 15 0
      fs-service/src/main/java/com/fs/xiaoshouyi/enums/XiaoShouYiAuthStatus.java
  57. 39 0
      fs-service/src/main/java/com/fs/xiaoshouyi/enums/XiaoShouYiForwardType.java
  58. 11 0
      fs-service/src/main/java/com/fs/xiaoshouyi/exception/XiaoShouYiSendVideoLimitExceededException.java
  59. 669 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiMaterialService.java
  60. 489 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiOAuthService.java
  61. 275 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiTrackLinkCacheService.java
  62. 14 0
      fs-service/src/main/java/com/fs/xiaoshouyi/vo/LinkParamVo.java
  63. 1 1
      fs-service/src/main/resources/application-druid-syysy.yml
  64. 9 1
      fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml
  65. 38 0
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  66. 4 0
      fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml
  67. 62 0
      fs-service/src/main/resources/mapper/sop/QwSopLogsMapper.xml

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

@@ -1,5 +1,6 @@
 package com.fs.his.task;
 
+import cn.binarywang.wx.miniapp.api.WxMaService;
 import cn.hutool.core.date.DateTime;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.json.JSONUtil;
@@ -10,6 +11,7 @@ import com.fs.app.domain.FsAppRole;
 import com.fs.app.mapper.FsAppRoleMapper;
 import com.fs.common.exception.CustomException;
 import com.fs.company.service.ICompanySmsService;
+import com.fs.core.config.WxMaConfiguration;
 import com.fs.core.utils.OrderCodeUtils;
 import com.fs.doctor.service.IFsDoctorOnlineService;
 import com.fs.fastGpt.domain.FastgptChatVoiceHomo;
@@ -96,8 +98,11 @@ import com.fs.wx.domain.FsWxExpressTask;
 import com.fs.wx.dto.*;
 import com.fs.wx.mapper.FsWxExpressTaskMapper;
 import com.fs.wx.service.ShippingService;
+import com.fs.xiaoshouyi.dto.TokenResponse;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
 import com.google.gson.Gson;
 import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -313,6 +318,9 @@ public class Task {
     @Autowired
     private IFsCompanyExternalPayReceiptService companyExternalPayReceiptService;
 
+    @Autowired
+    private XiaoShouYiOAuthService xiaoShouYiOAuthService;
+
     public static final String SOP_TEMP_VOICE_KEY = "sop:tempVoice";
 
     // sop升单客户类型
@@ -2568,4 +2576,54 @@ public class Task {
         companyExternalPayReceiptService.syncReceipts(beginTime,endTime,null,qwCompanyList);
     }
 
+    public void checkMainAppId(){
+        List<String> mainAppIdGroup = companyUserMapper.selectCompanyUserMainAppIdGroup();
+        if (CollectionUtils.isEmpty(mainAppIdGroup)) {
+            return;
+        }
+        for (String mainAppId : mainAppIdGroup) {
+            try {
+                final WxMaService wxMaService = WxMaConfiguration.getMaService(mainAppId);
+                String scene="collectionId";
+                wxMaService.getQrcodeService().createWxaCodeUnlimitBytes(
+                        scene,
+                        "pages_course/customerStatistics",
+                        true,
+                        "release",//trial - 体验版  release - 正式版  develop - 开发版
+                        430,
+                        true,
+                        null,
+                        false);
+            } catch (WxErrorException e) {
+                int errorCode = e.getError().getErrorCode();
+                if (errorCode == 50002) {
+                    // 50002 业务逻辑处理
+                    List<CompanyUser> companyUsers = companyUserMapper.selectCompanyUserListByMainAppId(mainAppId);
+                    List<CompanyUser> mapUsers = new ArrayList<>();
+                    for (CompanyUser companyUser : companyUsers) {
+
+                    }
+                }
+                logger.info("生成二维码失败,mainAppId: {}, errorCode: {}, 原因: {}", mainAppId, errorCode, e.getMessage());
+            } catch (Exception e) {
+                logger.info("生成二维码失败,mainAppId: {}, 原因: {}", mainAppId, e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 销售易 OAuth 凭证保活(由总后台 sys_job 定时调度)
+     */
+    public void refreshXiaoShouYiOAuthToken() {
+        log.info("开始执行销售易OAuth凭证保活定时任务");
+        try {
+            TokenResponse response = xiaoShouYiOAuthService.proactiveRefreshToken();
+            if (!response.isSuccess()) {
+                log.warn("销售易OAuth凭证保活未成功:{}", response.getError());
+            }
+        } catch (Exception e) {
+            log.error("销售易OAuth凭证保活异常", e);
+        }
+    }
+
 }

+ 1 - 0
fs-admin/src/main/java/com/fs/web/controller/system/SysConfigController.java

@@ -106,6 +106,7 @@ public class SysConfigController extends BaseController
     @PreAuthorize("@ss.hasPermi('system:config:edit')")
     @Log(title = "参数管理", businessType = BusinessType.UPDATE)
     @PutMapping
+    @RepeatSubmit
     public AjaxResult edit(@Validated @RequestBody SysConfig config)
     {
         if (UserConstants.NOT_UNIQUE.equals(configService.checkConfigKeyUnique(config)))

+ 30 - 0
fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java

@@ -0,0 +1,30 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 销售易 Token 管理(总后台点播配置使用)
+ */
+@RestController
+@RequestMapping("/xiaoShouYi")
+@RequiredArgsConstructor
+public class XiaoShouYiController {
+
+    private final XiaoShouYiOAuthService oAuthService;
+
+    /**
+     * 获取 Token 状态;refresh=true 时先尝试刷新凭证再返回
+     */
+    @PreAuthorize("@ss.hasPermi('system:config:query')")
+    @GetMapping("/token")
+    public AjaxResult getToken(@RequestParam(value = "refresh", defaultValue = "false") boolean refresh) {
+        return AjaxResult.success(oAuthService.getTokenStatus(refresh));
+    }
+}

+ 5 - 1
fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java

@@ -226,7 +226,7 @@ public class RedisCache
     }
 
 
-    public Boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) {
+    public Boolean setIfAbsent(String key, final Object value, long timeout, TimeUnit unit) {
         return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
     }
 
@@ -324,4 +324,8 @@ public class RedisCache
             return members;
         });
     }
+
+    public boolean hasKey(String key) {
+        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
+    }
 }

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

@@ -20,6 +20,7 @@ import com.fs.company.domain.*;
 import com.fs.company.param.CompanyUserAreaParam;
 import com.fs.company.param.CompanyUserCodeParam;
 import com.fs.company.param.CompanyUserQwParam;
+import com.fs.company.param.CourseAppIdParam;
 import com.fs.company.service.*;
 import com.fs.company.utils.DomainUtil;
 import com.fs.company.vo.CompanyUserImportVO;
@@ -733,4 +734,21 @@ public class CompanyUserController extends BaseController
         int rows = fsCompanyCustomerService.transferCustomer(param);
         return toAjax(rows);
     }
+
+    /**
+     * 获取销售主备看课小程序信息
+     */
+    @GetMapping("/getCourseAppIdInfo")
+    public AjaxResult getCourseAppIdInfo(@RequestParam("userId") Long userId)
+    {
+        return AjaxResult.success(companyUserService.getCourserAppId(userId));
+    }
+
+    /**
+     * 绑定销售主备看课小程序信息
+     */
+    @PostMapping("/bindCourserAppId")
+    public AjaxResult bindCourserAppId(@RequestBody CourseAppIdParam param) {
+        return toAjax(companyUserService.bindCourserAppId(param));
+    }
 }

+ 5 - 0
fs-company/src/main/java/com/fs/company/controller/course/FsCoursePlaySourceConfigController.java

@@ -62,6 +62,11 @@ public class FsCoursePlaySourceConfigController extends BaseController {
         return AjaxResult.success(optionsVOS);
     }
 
+    @GetMapping("/allList")
+    public AjaxResult allList(){
+        return AjaxResult.success(fsCoursePlaySourceConfigService.allList());
+    }
+
     @PreAuthorize("@ss.hasPermi('course:playSourceConfig:query')")
     @GetMapping(value = "/{id}")
     public AjaxResult getInfo(@PathVariable Long id) {

+ 28 - 0
fs-company/src/main/java/com/fs/company/utils/CompanyAccessUtils.java

@@ -0,0 +1,28 @@
+package com.fs.company.utils;
+
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.exception.CustomException;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.security.SecurityUtils;
+
+
+public final class CompanyAccessUtils {
+
+    private CompanyAccessUtils() {
+    }
+
+    public static Long currentCompanyId() {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (loginUser == null || loginUser.getCompany() == null) {
+            throw new CustomException("δ��¼", HttpStatus.UNAUTHORIZED);
+        }
+        return loginUser.getCompany().getCompanyId();
+    }
+
+    public static void assertCompanyResource(Long resourceCompanyId) {
+        Long current = currentCompanyId();
+        if (resourceCompanyId == null || !current.equals(resourceCompanyId)) {
+            throw new CustomException("�Ƿ�����", HttpStatus.FORBIDDEN);
+        }
+    }
+}

+ 96 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java

@@ -0,0 +1,96 @@
+package com.fs.xiaoshouyi.client;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.util.Map;
+
+@Slf4j
+@Component
+public class XiaoShouYiHttpClient {
+
+    private static final int TIMEOUT = 30000;
+
+    public HttpResult postForm(String url, Map<String, Object> form) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .form(form)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postForm请求异常, url={}", url, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public HttpResult postJson(String url, String authHeader, Object bodyObj) {
+        String bodyJson = JSONUtil.toJsonStr(bodyObj);
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .body(bodyJson)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postJson请求异常, url={}, body={}", url, bodyJson, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public HttpResult get(String url, String authHeader) {
+        try (HttpResponse response = HttpRequest.get(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易get请求异常, url={}", url, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * multipart/form-data 上传文件
+     */
+    public HttpResult postMultipart(String url, String authHeader, File file, Boolean isVideo) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Authorization", authHeader)
+                .form("file", file)
+                .form("isVideo", isVideo == null ? Boolean.FALSE : isVideo)
+                .timeout(TIMEOUT)
+                .execute()) {
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postMultipart请求异常, url={}, file={}, isVideo={}",
+                    url, file == null ? null : file.getAbsolutePath(), isVideo, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public static class HttpResult {
+        private final int status;
+        private final String body;
+
+        public HttpResult(int status, String body) {
+            this.status = status;
+            this.body = body;
+        }
+
+        public int getStatus() {
+            return status;
+        }
+
+        public String getBody() {
+            return body;
+        }
+    }
+}

+ 61 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiOAuthCallbackController.java

@@ -0,0 +1,61 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.xiaoshouyi.dto.TokenResponse;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.view.RedirectView;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 销售易 OAuth 回调(Token 写入 Redis,供业务服务读取)
+ */
+@Slf4j
+@RestController
+@RequestMapping("/xiaoShouYi/New/auth")
+@RequiredArgsConstructor
+public class XiaoShouYiOAuthCallbackController {
+    Logger logger= LoggerFactory.getLogger(getClass());
+    private final XiaoShouYiOAuthService oAuthService;
+
+    @GetMapping("/callback")
+    public RedirectView authCallback(@RequestParam("code") String code) {
+        logger.info("收到销售易授权回调, code={}", code);
+
+        TokenResponse tokenResp = oAuthService.exchangeCodeForToken(code);
+        if (tokenResp.isSuccess()) {
+            logger.info("授权成功");
+            return new RedirectView("/prod-api/xiaoShouYi/New/auth/success");
+        }
+
+        logger.error("授权失败:{}", tokenResp.getError());
+        return new RedirectView("/prod-api/xiaoShouYi/New/auth/error?msg=" + urlEncode(tokenResp.getError()));
+    }
+
+    @GetMapping("/success")
+    public String success() {
+        return "授权成功,可以开始使用素材追踪功能";
+    }
+
+    @GetMapping("/error")
+    public String error(@RequestParam(required = false) String msg) {
+        return "授权失败,错误信息: " + msg;
+    }
+
+    private String urlEncode(String value) {
+        try {
+            return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            throw new RuntimeException("URL编码失败", e);
+        }
+    }
+}

+ 108 - 58
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -49,6 +49,7 @@ import com.fs.sop.vo.SopUserLogsVo;
 import com.fs.system.service.ISysConfigService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.fs.voice.utils.StringUtil;
+import com.fs.xiaoshouyi.service.XiaoShouYiTrackLinkCacheService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -79,7 +80,7 @@ import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
 @Slf4j
 public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
-
+    private static final String gjminiappLink = "/pages/course/learning?course=";
     private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
     private static final String signArticleLink = "/pages_course/article.html?link=";
     private static final String shareMaterialLink = "/pages_course/customerStatistics.html?link=";
@@ -112,6 +113,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     // Batch size for database inserts, configurable via application.properties
     private final int BATCH_SIZE = 500;
 
+    @Autowired
+    private XiaoShouYiTrackLinkCacheService xiaoShouYiTrackLinkCacheService;
     @Autowired
     private IFsCourseLinkService courseLinkService;
     @Autowired
@@ -1008,66 +1011,86 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             switch (setting.getContentType()) {
                 //文字和短链一起
                 case "1":
+                    String setTxt = StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText;
+                    setting.setValue(setting.getValue()
+                            .replaceAll("#客服称呼#", setTxt)
+                            .replaceAll("#销售称呼#", setTxt)
+                    );
+                    break;
                 case "3":
-                    if ("1".equals(setting.getIsBindUrl())) {
-                        String link;
-                        if (isGroupChat) {
-                            FsCourseLinkCreateParam createParam = new FsCourseLinkCreateParam();
-                            createParam.setCourseId(courseId);
-                            createParam.setVideoId(videoId);
-                            createParam.setCorpId(logVo.getCorpId());
-                            createParam.setCompanyUserId(Long.parseLong(companyUserId));
-                            createParam.setCompanyId(Long.parseLong(companyId));
-                            createParam.setChatId(logVo.getChatId());
-                            createParam.setQwUserId(Long.valueOf(qwUserId));
-                            createParam.setDays(setting.getExpiresDays());
-                            R createLink = courseLinkService.createRoomLinkUrl(createParam);
-                            if (createLink.get("code").equals(500)) {
-                                throw new BaseException("链接生成失败!");
-                            }
-                            try {
-                                groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
-                                    Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
-                                    GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
-                                    if (vo != null && vo.getId() != null) {
-                                        sopLogs.setFsUserId(vo.getFsUserId());
-                                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo);
-                                    }
-                                });
-                            } catch (Exception e) {
-                                log.error("群聊创建看课记录失败!", e);
-                            }
-                            link = (String) createLink.get("url");
-                        } else {
-                            addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo);
-                            link = generateShortLink(setting, logVo, sendTime, courseId, videoId,
-                                    qwUserId, companyUserId, companyId, externalId,isOfficial,sopLogs.getFsUserId());
-                        }
-
-                        if (StringUtils.isNotEmpty(link)) {
-                            if ("3".equals(setting.getContentType())) {
-                                setting.setLinkUrl(link);
-                            } else {
-                                String currentValue = setting.getValue();
-                                if (currentValue == null) {
-                                    setting.setValue(link);
-                                } else {
-                                    setting.setValue(currentValue
-                                            .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText)
-                                            + "\n" + link);
-                                }
-                            }
-                        } else {
-                            log.error("生成短链失败,跳过设置 URL。");
-                        }
-
-                    } else {
-                        if ("1".equals(setting.getContentType())) {
-                            setting.setValue(setting.getValue()
-                                    .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText));
-                        }
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo);
+
+                    Map<String, String> linkMap = createH5GjLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
+                            qwUserId, companyUserId, companyId, Long.parseLong(externalId), cachedCourseConfig);
+                    String link = linkMap.get("url");
+                    String linkNo = linkMap.get("link");
+                    String xsyRedirectUrl = xiaoShouYiTrackLinkCacheService.applySopTrackRedirectUrl(
+                            sopLogs, sopLogs.getCorpId(), companyUserId, qwUserId, courseId, videoId,
+                            externalId, link, setting.getLinkImageUrl(), setting.getLinkTitle(),
+                            setting.getSmsTemplateTitle(), setting.getSmsTemplateContent(), linkNo);
+                    if (StringUtils.isNotEmpty(xsyRedirectUrl)) {
+                        setting.setXsyLinkUrl(xsyRedirectUrl);
+                        setting.setLinkUrl(xsyRedirectUrl);
                     }
                     break;
+//                    if ("1".equals(setting.getIsBindUrl())) {
+//                        String link;
+//                        if (isGroupChat) {
+//                            FsCourseLinkCreateParam createParam = new FsCourseLinkCreateParam();
+//                            createParam.setCourseId(courseId);
+//                            createParam.setVideoId(videoId);
+//                            createParam.setCorpId(logVo.getCorpId());
+//                            createParam.setCompanyUserId(Long.parseLong(companyUserId));
+//                            createParam.setCompanyId(Long.parseLong(companyId));
+//                            createParam.setChatId(logVo.getChatId());
+//                            createParam.setQwUserId(Long.valueOf(qwUserId));
+//                            createParam.setDays(setting.getExpiresDays());
+//                            R createLink = courseLinkService.createRoomLinkUrl(createParam);
+//                            if (createLink.get("code").equals(500)) {
+//                                throw new BaseException("链接生成失败!");
+//                            }
+//                            try {
+//                                groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+//                                    Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+//                                    GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+//                                    if (vo != null && vo.getId() != null) {
+//                                        sopLogs.setFsUserId(vo.getFsUserId());
+//                                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo);
+//                                    }
+//                                });
+//                            } catch (Exception e) {
+//                                log.error("群聊创建看课记录失败!", e);
+//                            }
+//                            link = (String) createLink.get("url");
+//                        } else {
+//                            addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo);
+//                            link = generateShortLink(setting, logVo, sendTime, courseId, videoId,
+//                                    qwUserId, companyUserId, companyId, externalId,isOfficial,sopLogs.getFsUserId());
+//                        }
+//
+//                        if (StringUtils.isNotEmpty(link)) {
+//                            if ("3".equals(setting.getContentType())) {
+//                                setting.setLinkUrl(link);
+//                            } else {
+//                                String currentValue = setting.getValue();
+//                                if (currentValue == null) {
+//                                    setting.setValue(link);
+//                                } else {
+//                                    setting.setValue(currentValue
+//                                            .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText)
+//                                            + "\n" + link);
+//                                }
+//                            }
+//                        } else {
+//                            log.error("生成短链失败,跳过设置 URL。");
+//                        }
+//
+//                    } else {
+//                        if ("1".equals(setting.getContentType())) {
+//                            setting.setValue(setting.getValue()
+//                                    .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(welcomeText) ? "" : welcomeText));
+//                        }
+//                    }
                 //小程序单独
                 case "4":
 
@@ -2344,7 +2367,34 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         return externalContact.getStatus() == 0 || externalContact.getStatus() == 2 || externalContact.getStatus() == 3;
     }
 
+    private Map<String, String> createH5GjLinkByMiniApp(QwSopTempSetting.Content.Setting setting, String corpId, Date sendTime,
+                                                        Long courseId, Long videoId, String qwUserId,
+                                                        String companyUserId, String companyId, Long externalId, CourseConfig config) {
+
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId.toString(), 3);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
 
+        String realLinkFull = gjminiappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+        //存短链-
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
     public String createActivityLinkByMiniApp(QwSopTempSetting.Content.Setting st, QwSopLogs sopLogs, String corpId, Date sendTime, Long courseId, Long videoId, String qwUserId, String companyUserId, String companyId, CourseConfig config, String chatId) {
 
         FsCourseLink link = createActivityByLink(corpId, sendTime, courseId, videoId, qwUserId.toString(),

+ 9 - 0
fs-service/src/main/java/com/common/RedisKeyCommon.java

@@ -0,0 +1,9 @@
+package com.common;
+
+public class RedisKeyCommon {
+
+    /**
+     * 小程序替换key + {yyyy-MM-dd},vue为当天所有更换兜底小程序的id集合,不包含最新的兜底小程序id
+     */
+    public static final String QW_MINAPP_REPLACE_KEY = "fs_qw_miniapp_replace:";
+}

+ 9 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyUser.java

@@ -190,6 +190,15 @@ public class CompanyUser extends BaseEntity
     private Long cidServerId;
 
     private Long aiSipCallUserId;
+    /**
+     * 主小程序id
+     */
+    private String mainAppId;
+
+    /**
+     * 备用小程序id
+     */
+    private String backupAppId;
 
     public Long getAiSipCallUserId() {
         return aiSipCallUserId;

+ 6 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java

@@ -340,4 +340,10 @@ public interface CompanyUserMapper
 
     //根据医生id获取医生对应的销售
     List<CompanyUser> selectCompanyUserListByDoctorId(Long doctorId);
+
+    @Select("SELECT DISTINCT main_app_id FROM company_user WHERE main_app_id IS NOT NULL AND backup_app_id IS NOT NULL ")
+    List<String> selectCompanyUserMainAppIdGroup();
+
+    @Select("SELECT * FROM company_user WHERE main_app_id IS NOT NULL AND backup_app_id IS NOT NULL AND main_app_id = #{mainAppId}")
+    List<CompanyUser> selectCompanyUserListByMainAppId(@Param("mainAppId") String mainAppId);
 }

+ 12 - 0
fs-service/src/main/java/com/fs/company/param/CourseAppIdParam.java

@@ -0,0 +1,12 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class CourseAppIdParam {
+    private Long userId;
+    private String mainAppId;
+    private List<String> backupAppId;
+}

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

@@ -5,6 +5,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.param.CompanyUserAreaParam;
 import com.fs.company.param.CompanyUserCodeParam;
 import com.fs.company.param.CompanyUserQwParam;
+import com.fs.company.param.CourseAppIdParam;
 import com.fs.company.vo.*;
 import com.fs.his.vo.CitysAreaVO;
 import com.fs.his.vo.OptionsVO;
@@ -253,4 +254,8 @@ public interface ICompanyUserService {
 
     //根据医生id获取绑定医生的销售列表
     List<CompanyUser> selectCompanyUserListByDoctorId(Long doctorId);
+
+    CourserAppIdVO getCourserAppId(Long userId);
+
+    int bindCourserAppId(CourseAppIdParam param);
 }

+ 37 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyUserServiceImpl.java

@@ -20,6 +20,7 @@ import com.fs.company.mapper.*;
 import com.fs.company.param.CompanyUserAreaParam;
 import com.fs.company.param.CompanyUserCodeParam;
 import com.fs.company.param.CompanyUserQwParam;
+import com.fs.company.param.CourseAppIdParam;
 import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.vo.*;
@@ -1111,4 +1112,40 @@ public class CompanyUserServiceImpl implements ICompanyUserService
         }
         return companyUserList;
     }
+
+    @Override
+    public CourserAppIdVO getCourserAppId(Long userId) {
+        CourserAppIdVO vo = new CourserAppIdVO();
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(userId);
+        if (companyUser != null) {
+            vo.setUserId(companyUser.getUserId());
+            vo.setMainAppId(companyUser.getMainAppId());
+            if (StringUtils.isNotEmpty(companyUser.getBackupAppId())) {
+                vo.setBackupAppId(Arrays.asList(companyUser.getBackupAppId().split(",")));
+            }
+            return vo;
+        } else {
+            throw new CustomException("销售员工不存在");
+        }
+    }
+
+    @Override
+    public int bindCourserAppId(CourseAppIdParam param) {
+        if (param != null && param.getUserId() != null) {
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserById(param.getUserId());
+            if (companyUser != null) {
+                if (param.getBackupAppId().contains(param.getMainAppId())) {
+                    throw new CustomException("主appId不能在备appId中");
+                }
+                companyUser.setUserId(companyUser.getUserId());
+                companyUser.setMainAppId(param.getMainAppId());
+                companyUser.setBackupAppId(String.join(",", param.getBackupAppId()));
+                return companyUserMapper.updateCompanyUser(companyUser);
+            } else {
+                throw new CustomException("销售员工不存在");
+            }
+        } else {
+            throw new CustomException("参数错误");
+        }
+    }
 }

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

@@ -145,4 +145,14 @@ public class CompanyUserQwListVO extends BaseEntity {
      */
     private Long aiSipCallUserId;
 
+    /**
+     * 主小程序id
+     */
+    private String mainAppId;
+
+    /**
+     * 备用小程序id
+     */
+    private String backupAppId;
+
 }

+ 14 - 0
fs-service/src/main/java/com/fs/company/vo/CourserAppIdVO.java

@@ -0,0 +1,14 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class CourserAppIdVO {
+    private Long userId;
+
+    private String mainAppId;
+
+    private List<String> backupAppId;
+}

+ 5 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -78,6 +78,11 @@ public class CourseConfig implements Serializable {
      */
     private Boolean isOpenIM;
 
+    /**
+     * 外网通用看课链接域名
+     */
+    private String realLinkGjDomainName;
+
 
     @Data
     public static class DisabledTimeVo{

+ 10 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java

@@ -84,6 +84,9 @@ public class FsUserCourseVideo extends BaseEntity
 
 
     private String packageJson;//绑定疗法
+    /**
+     * 是否营销,0-否,1-是(销售易发课仅允许 isFirst=1 的小节)
+     */
     private Integer isFirst;
     private Integer isTranscode;//是否转码
 
@@ -112,5 +115,12 @@ public class FsUserCourseVideo extends BaseEntity
 
     private Long listingEndTime;//商品结束售卖时间
 
+    /**
+     * 是否公开课,0-否,1-是
+     */
+    private Integer openClass;
+
+
+
 
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCoursePlaySourceConfigMapper.java

@@ -19,4 +19,7 @@ public interface FsCoursePlaySourceConfigMapper extends BaseMapper<FsCoursePlayS
 
     @Select("SELECT appid dict_value1, `name` dict_label FROM fs_course_play_source_config WHERE is_mall = 1 AND is_del = 0")
     List<OptionsVO> selectCoursePlaySourceConfigVOList();
+
+    @Select("SELECT appid dict_value1, `name` dict_label FROM fs_course_play_source_config WHERE is_del = 0")
+    List<OptionsVO> allList();
 }

+ 12 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java

@@ -11,6 +11,7 @@ import com.fs.course.vo.*;
 import com.fs.im.dto.OpenImBatchResponseDataDTO;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.sop.vo.QwRatingVO;
+import com.fs.system.domain.vo.WatchLogScanBounds;
 import org.apache.ibatis.annotations.Delete;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -599,4 +600,15 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
 
     List<FsSopMyCourseH5LinkVO> getSopCourseH5StudyListByQwExId(@Param("qwExternalId") Long qwExternalId);
 
+    WatchLogScanBounds selectWatchLogScanBounds(@Param("startTime") String startTime);
+
+    List<Long> selectLogIdsAtRowStep(@Param("startTime") String startTime,
+                                     @Param("stepSize") long stepSize,
+                                     @Param("totalCount") long totalCount);
+
+    List<FsCourseWatchLog> selectWatchLogsByCursorUpTo(@Param("startOfDay") String startOfDay,
+                                                       @Param("lastLogId") Long lastLogId,
+                                                       @Param("batchSize") int batchSize,
+                                                       @Param("maxId") Long maxId);
+
 }

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

@@ -16,4 +16,6 @@ public interface IFsCoursePlaySourceConfigService extends IService<FsCoursePlayS
     List<FsCoursePlaySourceConfigVO> selectCoursePlaySourceConfigVOListByMap(Map<String, Object> params);
 
     List<OptionsVO> selectCoursePlaySourceConfigVOList();
+
+    List<OptionsVO> allList();
 }

+ 5 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCoursePlaySourceConfigServiceImpl.java

@@ -30,4 +30,9 @@ public class FsCoursePlaySourceConfigServiceImpl extends ServiceImpl<FsCoursePla
     public List<OptionsVO> selectCoursePlaySourceConfigVOList() {
         return baseMapper.selectCoursePlaySourceConfigVOList();
     }
+
+    @Override
+    public List<OptionsVO> allList() {
+        return baseMapper.allList();
+    }
 }

+ 35 - 2
fs-service/src/main/java/com/fs/kdzl/service/test.java

@@ -1,12 +1,45 @@
 package com.fs.kdzl.service;
 
+import cn.binarywang.wx.miniapp.api.WxMaService;
+import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl;
+import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl;
+import com.fs.core.config.WxMaConfiguration;
 import com.fs.kdzl.service.impl.KdzlServiceImpl;
+import me.chanjar.weixin.common.error.WxErrorException;
 
 public class test {
     static KdzlServiceImpl kdzlService = new KdzlServiceImpl();
+    private static WxMaConfiguration wxMaConfiguration;
     public static void main(String[] args) {
         //System.out.println(kdzlService.getAccessToken());
-        //System.out.println(kdzlService.exportCustomers());
-        //kdzlService.exportCustomers(12000,2000);
+        //kdzlService.exportHighSeasCustomers(null);
+        //System.out.println(kdzlService.exportCallRecords());
+
+        WxMaDefaultConfigImpl maConfig = new WxMaDefaultConfigImpl();
+        maConfig.setAppid("wxd6a0f949330413ee");
+        maConfig.setSecret("31941959b53525cf6fdd32264e95ca5f");
+        maConfig.setToken("cbnd7lJvkripVOpyTFAna6NAWCxCrvC");
+        maConfig.setAesKey("HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E");
+        maConfig.setMsgDataFormat("JSON");
+
+        WxMaService service = new WxMaServiceImpl();
+        service.setWxMaConfig(maConfig);
+
+        String scene="collectionId";
+        try {
+            service.getQrcodeService().createWxaCodeUnlimitBytes(scene,
+                    "pages_order/confirmUserinfo",
+                    true,
+                    "release",//release trial
+                    430,
+                    true,
+                    null,
+                    false);
+        } catch (WxErrorException e) {
+            System.out.println("aaaaa"+e);
+            System.out.println("bbbbbbbb: "+e.getMessage());
+            int errorCode = e.getError().getErrorCode();
+            System.out.println("cccccccc: "+errorCode);
+        }
     }
 }

+ 2 - 0
fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java

@@ -93,6 +93,8 @@ public class QwSopCourseFinishTempSetting implements Serializable,Cloneable{
         private String linkImageUrl;
         //链接地址
         private String linkUrl;
+        //销售易追踪链接
+        private String xsyLinkUrl;
         //发送app消息的链接
         private String appLinkUrl;
         //文件地址

+ 2 - 0
fs-service/src/main/java/com/fs/qw/vo/QwSopTempSetting.java

@@ -100,6 +100,8 @@ public class QwSopTempSetting implements Serializable{
             private String linkImageUrl;
             //链接地址
             private String linkUrl;
+            //销售易追踪链接
+            private String xsyLinkUrl;
             //发送app消息的链接
             private String appLinkUrl;
 

+ 13 - 0
fs-service/src/main/java/com/fs/sop/domain/QwSopLogs.java

@@ -87,6 +87,7 @@ public class QwSopLogs implements Serializable {
     private Integer sort;
     private Long ms;
     private Long qwUserKey;
+    private String memo;
 
     /**
      * 官方群发的限制 1官方+补发   2官方+一对一通连
@@ -105,6 +106,18 @@ public class QwSopLogs implements Serializable {
      */
     private Long smsLogsId;
 
+    /**
+     * 发送的小程序id
+     */
+    private String sendMiniAppId;
+
+    /**
+     * app发送状态0未发送1成功2失败3不发送
+     */
+    private int appSendStatus;
+
+    private String appSendRemark;
+
     // 构造函数
 //    public QwSopLogs() {
 //        this.id = UUID.randomUUID().toString();

+ 8 - 0
fs-service/src/main/java/com/fs/sop/mapper/QwSopLogsMapper.java

@@ -9,6 +9,7 @@ import com.fs.sop.params.*;
 import com.fs.sop.vo.QwSopLogsDoSendListTVO;
 import com.fs.sop.vo.QwSopLogsListCVO;
 import com.fs.sop.vo.QwSopLogsListVOByUserInfo;
+import com.fs.system.domain.vo.WatchLogKeyVo;
 import org.apache.ibatis.annotations.MapKey;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -357,4 +358,11 @@ public interface QwSopLogsMapper extends BaseMapper<QwSopLogs> {
 
     @DataSource(DataSourceType.SOP)
     List<QwSopLogs> getQwSopInfoByUid(@Param("sopSmsLogIds") List<Long> sopSmsLogIds);
+
+    @DataSource(DataSourceType.SOP)
+    List<QwSopLogs> selectByKeysForAppId(@Param("list") List<WatchLogKeyVo> keys,
+                                         @Param("startTime") String startTime,
+                                         @Param("oldAppId") String oldAppId);
+    @DataSource(DataSourceType.SOP)
+    int batchUpdateQwSopLogsByIdInMiniAppId(@Param("list") List<QwSopLogs> preparedBatch);
 }

+ 182 - 43
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -60,6 +60,7 @@ import com.fs.sop.vo.*;
 import com.fs.system.service.ISysConfigService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.fs.voice.utils.StringUtil;
+import com.fs.xiaoshouyi.service.XiaoShouYiTrackLinkCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
@@ -103,6 +104,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private static final String APP_ID_CONFIG_KEY = "appStore.appId.config";
     private static final String DEFAULT_APP_ID = "wx50bcb040b4963a7e";
 
+    private static final String gjminiappLink = "/pages/course/learning?course=";
+
+    @Lazy
+    @Autowired
+    private XiaoShouYiTrackLinkCacheService xiaoShouYiTrackLinkCacheService;
     @Autowired
     private ISysConfigService configService;
 
@@ -932,36 +938,93 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         case "1":
                         case "3":
                             if ("1".equals(st.getIsBindUrl())) {
-
-                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), qwUserId, companyUserId, companyId, item.getExternalId(),param.getStartTime(),createTime );
-
-                                String sortLink = generateShortLink(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(),
-                                        Long.valueOf(qwUserId), companyUserId, companyId, finalDomainName,item.getExternalId(),config);
-
-                                if (StringUtils.isNotEmpty(sortLink)) {
-                                    if ("3".equals(st.getContentType())) {
-                                        st.setLinkUrl(sortLink);
+                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(), item.getFsUserId(), qwUserId, companyUserId, companyId, item.getExternalId(), param.getStartTime(), createTime);
+
+                                Map<String, String> linkMap = createH5GjLinkByMiniApp(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(),
+                                        qwUserId, companyUserId, companyId, item.getExternalId(), config);
+                                String link = linkMap.get("url");
+                                String linkNo = linkMap.get("link");
+                                if ("3".equals(st.getContentType())) {
+                                    String xsyRedirectUrl = xiaoShouYiTrackLinkCacheService.applySopTrackRedirectUrl(
+                                            sopLogs, param.getCorpId(), companyUserId, qwUserId,
+                                            param.getCourseId() != null ? param.getCourseId().longValue() : null,
+                                            param.getVideoId() != null ? param.getVideoId().longValue() : null,
+                                            item.getExternalId() != null ? String.valueOf(item.getExternalId()) : null,
+                                            link, st.getLinkImageUrl(), st.getLinkTitle(),
+                                            st.getSmsTemplateTitle(), st.getSmsTemplateContent(), linkNo);
+                                    if (StringUtils.isNotEmpty(xsyRedirectUrl)) {
+                                        st.setXsyLinkUrl(xsyRedirectUrl);
+                                        st.setLinkUrl(xsyRedirectUrl);
+                                    }
+                                } else if (StringUtils.isNotEmpty(link)) {
+                                    String currentValue = st.getValue();
+                                    if (currentValue == null) {
+                                        st.setValue(link);
                                     } else {
-                                        String currentValue = st.getValue();
-                                        if (currentValue == null) {
-                                            st.setValue(sortLink);
-                                        } else {
-                                            st.setValue(currentValue
-                                                    .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
-                                                    .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus())|| "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus())
-                                                    + "\n" + sortLink);
-                                        }
+                                        st.setValue(currentValue
+                                                .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText())
+                                                + "\n" + link);
                                     }
                                 } else {
-                                    log.warn("生成短链失败,跳过设置 URL。");
+                                    log.error("生成短链失败,跳过设置 URL。");
                                 }
-                            }else {
-                                if ("1".equals(st.getContentType())) {
-                                    st.setValue(st.getValue()
-                                            .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
-                                            .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus()));
+                            }
+                            if ("1".equals(st.getContentType())) {
+                                String txt = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+                                st.setValue(st.getValue()
+                                        .replaceAll("#客服称呼#", txt)
+                                        .replaceAll("#销售称呼#", txt)
+                                        .replaceAll("#客户称呼#", StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus()));
+                            }
+                            if ("3".equals(st.getContentType())) {
+                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), qwUserId, companyUserId, companyId, item.getExternalId(),param.getStartTime(),createTime );
+                                Map<String, String> linkMap = createH5GjLinkByMiniApp(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(),
+                                        String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
+                                String link = linkMap.get("url");
+                                String linkNo = linkMap.get("link");
+                                String xsyRedirectUrl = xiaoShouYiTrackLinkCacheService.applySopTrackRedirectUrl(
+                                        sopLogs, param.getCorpId(), companyUserId, String.valueOf(qwUser.getId()),
+                                        param.getCourseId() != null ? param.getCourseId().longValue() : null,
+                                        param.getVideoId() != null ? param.getVideoId().longValue() : null,
+                                        item.getExternalId() != null ? String.valueOf(item.getExternalId()) : null,
+                                        link, st.getLinkImageUrl(), st.getLinkTitle(),
+                                        st.getSmsTemplateTitle(), st.getSmsTemplateContent(), linkNo);
+                                if (StringUtils.isNotEmpty(xsyRedirectUrl)) {
+                                    st.setXsyLinkUrl(xsyRedirectUrl);
+                                    st.setLinkUrl(xsyRedirectUrl);
                                 }
                             }
+//                            if ("1".equals(st.getIsBindUrl())) {
+//
+//                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), qwUserId, companyUserId, companyId, item.getExternalId(),param.getStartTime(),createTime );
+//
+//                                String sortLink = generateShortLink(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(),
+//                                        Long.valueOf(qwUserId), companyUserId, companyId, finalDomainName,item.getExternalId(),config);
+//
+//                                if (StringUtils.isNotEmpty(sortLink)) {
+//                                    if ("3".equals(st.getContentType())) {
+//                                        //st.setLinkUrl(sortLink);
+//                                    } else {
+//                                        String currentValue = st.getValue();
+//                                        if (currentValue == null) {
+//                                            st.setValue(sortLink);
+//                                        } else {
+//                                            st.setValue(currentValue
+//                                                    .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
+//                                                    .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus())|| "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus())
+//                                                    + "\n" + sortLink);
+//                                        }
+//                                    }
+//                                } else {
+//                                    log.warn("生成短链失败,跳过设置 URL。");
+//                                }
+//                            }else {
+//                                if ("1".equals(st.getContentType())) {
+//                                    st.setValue(st.getValue()
+//                                            .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
+//                                            .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus()));
+//                                }
+//                            }
 
                             break;
                         //小程序单独
@@ -1517,39 +1580,86 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 //文字和短链一起
                 case "1":
                 case "3":
-                    if ("1".equals(st.getIsBindUrl())) {
+                    if ("3".equals(st.getContentType())) {
+                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(), item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                                item.getExternalId(), item.getStartTime(), dataTime);
 
-                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
-                                companyId, item.getExternalId(),param.getStartTime(),dataTime );
+                        Map<String, String> linkMap = createH5GjLinkByMiniApp(st, param.getCorpId(), dataTime, param.getCourseId(), param.getVideoId(),
+                                String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
+                        String link = linkMap.get("url");
+                        String linkNo = linkMap.get("link");
+                        String xsyRedirectUrl = xiaoShouYiTrackLinkCacheService.applySopTrackRedirectUrl(
+                                sopLogs, param.getCorpId(), companyUserId, String.valueOf(qwUser.getId()),
+                                param.getCourseId() != null ? param.getCourseId().longValue() : null,
+                                param.getVideoId() != null ? param.getVideoId().longValue() : null,
+                                item.getExternalId() != null ? String.valueOf(item.getExternalId()) : null,
+                                link, st.getLinkImageUrl(), st.getLinkTitle(),
+                                st.getSmsTemplateTitle(), st.getSmsTemplateContent(), linkNo);
+                        if (StringUtils.isNotEmpty(xsyRedirectUrl)) {
+                            st.setXsyLinkUrl(xsyRedirectUrl);
+                            st.setLinkUrl(xsyRedirectUrl);
+                        }
 
-                        String sortLink = generateShortLink(st, param.getCorpId(), dataTime, param.getCourseId(), param.getVideoId(),
-                                qwUser.getId(), companyUserId, companyId, domainName,item.getExternalId(),config);
 
-                        if (StringUtils.isNotEmpty(sortLink)) {
+                        if (StringUtils.isNotEmpty(link)) {
                             if ("3".equals(st.getContentType())) {
-                                st.setLinkUrl(sortLink);
+//                                st.setLinkUrl(link);
+//                                st.setXsyLinkUrl(response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl());
+//                                st.setLinkUrl(link);
+//                                st.setXsyLinkUrl(response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl());
                             } else {
                                 String currentValue = st.getValue();
                                 if (currentValue == null) {
-                                    st.setValue(sortLink);
+                                    st.setValue(link);
                                 } else {
                                     st.setValue(currentValue
-                                            .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
-                                            .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus())|| "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus())
-                                            + "\n" + sortLink);
+                                            .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText())
+                                            + "\n" + link);
                                 }
                             }
                         } else {
-                            log.warn("生成短链失败,跳过设置 URL。");
-                        }
-                    }else {
-                        if ("1".equals(st.getContentType())) {
-                            st.setValue(st.getValue()
-                                    .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
-                                    .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus()));
+                            log.error("生成短链失败,跳过设置 URL。");
                         }
                     }
-
+//                    if ("1".equals(st.getContentType())) {
+//                        String txt = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+//                        st.setValue(st.getValue()
+//                                .replaceAll("#客服称呼#", txt)
+//                                .replaceAll("#销售称呼#", txt)
+//                                .replaceAll("#客户称呼#", StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus()));
+//                    }
+//                    if ("1".equals(st.getIsBindUrl())) {
+//
+//                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
+//                                companyId, item.getExternalId(),param.getStartTime(),dataTime );
+//
+//                        String sortLink = generateShortLink(st, param.getCorpId(), dataTime, param.getCourseId(), param.getVideoId(),
+//                                qwUser.getId(), companyUserId, companyId, domainName,item.getExternalId(),config);
+//
+//                        if (StringUtils.isNotEmpty(sortLink)) {
+//                            if ("3".equals(st.getContentType())) {
+//                                st.setLinkUrl(sortLink);
+//                            } else {
+//                                String currentValue = st.getValue();
+//                                if (currentValue == null) {
+//                                    st.setValue(sortLink);
+//                                } else {
+//                                    st.setValue(currentValue
+//                                            .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
+//                                            .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus())|| "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus())
+//                                            + "\n" + sortLink);
+//                                }
+//                            }
+//                        } else {
+//                            log.warn("生成短链失败,跳过设置 URL。");
+//                        }
+//                    }else {
+//                        if ("1".equals(st.getContentType())) {
+//                            st.setValue(st.getValue()
+//                                    .replaceAll("#销售称呼#",StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText())?"":qwUser.getWelcomeText())
+//                                    .replaceAll("#客户称呼#",StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus())?"同学":contact.getStageStatus()));
+//                        }
+//                    }
                     break;
                 //小程序单独
                 case "4":
@@ -2115,6 +2225,35 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return link.getRealLink();
     }
 
+    private Map<String, String> createH5GjLinkByMiniApp(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                                        Integer courseId, Integer videoId, String qwUserId,
+                                                        String companyUserId, String companyId, Long externalId, CourseConfig config) {
+
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+
+        String realLinkFull = gjminiappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+        //存短链-
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
+
     /**
      * 获取 appId 的私有方法
      */

+ 59 - 0
fs-service/src/main/java/com/fs/system/domain/vo/WatchLogKeyVo.java

@@ -0,0 +1,59 @@
+package com.fs.system.domain.vo;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Objects;
+
+/**
+ * 看课记录与 SOP 表的关联键:(qw_user_key, sop_id, fs_user_id, external_id)。
+ * 不可变对象;去重时四字段全部参与,sopId 不同视为不同键。
+ */
+@Getter
+public final class WatchLogKeyVo {
+
+    private final String qwUserId;
+    private final String sopId;
+    private final Long userId;
+    private final Long externalId;
+
+    public WatchLogKeyVo(String qwUserId, String sopId, Long userId, Long externalId) {
+        this.qwUserId = StringUtils.trimToNull(qwUserId);
+        this.sopId = StringUtils.trimToNull(sopId);
+        this.userId = userId;
+        this.externalId = externalId;
+    }
+
+    /**
+     * 去重指纹:四字段全部拼接,sopId 不同则指纹必不同。
+     */
+    public String fingerprint() {
+        return qwUserId + '\u0001' + sopId + '\u0001' + userId + '\u0001' + externalId;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        WatchLogKeyVo that = (WatchLogKeyVo) o;
+        return Objects.equals(qwUserId, that.qwUserId)
+                && Objects.equals(sopId, that.sopId)
+                && Objects.equals(userId, that.userId)
+                && Objects.equals(externalId, that.externalId);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(qwUserId, sopId, userId, externalId);
+    }
+
+    @Override
+    public String toString() {
+        return "WatchLogKeyVo{qwUserId=" + qwUserId + ", sopId=" + sopId
+                + ", userId=" + userId + ", externalId=" + externalId + '}';
+    }
+}

+ 14 - 0
fs-service/src/main/java/com/fs/system/domain/vo/WatchLogScanBounds.java

@@ -0,0 +1,14 @@
+package com.fs.system.domain.vo;
+
+import lombok.Data;
+
+/**
+ * 小程序替换任务:看课表扫描边界(一次查询返回 min/max/count)
+ */
+@Data
+public class WatchLogScanBounds {
+
+    private Long minId;
+    private Long maxId;
+    private Long rowCount;
+}

+ 1825 - 0
fs-service/src/main/java/com/fs/system/service/ReplaceMiniAppHandleService.java

@@ -0,0 +1,1825 @@
+package com.fs.system.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.common.RedisKeyCommon;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.sop.domain.QwSopLogs;
+import com.fs.sop.mapper.QwSopLogsMapper;
+import com.fs.system.domain.vo.WatchLogKeyVo;
+import com.fs.system.domain.vo.WatchLogScanBounds;
+import com.fs.system.support.MiniAppReplaceSupport;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import javax.annotation.PreDestroy;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
+
+/**
+ * 小程序 AppId 更换服务(支持同一天内多个旧 AppId 依次替换为同一个新 AppId)。
+ *
+ * <h3>整体流程</h3>
+ * <pre>
+ * 看课表(fs_course_watch_log) 按 log_id 游标扫描(按实际行数切少量并行分区,非 ID 步长)
+ *   → 提取关联键(qwUserId,sopId,userId,externalId)
+ *   → 查 SOP 表(qw_sop_logs) 匹配待发记录
+ *   → 过滤 send_mini_app_id 属于「旧 AppId 集合」且 content_json 含旧值的记录
+ *   → 批量 UPDATE send_mini_app_id 与 content_json 中的 miniprogramAppid
+ * </pre>
+ *
+ * <h3>Redis 幂等(key = fs_qw_miniapp_replace:{startTime})</h3>
+ * 任务开始时若集合不含当前 oldAppId → 立刻写入 Redis(中断后不丢失登记);
+ * 已含则跳过,避免同一天重复处理同一旧 AppId。
+ * 每次任务看课扫一遍;当前 oldAppId 优先替换为本次 newAppId,历史 oldAppId 并行替换为同一 newAppId。
+ *
+ * <h3>任务抢占</h3>
+ * 新任务到达 → taskEpoch 递增 + cancelled + 取消分区 Future;
+ * 旧任务在各检查点发现 epoch 不匹配后退出并释放 taskLock,新任务再执行。
+ *
+ * <h3>线程模型</h3>
+ * coordinatorExecutor(单线程 + 队列1):串行跑主任务,DiscardOldest 保留最新排队任务;
+ * partitionExecutor(16~32 线程):并行扫看课数据段;
+ * sopExecutor(16~40 线程,总 worker≤56):查 SOP + 批量 UPDATE(看课扫完后再按轮次执行);
+ * heartbeatExecutor(单线程定时):每 5 分钟播报一次进度。
+ *
+ * <h3>分段策略</h3>
+ * 多段时一律按真实行数均分(ROW_NUMBER),每段约 12 万行,避免 log_id 空洞产生空段或拖尾段;
+ * 大数据量均分失败时降级单段,不回退 log_id 等距。
+ *
+ * <h3>CPU / 内存平滑</h3>
+ * 段任务滑动窗口 + 启动轻量错峰;SOP 背压 Semaphore;全局键去重有软上限;content_json 长度硬限制。
+ *
+ * <h3>安全保障</h3>
+ * 游标前进检测 + 循环硬上限防死循环;Semaphore 背压防拖垮 DB;总 worker ≤ 56;epoch 抢占可中断;
+ * 单批失败可丢弃并记日志,Future/分区等待有超时上限,避免无限阻塞导致任务卡死。
+ */
+@Slf4j
+@Service
+public class ReplaceMiniAppHandleService {
+
+    // ======================== 批处理参数(安全硬上限 + 性能调优) ========================
+
+    private static final DateTimeFormatter DATETIME_FMT =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private static final int CPU_CORES = Math.max(4, Runtime.getRuntime().availableProcessors());
+    /** 看课扫描并行度(128 核约 32) */
+    private static final int PARALLELISM;
+    /** SOP 查改并行度(128 核约 24,与扫描合计不超过 MAX_TOTAL_WORKERS) */
+    private static final int SOP_PARALLELISM;
+    /** 扫描 + SOP 总 worker 硬上限,防止 16GB 机器线程/连接过多 */
+    private static final int MAX_TOTAL_WORKERS = 56;
+    /** 同时在途 SOP 任务上限(= SOP 线程 × 3,超出则扫描线程短暂等待,背压保护 DB) */
+    private static final int MAX_SOP_IN_FLIGHT;
+
+    static {
+        int scan = Math.min(32, Math.max(16, CPU_CORES / 4));
+        int sop = Math.min(40, Math.max(20, CPU_CORES / 3));
+        if (scan + sop > MAX_TOTAL_WORKERS) {
+            sop = Math.max(16, MAX_TOTAL_WORKERS - scan);
+        }
+        PARALLELISM = scan;
+        SOP_PARALLELISM = sop;
+        MAX_SOP_IN_FLIGHT = SOP_PARALLELISM * 3;
+    }
+
+    /** 每个并行段目标行数(按行均分,保证各段真实数据量接近) */
+    private static final long ROWS_PER_PARTITION = 120_000L;
+    private static final int MAX_PARTITIONS = 64;
+    private static final long SINGLE_PARTITION_FALLBACK_THRESHOLD = 100_000L;
+    /** 同时在途段数上限(渐进加大,见 resolveMaxInFlight) */
+    private static final int PARTITION_SUBMIT_WINDOW = Math.min(12, PARALLELISM + 2);
+    /** 启动阶段每提交 N 段停顿一次(毫秒),防止 CPU 瞬间打满 */
+    private static final long PARTITION_SUBMIT_STAGGER_MS = 300L;
+    private static final int PARTITION_SUBMIT_STAGGER_EVERY = 2;
+    /** 初始估算:每扫描线程约 2.5 万行/分钟(打 8 折留余量) */
+    private static final long EST_SCAN_ROWS_PER_THREAD_PER_MIN = 25_000L;
+    /** 单次从看课表拉取行数 */
+    private static final int CURSOR_BATCH_SIZE = 80_000;
+    /** 单次批量查 SOP 的关联键数量(复合键 IN 批次,略小有利于索引) */
+    private static final int SOP_KEY_BATCH_SIZE = 4000;
+    /** 历史 oldAppId 并行度(预估单日约 10 个旧 AppId,限制并行避免 CPU/DB 尖峰) */
+    private static final int HISTORICAL_PASS_PARALLELISM = Math.min(4, Math.max(2, CPU_CORES / 8));
+    /** 单次批量 UPDATE 条数 */
+    private static final int UPDATE_BATCH_SIZE = 2000;
+    /** 去重键达此数量即刷入全局 pending 集合,控制单段内存 */
+    private static final int FLUSH_KEY_THRESHOLD = 20_000;
+    /** 每扫 N 批看课也刷入 pending 集合 */
+    private static final int FLUSH_EVERY_CURSOR_BATCHES = 3;
+    /** 分区内游标循环硬上限(兜底) */
+    private static final int MAX_CURSOR_BATCHES_FALLBACK =
+            (int) Math.min(50_000, (ROWS_PER_PARTITION * MAX_PARTITIONS) / CURSOR_BATCH_SIZE + 500);
+    private static final int MAX_RETRY = 3;
+    private static final long INIT_RETRY_DELAY_MS = 100L;
+    private static final long TASK_WAIT_TIMEOUT_SECONDS = 300;
+    private static final int COORDINATOR_QUEUE_CAPACITY = 1;
+    private static final int PARTITION_QUEUE_CAPACITY = MAX_PARTITIONS;
+    private static final int SOP_QUEUE_CAPACITY = 384;
+    /** 等待单个 SOP 任务的最长时间(秒),超时后继续等并检查取消,避免假死无响应 */
+    private static final long SOP_FUTURE_WAIT_SECONDS = 60;
+    /** 单批 SOP Future 最多等待轮数(× SOP_FUTURE_WAIT_SECONDS 后取消并丢弃) */
+    private static final int SOP_FUTURE_MAX_TIMEOUT_ROUNDS = 120;
+    /** 扫描线程等 SOP 背压槽位的最长单次等待(秒),超时打日志 */
+    private static final long SOP_PERMIT_WAIT_SECONDS = 120L;
+    /** 等 SOP 槽位累计超过此秒数则丢弃本批,避免协调线程永久阻塞 */
+    private static final long SOP_PERMIT_MAX_WAIT_SECONDS = 600L;
+    /** 分区 CompletionService 连续空 poll 次数上限(×2秒),超时则取消在途段 */
+    private static final int PARTITION_POLL_IDLE_MAX = 150;
+    /** 历史轮次协调 Future 最长等待(秒) */
+    private static final long HISTORICAL_PASS_WAIT_SECONDS = 3600L;
+    /** Redis 集合过期时间(小时) */
+    private static final int REDIS_EXPIRE_HOURS = 24;
+    private static final String LOG_TAG = "[小程序换绑]";
+    /** 进度汇报间隔:5 分钟 */
+    private static final long PROGRESS_LOG_INTERVAL_MS = 300_000L;
+    /** content_json 最大处理长度,防止异常大 JSON 拖垮内存 */
+    private static final int MAX_CONTENT_JSON_LENGTH = 512_000;
+
+    private static final String PHASE_PREPARE = "启动准备";
+    private static final String PHASE_COUNT = "统计今日看课数量";
+    private static final String PHASE_SPLIT = "拆分并行任务";
+    private static final String PHASE_SCAN = "扫描看课表";
+    private static final String PHASE_SOP_PASS = "SOP逐轮查改";
+    private static final String PHASE_SOP_DRAIN = "SOP收尾";
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Autowired
+    private FsCourseWatchLogMapper watchLogMapper;
+
+    @Autowired
+    private QwSopLogsMapper sopLogsMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    // ======================== 专用线程池与并发控制 ========================
+
+    /** 单线程协调器:投递主任务、等待分区完成,不占用 Spring @Async 线程池 */
+    private final ThreadPoolExecutor coordinatorExecutor;
+    /** 分区 worker:只看课扫描 + 投递 SOP 任务 */
+    private final ThreadPoolExecutor partitionExecutor;
+    /** SOP worker:查 SOP + 批量 UPDATE */
+    private final ThreadPoolExecutor sopExecutor;
+    /** 历史 oldAppId 轮次协调(有限并行,不与当前轮同时跑) */
+    private final ExecutorService historicalPassExecutor;
+
+    /** 取消标志:新任务或 shutdown 时置 true,配合 epoch 让旧 worker 退出 */
+    private final AtomicBoolean cancelled = new AtomicBoolean(false);
+    /** 服务销毁标志:阻止新任务提交 */
+    private final AtomicBoolean destroyed = new AtomicBoolean(false);
+    /**
+     * 任务代际号:每次 replaceAppId / cancel / shutdown 时递增。
+     * 各 worker 持有提交时的 epoch,运行中若发现 epoch != taskEpoch 则视为已被取代。
+     */
+    private final AtomicLong taskEpoch = new AtomicLong(0);
+    /** 互斥锁:同一时刻只有一个 runReplaceTask 在执行(含等分区结束) */
+    private final ReentrantLock taskLock = new ReentrantLock();
+    /** 保护 activeFutures 的读写,避免 cancel 与 submit 竞态 */
+    private final Object futuresLock = new Object();
+    /** 当前任务已提交的分区 Future,供抢占时 cancel(true) 中断阻塞 IO */
+    private volatile List<Future<?>> activeFutures = Collections.emptyList();
+    /** 当前任务已提交的 SOP Future */
+    private volatile List<Future<long[]>> activeSopFutures = Collections.emptyList();
+    private final Object sopFuturesLock = new Object();
+
+    /** 全局统计(分区线程累加,LongAdder 无锁) */
+    private final LongAdder totalNeed = new LongAdder();
+    private final LongAdder totalUpdated = new LongAdder();
+    /** 诊断:各阶段累计计数,便于排查「有数据但结果为 0」 */
+    private final LongAdder statWatchLogRows = new LongAdder();
+    private final LongAdder statValidKeys = new LongAdder();
+    private final LongAdder statSopMatched = new LongAdder();
+    private final LongAdder statPartitionErrors = new LongAdder();
+    private final LongAdder statSopTasksInFlight = new LongAdder();
+    /** 因超时/异常主动丢弃的批次数(看课查库、SOP 查改、槽位等待等) */
+    private final LongAdder statBatchesDiscarded = new LongAdder();
+    /** 需改 SOP 的 id(任务级,用于最终报告列出漏改) */
+    private final Set<String> needUpdateSopIds = ConcurrentHashMap.newKeySet();
+    /** 已成功写入的 SOP id */
+    private final Set<String> successUpdateSopIds = ConcurrentHashMap.newKeySet();
+    /** 扫描阶段汇总的关联键(看课扫完后再按轮次查 SOP,扫描不被 SOP 背压阻塞) */
+    private final Set<WatchLogKeyVo> pendingSopKeys = ConcurrentHashMap.newKeySet();
+    /** 待处理关联键过多时只 warn 一次 */
+    private final AtomicBoolean pendingKeysWarned = new AtomicBoolean(false);
+    private static final int PENDING_KEYS_WARN_THRESHOLD = 1_500_000;
+    /** 最终报告最多打印的漏改 id 条数 */
+    private static final int MAX_MISSED_IDS_LOG = 2000;
+
+    /** 心跳汇报用:由协调线程写入,心跳线程只读 */
+    private volatile long progressEpoch = -1L;
+    private volatile long progressTaskStartMs = 0L;
+    private volatile String progressPhase = "";
+    private volatile long progressTotalRows = 0L;
+    private volatile int progressDonePartitions = 0;
+    private volatile int progressTotalPartitions = 0;
+    /** 当前任务每段允许的最大游标批次数 */
+    private volatile int taskMaxCursorBatches = MAX_CURSOR_BATCHES_FALLBACK;
+    /** 本任务 SOP 背压信号量(限制同时在途 SOP 任务数) */
+    private volatile Semaphore taskSopPermits;
+    /** 上次心跳时的看课扫描行数(用于算速度和检测卡住) */
+    private volatile long heartbeatLastScannedRows = 0L;
+    private volatile long heartbeatLastTickMs = 0L;
+    /** 根据当前速度推算的预计完成时刻(毫秒),每次进度汇报更新 */
+    private volatile long progressEstimatedFinishMs = 0L;
+    /** 当前 SOP 轮次描述(心跳展示) */
+    private volatile String progressSopPassLabel = "";
+    /** 本任务各 oldAppId 处理明细(协调线程写入,报告时读取) */
+    private final List<SopPassSummary> taskSopPassSummaries = new ArrayList<>();
+    /** 本任务 Redis 登记 oldAppId 总数 */
+    private volatile int taskRegistryOldAppCount = 0;
+
+    /** 进度心跳(与协调/分区线程隔离,避免长时间 DB 阻塞时无日志) */
+    private final ScheduledExecutorService heartbeatExecutor;
+    /** 防止重复心跳任务泄漏 */
+    private final Object heartbeatLock = new Object();
+    private volatile ScheduledFuture<?> activeHeartbeatFuture;
+
+    public ReplaceMiniAppHandleService() {
+        this.coordinatorExecutor = new ThreadPoolExecutor(
+                1, 1, 0L, TimeUnit.MILLISECONDS,
+                new ArrayBlockingQueue<>(COORDINATOR_QUEUE_CAPACITY),
+                namedThreadFactory("miniapp-replace-coordinator"),
+                (r, e) -> {
+                    if (!e.isShutdown()) {
+                        log.info("{} 排队任务过多,丢弃最旧的一条,执行最新换绑请求", LOG_TAG);
+                        e.getQueue().poll();
+                        e.execute(r);
+                    }
+                });
+        this.partitionExecutor = new ThreadPoolExecutor(
+                PARALLELISM, PARALLELISM, 60L, TimeUnit.SECONDS,
+                new ArrayBlockingQueue<>(PARTITION_QUEUE_CAPACITY),
+                namedThreadFactory("miniapp-replace-partition"),
+                new ThreadPoolExecutor.CallerRunsPolicy());
+        this.partitionExecutor.allowCoreThreadTimeOut(true);
+        this.sopExecutor = new ThreadPoolExecutor(
+                SOP_PARALLELISM, SOP_PARALLELISM, 60L, TimeUnit.SECONDS,
+                new ArrayBlockingQueue<>(SOP_QUEUE_CAPACITY),
+                namedThreadFactory("miniapp-replace-sop"),
+                new ThreadPoolExecutor.CallerRunsPolicy());
+        this.sopExecutor.allowCoreThreadTimeOut(true);
+        this.historicalPassExecutor = Executors.newFixedThreadPool(
+                HISTORICAL_PASS_PARALLELISM,
+                namedThreadFactory("miniapp-replace-hist-pass"));
+        this.heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(
+                namedThreadFactory("miniapp-replace-heartbeat"));
+    }
+
+    // ======================== 对外入口 ========================
+
+    /**
+     * 提交替换任务(异步):HTTP 线程校验参数后立即返回,实际工作在协调线程执行。
+     * <p>注意:此处会先递增 epoch 并取消旧任务,再投递到 coordinatorExecutor。</p>
+     */
+    public void replaceAppId(String oldAppId, String newAppId, String startTime) {
+        String err = MiniAppReplaceSupport.validateReplaceParams(oldAppId, newAppId, startTime);
+        if (err != null) {
+            throw new IllegalArgumentException(err);
+        }
+        if (destroyed.get()) {
+            log.info("{} 服务已关闭,任务未提交", LOG_TAG);
+            return;
+        }
+
+        final long epoch = taskEpoch.incrementAndGet();
+        cancelled.set(true);
+        cancelActiveFutures();
+
+        try {
+            coordinatorExecutor.execute(() -> runReplaceTask(oldAppId, newAppId, startTime, epoch));
+        } catch (RejectedExecutionException e) {
+            log.error("{} 协调线程池拒绝任务 epoch={}", LOG_TAG, epoch, e);
+        }
+    }
+
+    /**
+     * 主任务体(仅在协调线程中运行)。
+     * <ol>
+     *   <li>抢 taskLock,防止与上一任务重叠</li>
+     *   <li>登记 Redis(oldAppId 立刻写入)</li>
+     *   <li>分区并行扫描看课表 → 更新 SOP 表</li>
+     *   <li>finally 释放锁,便于下一任务进入</li>
+     * </ol>
+     */
+    private void runReplaceTask(String oldAppId, String newAppId, String startTime, long epoch) {
+        if (epoch != taskEpoch.get()) {
+            return;
+        }
+
+        boolean locked = false;
+        long startMs = System.currentTimeMillis();
+        ScheduledFuture<?> heartbeatFuture = null;
+        try {
+            locked = taskLock.tryLock(TASK_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+            if (!locked) {
+                logEnd(oldAppId, newAppId, startTime, startMs, "已放弃", "上一个任务未在5分钟内结束");
+                return;
+            }
+            if (epoch != taskEpoch.get()) {
+                log.info("{} 任务已过期,跳过执行 | epoch={}", LOG_TAG, epoch);
+                return;
+            }
+
+            cancelled.set(false);
+            resetStats();
+
+            Set<String> registeredOldAppIds = registerOldAppIdAtStart(oldAppId, startTime);
+            if (registeredOldAppIds == null) {
+                logEnd(oldAppId, newAppId, startTime, startMs, "已结束", "今日已处理过或Redis写入失败");
+                return;
+            }
+
+            taskRegistryOldAppCount = registeredOldAppIds.size();
+            List<SopPass> sopPasses = buildSopPasses(registeredOldAppIds, oldAppId, newAppId);
+
+            heartbeatFuture = startProgressHeartbeat(epoch, startMs);
+            processUpdatePhase(sopPasses, startTime, epoch, oldAppId, newAppId, startMs);
+
+            if (epoch == taskEpoch.get() && !cancelled.get()) {
+                String finalStatus = statBatchesDiscarded.sum() > 0 ? "完成(有批次丢弃)" : "完成";
+                logEnd(oldAppId, newAppId, startTime, startMs, finalStatus, null);
+            } else {
+                logEnd(oldAppId, newAppId, startTime, startMs, "已中断", "被更新的换绑请求取代");
+            }
+
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            logEnd(oldAppId, newAppId, startTime, startMs, "已中断", "线程被中断");
+        } catch (Exception e) {
+            log.error("{} 任务异常终止 | 旧={} 新={}", LOG_TAG, oldAppId, newAppId, e);
+            logEnd(oldAppId, newAppId, startTime, startMs, "失败",
+                    e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
+        } finally {
+            stopProgressHeartbeat(heartbeatFuture);
+            if (locked) {
+                // 仅当前 epoch 仍有效时清除取消标志,避免覆盖新任务/ shutdown 设置的 cancelled
+                if (epoch == taskEpoch.get()) {
+                    cancelled.set(false);
+                }
+                synchronized (futuresLock) {
+                    activeFutures = Collections.emptyList();
+                }
+                synchronized (sopFuturesLock) {
+                    activeSopFutures = Collections.emptyList();
+                }
+                taskLock.unlock();
+            }
+        }
+    }
+
+    private void resetStats() {
+        totalNeed.reset();
+        totalUpdated.reset();
+        statWatchLogRows.reset();
+        statValidKeys.reset();
+        statSopMatched.reset();
+        statPartitionErrors.reset();
+        statSopTasksInFlight.reset();
+        statBatchesDiscarded.reset();
+        needUpdateSopIds.clear();
+        successUpdateSopIds.clear();
+        pendingSopKeys.clear();
+        pendingKeysWarned.set(false);
+        progressSopPassLabel = "";
+        heartbeatLastScannedRows = 0L;
+        heartbeatLastTickMs = 0L;
+        progressEstimatedFinishMs = 0L;
+        taskSopPermits = new Semaphore(MAX_SOP_IN_FLIGHT);
+        taskSopPassSummaries.clear();
+        taskRegistryOldAppCount = 0;
+    }
+
+    /** 外部主动取消:递增 epoch 并中断分区线程 */
+    public void cancel() {
+        taskEpoch.incrementAndGet();
+        cancelled.set(true);
+        cancelActiveFutures();
+        log.info("{} 收到取消指令", LOG_TAG);
+    }
+
+    /** 是否有任务正持有 taskLock(含等待分区结束阶段) */
+    public boolean isRunning() {
+        return taskLock.isLocked();
+    }
+
+    /** 中断当前任务所有分区与 SOP Future */
+    private void cancelActiveFutures() {
+        List<Future<?>> futures;
+        synchronized (futuresLock) {
+            futures = new ArrayList<>(activeFutures);
+        }
+        for (Future<?> future : futures) {
+            future.cancel(true);
+        }
+        List<Future<long[]>> sopFutures;
+        synchronized (sopFuturesLock) {
+            sopFutures = new ArrayList<>(activeSopFutures);
+        }
+        for (Future<long[]> future : sopFutures) {
+            future.cancel(true);
+        }
+    }
+
+    // ======================== Redis:按 startTime 登记已处理 oldAppId ========================
+
+    private String redisKey(String startTime) {
+        return RedisKeyCommon.QW_MINAPP_REPLACE_KEY + startTime;
+    }
+
+    /**
+     * 读取当日已登记 oldAppId 集合(兼容旧版 Map / Set JSON)。
+     *
+     * @return 空集合表示 key 不存在;null 表示解析失败
+     */
+    private Set<String> loadRegisteredOldAppIds(String startTime) {
+        String key = redisKey(startTime);
+        Set<String> ids = new LinkedHashSet<>();
+        if (!redisCache.hasKey(key)) {
+            return ids;
+        }
+        String json = redisCache.getCacheObject(key);
+        if (StringUtils.isBlank(json)) {
+            return ids;
+        }
+        String trimmed = json.trim();
+        if (trimmed.startsWith("[")) {
+            try {
+                com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSONArray.parseArray(trimmed);
+                if (arr != null) {
+                    for (int i = 0; i < arr.size(); i++) {
+                        String oldId = StringUtils.trimToNull(arr.getString(i));
+                        if (oldId != null) {
+                            ids.add(oldId);
+                        }
+                    }
+                }
+                return ids;
+            } catch (Exception e) {
+                log.error("{} 解析Redis Set失败 key={}", LOG_TAG, key, e);
+                return null;
+            }
+        }
+        try {
+            Map<String, ?> map = JSONObject.parseObject(trimmed, Map.class);
+            if (map != null) {
+                for (String oldId : map.keySet()) {
+                    oldId = StringUtils.trimToNull(oldId);
+                    if (oldId != null) {
+                        ids.add(oldId);
+                    }
+                }
+                return ids;
+            }
+        } catch (Exception e) {
+            log.error("{} 解析Redis失败 key={}", LOG_TAG, key, e);
+            return null;
+        }
+        return ids;
+    }
+
+    private boolean saveRegisteredOldAppIds(String startTime, Set<String> oldAppIds) {
+        try {
+            redisCache.setCacheObject(redisKey(startTime), JSONObject.toJSONString(oldAppIds),
+                    REDIS_EXPIRE_HOURS, TimeUnit.HOURS);
+            return true;
+        } catch (Exception e) {
+            log.error("{} 写入Redis失败 key={}", LOG_TAG, redisKey(startTime), e);
+            return false;
+        }
+    }
+
+    /**
+     * 登记当前 oldAppId;返回登记后全集。
+     *
+     * @return 全集;null = 已登记跳过 / Redis 异常
+     */
+    private Set<String> registerOldAppIdAtStart(String oldAppId, String startTime) {
+        Set<String> registered = loadRegisteredOldAppIds(startTime);
+        if (registered == null) {
+            return null;
+        }
+        if (registered.contains(oldAppId)) {
+            log.info("{} 今日已处理过该旧AppId,跳过 | oldAppId={} 日期={}", LOG_TAG, oldAppId, startTime);
+            return null;
+        }
+        registered.add(oldAppId);
+        if (!saveRegisteredOldAppIds(startTime, registered)) {
+            return null;
+        }
+        return registered;
+    }
+
+    /**
+     * 构建 SOP 轮次:第 1 轮当前 oldAppId,其余历史 oldAppId 全部替换为本次 newAppId(每轮单 AppId 等号查库)。
+     */
+    private List<SopPass> buildSopPasses(Set<String> registeredOldAppIds, String currentOldAppId,
+                                         String currentNewAppId) {
+        List<SopPass> passes = new ArrayList<>();
+        passes.add(new SopPass(currentOldAppId, currentNewAppId, false));
+
+        List<String> historical = registeredOldAppIds.stream()
+                .filter(id -> !id.equals(currentOldAppId))
+                .sorted()
+                .collect(Collectors.toList());
+        for (String oldId : historical) {
+            passes.add(new SopPass(oldId, currentNewAppId, true));
+        }
+
+        log.info("{} SOP计划 | 共{}个旧AppId | 第1轮当前{}→{} | 历史{}轮均→{} | 历史完成后并行{}路",
+                LOG_TAG, registeredOldAppIds.size(), currentOldAppId, currentNewAppId,
+                historical.size(), currentNewAppId,
+                historical.isEmpty() ? 0 : Math.min(HISTORICAL_PASS_PARALLELISM, historical.size()));
+        synchronized (taskSopPassSummaries) {
+            taskSopPassSummaries.clear();
+        }
+        return passes;
+    }
+
+    // ======================== 分区调度:并行扫描看课表 ========================
+
+    /**
+     * 看课并行扫描 → 汇总关联键 → SOP 当前 oldAppId 优先,再历史 oldAppId 并行。
+     */
+    private void processUpdatePhase(List<SopPass> sopPasses, String startTime, long epoch,
+                                    String currentOldAppId, String currentNewAppId, long taskStartMs)
+            throws InterruptedException {
+        checkRunning(epoch);
+        progressPhase = PHASE_COUNT;
+
+        WatchLogScanBounds bounds;
+        try {
+            bounds = watchLogMapper.selectWatchLogScanBounds(startTime);
+        } catch (Exception e) {
+            log.error("{} 统计看课数据失败", LOG_TAG, e);
+            return;
+        }
+        if (bounds == null || bounds.getMinId() == null || bounds.getMaxId() == null
+                || bounds.getRowCount() == null || bounds.getRowCount() == 0) {
+            log.info("{} 今日无看课记录,无需处理 | 日期={}", LOG_TAG, startTime);
+            return;
+        }
+
+        long minId = bounds.getMinId();
+        long maxId = bounds.getMaxId();
+        long rowCount = bounds.getRowCount();
+        progressTotalRows = rowCount;
+        progressPhase = PHASE_SPLIT;
+
+        // 先打开始日志,再切分;大数据量用 ROW_NUMBER 按行均分,避免 log_id 空洞拖尾
+        progressEstimatedFinishMs = estimateInitialFinishMs(taskStartMs, rowCount);
+        log.info("{} 开始处理 | {} → {} | 日期={} | 看课共{}条 | 开始{} | 预计完成{}",
+                LOG_TAG, currentOldAppId, currentNewAppId, startTime, rowCount,
+                formatDateTime(taskStartMs), formatDateTime(progressEstimatedFinishMs));
+
+        List<Partition> partitions = buildPartitions(minId, maxId, rowCount, startTime);
+        int totalPartitions = partitions.size();
+        progressTotalPartitions = totalPartitions;
+        progressDonePartitions = 0;
+        long rowsPerPartition = (rowCount + totalPartitions - 1) / totalPartitions;
+        taskMaxCursorBatches = (int) Math.min(MAX_CURSOR_BATCHES_FALLBACK,
+                (rowsPerPartition + CURSOR_BATCH_SIZE - 1) / CURSOR_BATCH_SIZE + 200);
+        progressPhase = PHASE_SCAN;
+        log.info("{} 并行方案 | 分{}段扫看课 | {}线程 | 扫完后SOP {}轮(当前优先)",
+                LOG_TAG, totalPartitions, PARALLELISM, sopPasses.size());
+
+        CountDownLatch latch = new CountDownLatch(totalPartitions);
+        List<Future<?>> futures = new CopyOnWriteArrayList<>();
+        synchronized (futuresLock) {
+            activeFutures = futures;
+        }
+
+        try {
+            runPartitionsWithWindow(partitions, latch, futures, startTime, epoch, totalPartitions, rowCount);
+            progressDonePartitions = totalPartitions - (int) latch.getCount();
+
+            if (pendingSopKeys.isEmpty()) {
+                log.info("{} 看课扫描完成,无有效关联键,跳过SOP", LOG_TAG);
+                return;
+            }
+            List<WatchLogKeyVo> allKeys = new ArrayList<>(pendingSopKeys);
+            pendingSopKeys.clear();
+            log.info("{} 看课扫描完成 | 关联键{}个 | 开始SOP(当前oldAppId优先)",
+                    LOG_TAG, allKeys.size());
+
+            List<Future<long[]>> sopFutures = Collections.synchronizedList(new ArrayList<>());
+            synchronized (sopFuturesLock) {
+                activeSopFutures = sopFutures;
+            }
+            try {
+                processAllSopPasses(allKeys, sopPasses, startTime, epoch, sopFutures);
+            } catch (InterruptedException e) {
+                throw e;
+            } catch (Exception e) {
+                statPartitionErrors.increment();
+                log.error("{} SOP阶段异常,已结束剩余轮次", LOG_TAG, e);
+            }
+        } finally {
+            synchronized (futuresLock) {
+                activeFutures = Collections.emptyList();
+            }
+            synchronized (sopFuturesLock) {
+                activeSopFutures = Collections.emptyList();
+            }
+        }
+
+        if (statPartitionErrors.sum() > 0) {
+            log.error("{} 处理结束,有{}段任务曾发生异常,请检查上方错误日志", LOG_TAG, statPartitionErrors.sum());
+        }
+    }
+
+    /**
+     * 滑动窗口提交并行段:同时在途不超过 PARTITION_SUBMIT_WINDOW,避免一次性堆满队列。
+     */
+    private void runPartitionsWithWindow(List<Partition> partitions, CountDownLatch latch,
+                                         List<Future<?>> futures,
+                                         String startTime, long epoch,
+                                         int totalPartitions, long rowCount) throws InterruptedException {
+        ExecutorCompletionService<Void> completionService = new ExecutorCompletionService<>(partitionExecutor);
+        int nextIndex = 0;
+        int inFlight = 0;
+        int submittedCount = 0;
+        int idlePolls = 0;
+
+        while (nextIndex < partitions.size() || inFlight > 0) {
+            checkRunning(epoch);
+            while (nextIndex < partitions.size() && inFlight < resolveMaxInFlight(submittedCount, totalPartitions, rowCount)) {
+                Partition partition = partitions.get(nextIndex++);
+                if (partition.startId > partition.endId) {
+                    latch.countDown();
+                    continue;
+                }
+                try {
+                    Future<Void> future = completionService.submit(() -> {
+                        try {
+                            runOnePartition(partition, startTime, epoch);
+                        } finally {
+                            latch.countDown();
+                        }
+                        return null;
+                    });
+                    futures.add(future);
+                    inFlight++;
+                    submittedCount++;
+                    if (shouldStaggerPartitionSubmit(submittedCount, totalPartitions, rowCount)) {
+                        long staggerMs = (totalPartitions >= 8 || rowCount >= SINGLE_PARTITION_FALLBACK_THRESHOLD)
+                                ? PARTITION_SUBMIT_STAGGER_MS / 2 : PARTITION_SUBMIT_STAGGER_MS;
+                        Thread.sleep(staggerMs);
+                        checkRunning(epoch);
+                    }
+                } catch (RejectedExecutionException e) {
+                    latch.countDown();
+                    statPartitionErrors.increment();
+                    statBatchesDiscarded.increment();
+                    log.error("{} 段任务提交被拒绝,丢弃 | logId={}-{}", LOG_TAG, partition.startId, partition.endId, e);
+                }
+            }
+            if (inFlight == 0) {
+                break;
+            }
+            Future<Void> completed = completionService.poll(2, TimeUnit.SECONDS);
+            if (completed != null) {
+                idlePolls = 0;
+                try {
+                    completed.get();
+                } catch (ExecutionException e) {
+                    statPartitionErrors.increment();
+                    log.error("{} 某段任务执行失败", LOG_TAG, e.getCause());
+                } catch (CancellationException ignored) {
+                    // 任务被新请求取消
+                }
+                inFlight--;
+                progressDonePartitions = totalPartitions - (int) latch.getCount();
+            } else if (inFlight > 0) {
+                idlePolls++;
+                if (idlePolls >= PARTITION_POLL_IDLE_MAX) {
+                    statPartitionErrors.increment();
+                    statBatchesDiscarded.add(inFlight);
+                    log.error("{} 分区任务{}秒无进展,取消在途{}段并继续",
+                            LOG_TAG, idlePolls * 2L, inFlight);
+                    for (Future<?> f : futures) {
+                        f.cancel(true);
+                    }
+                    break;
+                }
+            }
+        }
+    }
+
+    private void runOnePartition(Partition partition, String startTime, long epoch) {
+        try {
+            updatePartition(partition, startTime, epoch);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        } catch (Exception e) {
+            statPartitionErrors.increment();
+            log.error("{} 某段任务异常 | logId={}-{}", LOG_TAG, partition.startId, partition.endId, e);
+        }
+    }
+
+    /**
+     * 单分区:游标扫描看课表 → 汇总关联键(本阶段不查 SOP,避免背压阻塞扫描)。
+     */
+    private void updatePartition(Partition partition, String startTime, long epoch)
+            throws InterruptedException {
+        long cursor = partition.startId - 1;
+        int batchCount = 0;
+        int batchesSinceFlush = 0;
+
+        Set<WatchLogKeyVo> dedupKeys = new HashSet<>(8192);
+
+        while (cursor < partition.endId) {
+            checkRunning(epoch);
+            if (++batchCount > taskMaxCursorBatches) {
+                log.error("{} 本段扫看课次数超限,强制结束 | 段范围logId={}-{} | 已扫{}批",
+                        LOG_TAG, partition.startId, partition.endId, batchCount - 1);
+                break;
+            }
+
+            List<FsCourseWatchLog> logs = fetchWatchLogsWithRetry(startTime, cursor, partition.endId);
+            if (logs == null) {
+                statBatchesDiscarded.increment();
+                log.error("{} 看课查询连续失败,本段提前结束 | logId={}-{} cursor={}",
+                        LOG_TAG, partition.startId, partition.endId, cursor);
+                break;
+            }
+            if (CollectionUtils.isEmpty(logs)) {
+                break;
+            }
+
+            FsCourseWatchLog lastLog = logs.get(logs.size() - 1);
+            Long lastLogId = lastLog != null ? lastLog.getLogId() : null;
+            if (lastLogId == null || lastLogId <= cursor) {
+                log.info("{} 看课游标没往前走,本段提前结束 | logId={}-{} cursor={}",
+                        LOG_TAG, partition.startId, partition.endId, cursor);
+                break;
+            }
+            cursor = lastLogId;
+            statWatchLogRows.add(logs.size());
+            batchesSinceFlush++;
+
+            for (FsCourseWatchLog watchLog : logs) {
+                WatchLogKeyVo key = buildKeyOrNull(watchLog);
+                if (key != null) {
+                    dedupKeys.add(key);
+                }
+            }
+            logs.clear();
+
+            if (dedupKeys.size() >= FLUSH_KEY_THRESHOLD
+                    || batchesSinceFlush >= FLUSH_EVERY_CURSOR_BATCHES) {
+                accumulatePendingKeys(dedupKeys, epoch);
+                batchesSinceFlush = 0;
+            }
+        }
+
+        if (!dedupKeys.isEmpty()) {
+            accumulatePendingKeys(dedupKeys, epoch);
+        }
+    }
+
+    /** 扫描阶段:关联键写入全局集合(跨段去重),扫完后再统一 SOP */
+    private void accumulatePendingKeys(Set<WatchLogKeyVo> dedupKeys, long epoch) throws InterruptedException {
+        checkRunning(epoch);
+        for (WatchLogKeyVo key : dedupKeys) {
+            if (pendingSopKeys.add(key)) {
+                statValidKeys.increment();
+            }
+        }
+        dedupKeys.clear();
+        if (pendingSopKeys.size() >= PENDING_KEYS_WARN_THRESHOLD
+                && pendingKeysWarned.compareAndSet(false, true)) {
+            log.warn("{} 待处理关联键已达{},请注意内存占用", LOG_TAG, pendingSopKeys.size());
+        }
+    }
+
+    /**
+     * 当前 oldAppId 第 1 轮优先,完成后历史 oldAppId 并行(均替换为本次 newAppId)。
+     */
+    private void processAllSopPasses(List<WatchLogKeyVo> allKeys, List<SopPass> passes,
+                                     String startTime, long epoch,
+                                     List<Future<long[]>> sopFutures)
+            throws InterruptedException {
+        if (passes.isEmpty()) {
+            return;
+        }
+        int totalPasses = passes.size();
+
+        runSingleSopPass(allKeys, passes.get(0), startTime, epoch, sopFutures, 1, totalPasses);
+
+        if (totalPasses > 1) {
+            List<SopPass> historicalPasses = new ArrayList<>(passes.subList(1, totalPasses));
+            if (historicalPasses.size() >= 2) {
+                processHistoricalPassesParallel(allKeys, historicalPasses, startTime, epoch,
+                        sopFutures, totalPasses);
+            } else {
+                runSingleSopPass(allKeys, historicalPasses.get(0), startTime, epoch, sopFutures, 2, totalPasses);
+            }
+        }
+        progressSopPassLabel = "";
+    }
+
+    /** 历史 oldAppId 有限并行(当前轮已完成后执行) */
+    private void processHistoricalPassesParallel(List<WatchLogKeyVo> allKeys, List<SopPass> historicalPasses,
+                                                 String startTime, long epoch,
+                                                 List<Future<long[]>> sopFutures,
+                                                 int totalPasses)
+            throws InterruptedException {
+        int totalHist = historicalPasses.size();
+        List<Future<?>> coordinators = new ArrayList<>(totalHist);
+
+        for (int i = 0; i < totalHist; i++) {
+            final SopPass pass = historicalPasses.get(i);
+            final int passNo = i + 2;
+            checkRunning(epoch);
+            try {
+                coordinators.add(historicalPassExecutor.submit(() -> {
+                    try {
+                        runSingleSopPass(allKeys, pass, startTime, epoch, sopFutures, passNo, totalPasses);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    } catch (Exception e) {
+                        statPartitionErrors.increment();
+                        log.error("{} 历史轮次异常 | {} → {}", LOG_TAG, pass.oldAppId, pass.targetNewAppId, e);
+                    }
+                }));
+            } catch (RejectedExecutionException e) {
+                statPartitionErrors.increment();
+                statBatchesDiscarded.increment();
+                log.error("{} 历史轮次提交失败,跳过 | {} → {}", LOG_TAG, pass.oldAppId, pass.targetNewAppId, e);
+            }
+        }
+        for (Future<?> coordinator : coordinators) {
+            checkRunning(epoch);
+            awaitCoordinatorFuture(coordinator);
+        }
+    }
+
+    /** 等待历史轮次协调 Future,带超时与取消,避免协调线程永久阻塞 */
+    private void awaitCoordinatorFuture(Future<?> coordinator) {
+        long deadlineMs = System.currentTimeMillis() + HISTORICAL_PASS_WAIT_SECONDS * 1000L;
+        while (!coordinator.isDone()) {
+            long remainMs = deadlineMs - System.currentTimeMillis();
+            if (remainMs <= 0) {
+                coordinator.cancel(true);
+                statBatchesDiscarded.increment();
+                log.error("{} 历史轮次等待超过{}秒,已取消", LOG_TAG, HISTORICAL_PASS_WAIT_SECONDS);
+                return;
+            }
+            try {
+                coordinator.get(Math.min(remainMs, SOP_FUTURE_WAIT_SECONDS * 1000L), TimeUnit.MILLISECONDS);
+                return;
+            } catch (TimeoutException ignored) {
+                // 继续等待
+            } catch (CancellationException ignored) {
+                return;
+            } catch (ExecutionException e) {
+                statPartitionErrors.increment();
+                log.error("{} 历史轮次并行协调失败", LOG_TAG, e.getCause());
+                return;
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                coordinator.cancel(true);
+                return;
+            }
+        }
+    }
+
+    /**
+     * 执行单轮 SOP(一个 oldAppId → 一个 newAppId);轮内批任务并行,轮末 await 收尾。
+     */
+    private void runSingleSopPass(List<WatchLogKeyVo> allKeys, SopPass pass,
+                                  String startTime, long epoch,
+                                  List<Future<long[]>> sopFutures,
+                                  int passNo, int totalPasses)
+            throws InterruptedException {
+        checkRunning(epoch);
+        progressPhase = PHASE_SOP_PASS;
+        progressSopPassLabel = passNo + "/" + totalPasses + " "
+                + pass.oldAppId + "→" + pass.targetNewAppId
+                + (pass.historical ? "(历史)" : "(当前)");
+        long passStartMs = System.currentTimeMillis();
+        long needBefore = totalNeed.sum();
+        long updatedBefore = totalUpdated.sum();
+
+        log.info("{} SOP第{}/{}轮开始 | [{}] {} → {} | 关联键{}个",
+                LOG_TAG, passNo, totalPasses,
+                pass.historical ? "历史" : "当前优先",
+                pass.oldAppId, pass.targetNewAppId, allKeys.size());
+
+        List<Future<long[]>> passFutures = Collections.synchronizedList(new ArrayList<>());
+        submitSopBatchesForPass(allKeys, pass, startTime, epoch, passFutures);
+        sopFutures.addAll(passFutures);
+
+        progressPhase = PHASE_SOP_DRAIN;
+        awaitAllSopFutures(passFutures, epoch);
+
+        long passNeed = totalNeed.sum() - needBefore;
+        long passUpdated = totalUpdated.sum() - updatedBefore;
+        long passCostSec = (System.currentTimeMillis() - passStartMs) / 1000;
+        String phaseLabel = pass.historical ? "历史" : "当前优先";
+        String status = passNeed == passUpdated
+                ? (passNeed == 0 ? "完成(无待改)" : "完成")
+                : "部分失败";
+
+        log.info("{} SOP第{}/{}轮完成 | [{}] {} → {} | 需处理{} 已处理{} 差额{} | 耗时{} | {}",
+                LOG_TAG, passNo, totalPasses, phaseLabel, pass.oldAppId, pass.targetNewAppId,
+                passNeed, passUpdated, passNeed - passUpdated, formatDuration(passCostSec), status);
+
+        synchronized (taskSopPassSummaries) {
+            taskSopPassSummaries.add(SopPassSummary.executed(pass, phaseLabel, passNeed, passUpdated,
+                    passCostSec, status));
+        }
+    }
+
+    /** 单轮 SOP:send_mini_app_id 等号匹配,批内并行、受背压限制 */
+    private void submitSopBatchesForPass(List<WatchLogKeyVo> allKeys, SopPass pass,
+                                         String startTime, long epoch,
+                                         List<Future<long[]>> passFutures)
+            throws InterruptedException {
+        String oldPattern = "\"miniprogramAppid\":\"" + pass.oldAppId + "\"";
+        String newPattern = "\"miniprogramAppid\":\"" + pass.targetNewAppId + "\"";
+
+        Semaphore permits = taskSopPermits;
+        for (int i = 0; i < allKeys.size(); i += SOP_KEY_BATCH_SIZE) {
+            checkRunning(epoch);
+            if (permits != null && !tryAcquireSopPermit(permits, epoch)) {
+                statBatchesDiscarded.increment();
+                continue;
+            }
+            int end = Math.min(i + SOP_KEY_BATCH_SIZE, allKeys.size());
+            List<WatchLogKeyVo> chunk = new ArrayList<>(allKeys.subList(i, end));
+            statSopTasksInFlight.increment();
+            try {
+                Future<long[]> future = sopExecutor.submit(() -> {
+                    try {
+                        long[] stats = processKeyChunkForAppId(chunk, pass.oldAppId, oldPattern, newPattern,
+                                pass.targetNewAppId, startTime, epoch);
+                        totalNeed.add(stats[0]);
+                        totalUpdated.add(stats[1]);
+                        return stats;
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        return new long[]{0, 0};
+                    } catch (Exception e) {
+                        statPartitionErrors.increment();
+                        log.error("{} 改库任务出错 | 轮次{}→{} | 本批{}个关联键",
+                                LOG_TAG, pass.oldAppId, pass.targetNewAppId, chunk.size(), e);
+                        return new long[]{0, 0};
+                    } finally {
+                        statSopTasksInFlight.decrement();
+                        if (permits != null) {
+                            permits.release();
+                        }
+                    }
+                });
+                passFutures.add(future);
+            } catch (RejectedExecutionException e) {
+                statSopTasksInFlight.decrement();
+                if (permits != null) {
+                    permits.release();
+                }
+                statPartitionErrors.increment();
+                statBatchesDiscarded.increment();
+                log.error("{} 改库任务提交失败,丢弃本批 | 轮次{}→{} | 关联键{}个",
+                        LOG_TAG, pass.oldAppId, pass.targetNewAppId, chunk.size(), e);
+            }
+        }
+    }
+
+    /** 等待本轮 SOP Future 收尾;单批超时过久则 cancel 丢弃,避免协调线程卡死 */
+    private void awaitAllSopFutures(List<Future<long[]>> sopFutures, long epoch) throws InterruptedException {
+        for (Future<long[]> future : sopFutures) {
+            int timeoutRounds = 0;
+            while (!future.isDone()) {
+                checkRunning(epoch);
+                try {
+                    future.get(SOP_FUTURE_WAIT_SECONDS, TimeUnit.SECONDS);
+                } catch (TimeoutException ignored) {
+                    timeoutRounds++;
+                    if (timeoutRounds >= SOP_FUTURE_MAX_TIMEOUT_ROUNDS) {
+                        future.cancel(true);
+                        statBatchesDiscarded.increment();
+                        statPartitionErrors.increment();
+                        log.error("{} 改库任务等待超过{}秒,已取消并丢弃本批",
+                                LOG_TAG, SOP_FUTURE_WAIT_SECONDS * timeoutRounds);
+                        break;
+                    }
+                } catch (CancellationException ignored) {
+                    break;
+                } catch (ExecutionException e) {
+                    statPartitionErrors.increment();
+                    log.error("{} 改库任务失败,已跳过本批", LOG_TAG, e.getCause());
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * 单 oldAppId 查 SOP(send_mini_app_id = ?,不用 AppId IN)并批量更新。
+     *
+     * @return long[2]:{需更新数, 实际成功数}
+     */
+    private long[] processKeyChunkForAppId(List<WatchLogKeyVo> keys, String oldAppId,
+                                           String oldPattern, String newPattern,
+                                           String targetNewAppId, String startTime, long epoch)
+            throws InterruptedException {
+        if (keys.isEmpty() || StringUtils.isBlank(oldAppId)) {
+            return new long[]{0, 0};
+        }
+        checkRunning(epoch);
+
+        List<QwSopLogs> matched;
+        try {
+            matched = sopLogsMapper.selectByKeysForAppId(keys, startTime, oldAppId);
+        } catch (Exception e) {
+            statBatchesDiscarded.increment();
+            log.error("{} 查SOP失败,丢弃本批 | oldAppId={} 关联键{}个", LOG_TAG, oldAppId, keys.size(), e);
+            return new long[]{0, 0};
+        }
+        if (CollectionUtils.isEmpty(matched)) {
+            return new long[]{0, 0};
+        }
+        if (matched.size() > 50_000) {
+            log.info("{} 单批SOP命中{}条偏多 oldAppId={}", LOG_TAG, matched.size(), oldAppId);
+        }
+        statSopMatched.add(matched.size());
+
+        long need = 0;
+        long updated = 0;
+        List<QwSopLogs> updateBatch = new ArrayList<>(UPDATE_BATCH_SIZE);
+
+        for (int offset = 0; offset < matched.size(); offset += 10_000) {
+            checkRunning(epoch);
+            int end = Math.min(offset + 10_000, matched.size());
+            for (int i = offset; i < end; i++) {
+                QwSopLogs sop = matched.get(i);
+                if (sop == null || StringUtils.isBlank(sop.getId())) {
+                    continue;
+                }
+                if (!oldAppId.equals(sop.getSendMiniAppId())) {
+                    continue;
+                }
+                String contentJson = sop.getContentJson();
+                if (StringUtils.isBlank(contentJson) || contentJson.length() > MAX_CONTENT_JSON_LENGTH) {
+                    continue;
+                }
+                if (!contentJson.contains(oldPattern)) {
+                    continue;
+                }
+                if (contentJson.contains(newPattern)) {
+                    continue;
+                }
+
+                need++;
+                needUpdateSopIds.add(sop.getId());
+                updateBatch.add(sop);
+                if (updateBatch.size() >= UPDATE_BATCH_SIZE) {
+                    updated += batchUpdateWithRetry(updateBatch, targetNewAppId, epoch);
+                    updateBatch.clear();
+                }
+            }
+        }
+        matched.clear();
+
+        if (!updateBatch.isEmpty()) {
+            updated += batchUpdateWithRetry(updateBatch, targetNewAppId, epoch);
+            updateBatch.clear();
+        }
+        return new long[]{need, updated};
+    }
+
+    /**
+     * 构建更新对象并分批提交;失败指数退避重试。
+     * <p>send_status=3 / memo=更换小程序 为业务约定,使 SOP 重新进入可发送状态。</p>
+     */
+    private int batchUpdateWithRetry(List<QwSopLogs> logs, String newAppId, long epoch)
+            throws InterruptedException {
+        if (CollectionUtils.isEmpty(logs)) {
+            return 0;
+        }
+
+        List<QwSopLogs> preparedBatch = new ArrayList<>(logs.size());
+        for (QwSopLogs original : logs) {
+            if (original == null || StringUtils.isBlank(original.getId())) {
+                continue;
+            }
+            String oldAppId = original.getSendMiniAppId();
+            if (StringUtils.isBlank(oldAppId)) {
+                continue;
+            }
+            QwSopLogs updateObj = new QwSopLogs();
+            updateObj.setId(original.getId());
+            updateObj.setSendMiniAppId(newAppId);
+            updateObj.setContentJson(replaceAppIdInJson(original.getContentJson(), oldAppId, newAppId));
+            updateObj.setSendStatus(3L);
+            updateObj.setRealSendTime(null);
+            updateObj.setRemark(null);
+            updateObj.setMemo("更换小程序补发");
+            updateObj.setAppSendStatus(0);
+            updateObj.setAppSendRemark(null);
+            preparedBatch.add(updateObj);
+        }
+        if (preparedBatch.isEmpty()) {
+            return 0;
+        }
+
+        int total = 0;
+        for (int i = 0; i < preparedBatch.size(); i += UPDATE_BATCH_SIZE) {
+            checkRunning(epoch);
+            int end = Math.min(i + UPDATE_BATCH_SIZE, preparedBatch.size());
+            List<QwSopLogs> batch = new ArrayList<>(preparedBatch.subList(i, end));
+
+            Exception lastEx = null;
+            int updatedCount = 0;
+            for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
+                checkRunning(epoch);
+                try {
+                    updatedCount = sopLogsMapper.batchUpdateQwSopLogsByIdInMiniAppId(batch);
+                    break;
+                } catch (Exception e) {
+                    lastEx = e;
+                    if (attempt < MAX_RETRY) {
+                        long delay = INIT_RETRY_DELAY_MS * (1L << (attempt - 1));
+                        Thread.sleep(delay);
+                    }
+                }
+            }
+            if (updatedCount == 0 && lastEx != null) {
+                statBatchesDiscarded.increment();
+                log.error("{} 批量写入SOP失败,丢弃本批{}条", LOG_TAG, batch.size(), lastEx);
+            } else if (updatedCount >= batch.size()) {
+                for (QwSopLogs item : batch) {
+                    if (item != null && StringUtils.isNotBlank(item.getId())) {
+                        successUpdateSopIds.add(item.getId());
+                    }
+                }
+            } else if (updatedCount > 0) {
+                log.info("{} 批量写入SOP仅成功{}/{}条,未成功的id计入漏改", LOG_TAG, updatedCount, batch.size());
+            }
+            total += updatedCount;
+        }
+        return total;
+    }
+
+    // ======================== 工具方法 ========================
+
+    /**
+     * 替换 content_json 中 miniprogramAppid 字段值。
+     * 优先字符串 replace(快);失败降级 Jackson 解析(兼容格式异常)。
+     */
+    private String replaceAppIdInJson(String contentJson, String oldAppId, String newAppId) {
+        if (StringUtils.isBlank(contentJson) || contentJson.length() > MAX_CONTENT_JSON_LENGTH
+                || StringUtils.isAnyBlank(oldAppId, newAppId)) {
+            return contentJson;
+        }
+        String target = "\"miniprogramAppid\":\"" + oldAppId + "\"";
+        if (!contentJson.contains(target)) {
+            return contentJson;
+        }
+        String replacement = "\"miniprogramAppid\":\"" + newAppId + "\"";
+        try {
+            return contentJson.replace(target, replacement);
+        } catch (Exception e) {
+            try {
+                com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(contentJson);
+                com.fasterxml.jackson.databind.JsonNode appIdNode = root.get("miniprogramAppid");
+                if (appIdNode != null && !appIdNode.isNull()
+                        && oldAppId.equals(appIdNode.asText())) {
+                    ((ObjectNode) root).put("miniprogramAppid", newAppId);
+                    return objectMapper.writeValueAsString(root);
+                }
+            } catch (Exception ex) {
+                log.error("{} JSON解析降级失败", LOG_TAG, ex);
+            }
+            return contentJson;
+        }
+    }
+
+    /**
+     * 从看课记录构建 SOP 关联键,与 qw_sop_logs 的 (qw_user_key,sop_id,fs_user_id,external_id) 对应。
+     * 任一必填字段缺失则返回 null(跳过该行,不中断批次)。
+     */
+    private WatchLogKeyVo buildKeyOrNull(FsCourseWatchLog watchLog) {
+        if (watchLog == null) {
+            return null;
+        }
+        String qwUserId = StringUtils.trimToNull(String.valueOf(watchLog.getQwUserId()));
+        String sopId = StringUtils.trimToNull(watchLog.getSopId());
+        Long userId = watchLog.getUserId();
+        Long externalId = watchLog.getQwExternalContactId();
+        if (StringUtils.isAnyBlank(qwUserId, sopId) || userId == null || externalId == null) {
+            return null;
+        }
+        return new WatchLogKeyVo(qwUserId, sopId, userId, externalId);
+    }
+
+    /** 看课游标查询,失败指数退避重试;仍失败返回 null 供上层丢弃本批 */
+    private List<FsCourseWatchLog> fetchWatchLogsWithRetry(String startTime, long cursor, long endId) {
+        Exception lastEx = null;
+        for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
+            try {
+                return watchLogMapper.selectWatchLogsByCursorUpTo(
+                        startTime, cursor, CURSOR_BATCH_SIZE, endId);
+            } catch (Exception e) {
+                lastEx = e;
+                log.warn("{} 看课查询失败 | 第{}次 cursor={}", LOG_TAG, attempt, cursor, e);
+                if (attempt < MAX_RETRY) {
+                    try {
+                        Thread.sleep(INIT_RETRY_DELAY_MS * (1L << (attempt - 1)));
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        return null;
+                    }
+                }
+            }
+        }
+        log.error("{} 看课查询连续失败 cursor={}", LOG_TAG, cursor, lastEx);
+        return null;
+    }
+
+    /** 背压等待 SOP 槽位;累计超时则放弃本批,避免永久阻塞 */
+    private boolean tryAcquireSopPermit(Semaphore permits, long epoch) throws InterruptedException {
+        long waitStart = System.currentTimeMillis();
+        while (!permits.tryAcquire(SOP_PERMIT_WAIT_SECONDS, TimeUnit.SECONDS)) {
+            checkRunning(epoch);
+            long waitedSec = (System.currentTimeMillis() - waitStart) / 1000;
+            log.warn("{} 等待SOP槽位已{}秒 | 在途SOP={}",
+                    LOG_TAG, waitedSec, statSopTasksInFlight.sum());
+            if (waitedSec >= SOP_PERMIT_MAX_WAIT_SECONDS) {
+                log.error("{} 等待SOP槽位超过{}秒,丢弃本批", LOG_TAG, SOP_PERMIT_MAX_WAIT_SECONDS);
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** 大数据量或多段时更快拉满并行窗口;小数据量渐进 ramp,避免 CPU 尖峰 */
+    private static int resolveMaxInFlight(int alreadySubmitted, int partitionCount, long rowCount) {
+        if (partitionCount <= 1) {
+            return 1;
+        }
+        if (rowCount >= SINGLE_PARTITION_FALLBACK_THRESHOLD || partitionCount >= 6) {
+            if (alreadySubmitted < 4) {
+                return Math.min(PARTITION_SUBMIT_WINDOW, 4);
+            }
+            return PARTITION_SUBMIT_WINDOW;
+        }
+        if (alreadySubmitted < 2) {
+            return 2;
+        }
+        if (alreadySubmitted < 6) {
+            return 4;
+        }
+        if (alreadySubmitted < 12) {
+            return 6;
+        }
+        return PARTITION_SUBMIT_WINDOW;
+    }
+
+    /** 小数据量全程错峰;大数据/多段仅前几次轻量错峰 */
+    private static boolean shouldStaggerPartitionSubmit(int submittedCount, int partitionCount, long rowCount) {
+        if (rowCount >= SINGLE_PARTITION_FALLBACK_THRESHOLD || partitionCount >= 6) {
+            return submittedCount <= 2 && submittedCount % PARTITION_SUBMIT_STAGGER_EVERY == 0;
+        }
+        return submittedCount <= 8 && submittedCount % PARTITION_SUBMIT_STAGGER_EVERY == 0;
+    }
+
+    private static int computeTargetPartitionCount(long rowCount) {
+        return (int) Math.min(MAX_PARTITIONS,
+                Math.max(1L, (rowCount + ROWS_PER_PARTITION - 1) / ROWS_PER_PARTITION));
+    }
+
+    /**
+     * 多段时一律按行数均分(ROW_NUMBER),保证每段真实行数接近,避免 log_id 空洞导致空段或拖尾段。
+     * 均分失败:大数据量降级为单段;小数据量才回退 log_id 等距。
+     */
+    private List<Partition> buildPartitions(long minId, long maxId, long rowCount, String startTime) {
+        int targetPartitions = computeTargetPartitionCount(rowCount);
+        if (targetPartitions <= 1) {
+            return Collections.singletonList(new Partition(minId, maxId));
+        }
+
+        log.info("{} 共{}条,按行数均分{}段(协调线程单次查询)…", LOG_TAG, rowCount, targetPartitions);
+        List<Partition> byRow = buildPartitionsByRowStep(minId, maxId, rowCount, startTime, targetPartitions);
+        if (byRow != null && byRow.size() >= 2) {
+            List<Partition> normalized = normalizePartitions(byRow, minId, maxId);
+            logPartitionSpanHint(normalized, rowCount, "按行数均分");
+            return normalized;
+        }
+
+        log.warn("{} 按行数均分失败", LOG_TAG);
+        if (rowCount >= SINGLE_PARTITION_FALLBACK_THRESHOLD) {
+            log.warn("{} 数据量较大,不回退log_id等距(易产生空段/拖尾),改为单段处理", LOG_TAG);
+            return Collections.singletonList(new Partition(minId, maxId));
+        }
+        List<Partition> byId = normalizePartitions(
+                buildPartitionsByIdRange(minId, maxId, rowCount), minId, maxId);
+        logPartitionSpanHint(byId, rowCount, "log_id等距");
+        return byId;
+    }
+
+    /** ROW_NUMBER 按约 ROWS_PER_PARTITION 行取分界 log_id,使各段行数接近 */
+    private List<Partition> buildPartitionsByRowStep(long minId, long maxId, long rowCount,
+                                                     String startTime, int targetPartitions) {
+        if (targetPartitions <= 1) {
+            return null;
+        }
+        long stepSize = Math.max(1L, (rowCount + targetPartitions - 1) / targetPartitions);
+        List<Long> boundaryIds = null;
+        Exception lastEx = null;
+        for (int attempt = 1; attempt <= 2; attempt++) {
+            try {
+                long splitStartMs = System.currentTimeMillis();
+                boundaryIds = watchLogMapper.selectLogIdsAtRowStep(startTime, stepSize, rowCount);
+                log.info("{} 按行数均分查询完成 | 第{}次 | 用时{}秒 | 分界点{}个",
+                        LOG_TAG, attempt, (System.currentTimeMillis() - splitStartMs) / 1000,
+                        boundaryIds == null ? 0 : boundaryIds.size());
+                break;
+            } catch (Exception e) {
+                lastEx = e;
+                log.warn("{} 按行数均分查询失败 | 第{}次", LOG_TAG, attempt, e);
+                if (attempt < 2) {
+                    try {
+                        Thread.sleep(2000L);
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        return null;
+                    }
+                }
+            }
+        }
+        if (boundaryIds == null && lastEx != null) {
+            return null;
+        }
+        if (CollectionUtils.isEmpty(boundaryIds)) {
+            return null;
+        }
+        List<Partition> partitions = new ArrayList<>(boundaryIds.size() + 1);
+        long segmentStart = minId;
+        for (Long boundaryId : boundaryIds) {
+            if (boundaryId == null || boundaryId <= segmentStart) {
+                continue;
+            }
+            partitions.add(new Partition(segmentStart, boundaryId));
+            segmentStart = boundaryId + 1;
+        }
+        if (segmentStart <= maxId) {
+            partitions.add(new Partition(segmentStart, maxId));
+        }
+        return partitions.isEmpty() ? null : partitions;
+    }
+
+    /** 合并无效/重叠段,去掉 startId>endId 的空段 */
+    private static List<Partition> normalizePartitions(List<Partition> partitions, long minId, long maxId) {
+        if (CollectionUtils.isEmpty(partitions)) {
+            return Collections.singletonList(new Partition(minId, maxId));
+        }
+        List<Partition> result = new ArrayList<>(partitions.size());
+        long cursor = minId;
+        for (Partition p : partitions) {
+            if (p == null) {
+                continue;
+            }
+            long start = Math.max(cursor, p.startId);
+            long end = Math.min(maxId, p.endId);
+            if (start > end) {
+                continue;
+            }
+            result.add(new Partition(start, end));
+            cursor = end + 1;
+        }
+        if (result.isEmpty()) {
+            return Collections.singletonList(new Partition(minId, maxId));
+        }
+        Partition last = result.get(result.size() - 1);
+        if (last.endId < maxId && cursor <= maxId) {
+            result.add(new Partition(cursor, maxId));
+        }
+        return result;
+    }
+
+    private void logPartitionSpanHint(List<Partition> partitions, long rowCount, String strategy) {
+        if (partitions.size() <= 1) {
+            log.info("{} 分段策略={} | 共{}段", LOG_TAG, strategy, partitions.size());
+            return;
+        }
+        long minSpan = Long.MAX_VALUE;
+        long maxSpan = 0;
+        for (Partition p : partitions) {
+            long span = p.endId - p.startId + 1;
+            minSpan = Math.min(minSpan, span);
+            maxSpan = Math.max(maxSpan, span);
+        }
+        long avgRowsHint = rowCount / partitions.size();
+        log.info("{} 分段策略={} | 共{}段 | log_id跨度 min={} max={} | 每段约{}行",
+                LOG_TAG, strategy, partitions.size(), minSpan, maxSpan, avgRowsHint);
+    }
+
+    /**
+     * 按 log_id 等距切分(O(1) 无 DB 重查询)。
+     * log_id 有空洞时段内行数可能严重不均,仅在小数据量或均分失败时使用。
+     */
+    private List<Partition> buildPartitionsByIdRange(long minId, long maxId, long rowCount) {
+        int targetPartitions = computeTargetPartitionCount(rowCount);
+        if (targetPartitions <= 1) {
+            return Collections.singletonList(new Partition(minId, maxId));
+        }
+        long idSpan = maxId - minId + 1;
+        if (idSpan <= targetPartitions) {
+            return Collections.singletonList(new Partition(minId, maxId));
+        }
+        long idStep = Math.max(1L, idSpan / targetPartitions);
+        List<Partition> partitions = new ArrayList<>(targetPartitions);
+        long segmentStart = minId;
+        for (int i = 0; i < targetPartitions - 1; i++) {
+            long segmentEnd = Math.min(maxId, segmentStart + idStep - 1);
+            if (segmentEnd >= maxId) {
+                break;
+            }
+            partitions.add(new Partition(segmentStart, segmentEnd));
+            segmentStart = segmentEnd + 1;
+        }
+        if (segmentStart <= maxId) {
+            partitions.add(new Partition(segmentStart, maxId));
+        }
+        return partitions.isEmpty()
+                ? Collections.singletonList(new Partition(minId, maxId))
+                : partitions;
+    }
+
+    /** 任务开始时根据数据量粗算预计完成时刻(后续进度汇报会按实际速度修正) */
+    private static long estimateInitialFinishMs(long taskStartMs, long rowCount) {
+        if (rowCount <= 0) {
+            return taskStartMs;
+        }
+        long scanRowsPerMin = Math.max(20_000L, PARALLELISM * EST_SCAN_ROWS_PER_THREAD_PER_MIN * 8 / 10);
+        long scanMinutes = (rowCount + scanRowsPerMin - 1) / scanRowsPerMin;
+        long sopExtraMin = Math.max(5L, (rowCount + 500_000L - 1) / 500_000L);
+        return taskStartMs + (scanMinutes + sopExtraMin) * 60_000L;
+    }
+
+    /**
+     * 统一取消检查点:cancelled / epoch 过期 / 线程 interrupt 均抛出 InterruptedException。
+     * 各层 catch 后应恢复 interrupt 状态或向上传播。
+     */
+    private void checkRunning(long epoch) throws InterruptedException {
+        if (cancelled.get() || epoch != taskEpoch.get() || Thread.currentThread().isInterrupted()) {
+            throw new InterruptedException("任务被取消");
+        }
+    }
+
+    /** 启动进度汇报:每 5 分钟 1 条(启动时不打,避免与「开始处理」重复) */
+    private ScheduledFuture<?> startProgressHeartbeat(long epoch, long taskStartMs) {
+        synchronized (heartbeatLock) {
+            stopProgressHeartbeat(activeHeartbeatFuture);
+            progressEpoch = epoch;
+            progressTaskStartMs = taskStartMs;
+            progressPhase = PHASE_PREPARE;
+            progressTotalRows = 0L;
+            progressDonePartitions = 0;
+            progressTotalPartitions = 0;
+            heartbeatLastScannedRows = 0L;
+            heartbeatLastTickMs = taskStartMs;
+            progressEstimatedFinishMs = 0L;
+            activeHeartbeatFuture = heartbeatExecutor.scheduleWithFixedDelay(
+                    () -> runProgressHeartbeatTick(epoch),
+                    PROGRESS_LOG_INTERVAL_MS,
+                    PROGRESS_LOG_INTERVAL_MS,
+                    TimeUnit.MILLISECONDS);
+            return activeHeartbeatFuture;
+        }
+    }
+
+    private void runProgressHeartbeatTick(long epoch) {
+        try {
+            if (destroyed.get() || epoch != progressEpoch || epoch != taskEpoch.get()) {
+                return;
+            }
+            logProgressHeartbeat();
+        } catch (Exception e) {
+            log.error("{} 进度汇报线程异常", LOG_TAG, e);
+        }
+    }
+
+    private void stopProgressHeartbeat(ScheduledFuture<?> heartbeatFuture) {
+        progressEpoch = -1L;
+        synchronized (heartbeatLock) {
+            ScheduledFuture<?> toCancel = heartbeatFuture != null ? heartbeatFuture : activeHeartbeatFuture;
+            if (toCancel != null) {
+                toCancel.cancel(false);
+            }
+            if (toCancel == activeHeartbeatFuture) {
+                activeHeartbeatFuture = null;
+            }
+        }
+    }
+
+    /** 进度汇报:每 5 分钟输出已处理/目标/百分比,以及改库情况 */
+    private void logProgressHeartbeat() {
+        long nowMs = System.currentTimeMillis();
+        long runSec = progressTaskStartMs > 0 ? (nowMs - progressTaskStartMs) / 1000 : 0;
+        long scannedRows = statWatchLogRows.sum();
+        long totalRows = progressTotalRows;
+        int rowPct = totalRows > 0 ? (int) Math.min(100, scannedRows * 100 / totalRows) : 0;
+        long need = totalNeed.sum();
+        long updated = totalUpdated.sum();
+        long updateGap = need - updated;
+        long sopBusy = statSopTasksInFlight.sum();
+
+        updateEstimatedFinish(nowMs, runSec, scannedRows, totalRows, sopBusy);
+
+        String note = "";
+        if (scannedRows >= totalRows && sopBusy > 0 && StringUtils.isNotBlank(progressSopPassLabel)) {
+            note = " | SOP轮次:" + progressSopPassLabel;
+        } else if (statBatchesDiscarded.sum() > 0) {
+            note = " | 已丢弃批次:" + statBatchesDiscarded.sum();
+        }
+        heartbeatLastScannedRows = scannedRows;
+        heartbeatLastTickMs = nowMs;
+
+        if (totalRows <= 0) {
+            log.info("{} 进度汇报(每5分钟)| 准备中 | 已用{} | 预计完成{}",
+                    LOG_TAG, formatDuration(runSec), formatDateTime(progressEstimatedFinishMs));
+            return;
+        }
+
+        log.info("{} 进度汇报(每5分钟)| 看课:已处理{} / 目标{}({}%)| 改库:需改{} 已改{} 差额{} | 已用{} | 预计完成{}{}",
+                LOG_TAG,
+                scannedRows, totalRows, rowPct,
+                need, updated, updateGap,
+                formatDuration(runSec),
+                formatDateTime(progressEstimatedFinishMs),
+                note);
+    }
+
+    /** 按当前均速修正预计完成时刻(运行满 1 分钟后生效) */
+    private void updateEstimatedFinish(long nowMs, long runSec, long scannedRows, long totalRows, long sopBusy) {
+        if (totalRows <= 0) {
+            return;
+        }
+        if (runSec < 60) {
+            return;
+        }
+        if (scannedRows >= totalRows) {
+            progressEstimatedFinishMs = sopBusy > 0 ? nowMs + PROGRESS_LOG_INTERVAL_MS : nowMs;
+            return;
+        }
+        if (scannedRows > 0) {
+            long remainSec = (totalRows - scannedRows) * runSec / scannedRows;
+            long sopBufferSec = Math.max(300L, totalRows / 500_000L * 60L);
+            progressEstimatedFinishMs = nowMs + (remainSec + sopBufferSec) * 1000L;
+        }
+    }
+
+    private static String formatDateTime(long epochMs) {
+        return DATETIME_FMT.format(Instant.ofEpochMilli(epochMs));
+    }
+
+    private static String formatDuration(long seconds) {
+        if (seconds < 60) {
+            return seconds + "秒";
+        }
+        long minutes = seconds / 60;
+        if (minutes < 60) {
+            return minutes + "分钟";
+        }
+        long hours = minutes / 60;
+        long remainMin = minutes % 60;
+        return remainMin > 0 ? hours + "小时" + remainMin + "分钟" : hours + "小时";
+    }
+
+    /** 任务结束报告:预计 vs 实际,一目了然 */
+    private void logEnd(String oldAppId, String newAppId, String startTime,
+                        long startMs, String status, String reason) {
+        long endMs = System.currentTimeMillis();
+        long costSec = (endMs - startMs) / 1000;
+        long scanned = statWatchLogRows.sum();
+        long need = totalNeed.sum();
+        long updated = totalUpdated.sum();
+        long estimatedMs = progressEstimatedFinishMs;
+
+        log.info("{} ===== 任务报告 =====", LOG_TAG);
+        log.info("{}   结果:{}{}", LOG_TAG, status,
+                StringUtils.isNotBlank(reason) ? "(" + reason + ")" : "");
+        log.info("{}   换绑:{} → {} | 数据日期:{}", LOG_TAG, oldAppId, newAppId, startTime);
+        log.info("{}   开始:{}", LOG_TAG, formatDateTime(startMs));
+        log.info("{}   结束:{}", LOG_TAG, formatDateTime(endMs));
+        log.info("{}   预计完成:{} | 实际耗时:{}", LOG_TAG,
+                estimatedMs > 0 ? formatDateTime(estimatedMs) : formatDateTime(endMs),
+                formatDuration(costSec));
+        if (estimatedMs > 0 && "完成".equals(status)) {
+            long diffSec = Math.abs(endMs - estimatedMs) / 1000;
+            if (diffSec <= 60) {
+                log.info("{}   与预计对比:基本准时(误差{}分钟内)", LOG_TAG, Math.max(1, diffSec / 60));
+            } else {
+                String diffDesc = endMs <= estimatedMs
+                        ? "比预计早" + formatDuration(diffSec)
+                        : "比预计晚" + formatDuration(diffSec);
+                log.info("{}   与预计对比:{}", LOG_TAG, diffDesc);
+            }
+        }
+        log.info("{}   看课扫描:{}条", LOG_TAG, scanned);
+        log.info("{}   改库:需改{}条 | 已改{}条 | 差额{}条", LOG_TAG, need, updated, need - updated);
+        long discarded = statBatchesDiscarded.sum();
+        if (discarded > 0) {
+            log.info("{}   丢弃批次:{}(超时或异常,详见上方错误日志)", LOG_TAG, discarded);
+        }
+        long errors = statPartitionErrors.sum();
+        if (errors > 0) {
+            log.info("{}   异常计数:{}", LOG_TAG, errors);
+        }
+        logSopPassDetailReport();
+        logUpdateGapConclusion(need, updated);
+        log.info("{} ====================", LOG_TAG);
+    }
+
+    private void logSopPassDetailReport() {
+        List<SopPassSummary> summaries;
+        synchronized (taskSopPassSummaries) {
+            if (taskSopPassSummaries.isEmpty()) {
+                return;
+            }
+            summaries = new ArrayList<>(taskSopPassSummaries);
+        }
+
+        log.info("{} --- 各旧小程序换绑明细 ---", LOG_TAG);
+        int partialFailed = 0;
+        for (SopPassSummary s : summaries) {
+            long gap = s.need - s.updated;
+            if (gap != 0) {
+                partialFailed++;
+            }
+            log.info("{}   [{}] {} → {} | 需处理{} 已处理{} 差额{} | 耗时{} | {}",
+                    LOG_TAG, s.phaseLabel, s.oldAppId, s.targetNewAppId,
+                    s.need, s.updated, gap, formatDuration(s.durationSec), s.status);
+        }
+
+        String coverage = partialFailed == 0
+                ? "Redis登记" + taskRegistryOldAppCount + "个旧AppId均已执行,需处理=已处理"
+                : "有" + partialFailed + "个旧AppId存在未改完记录,请核查";
+        log.info("{}   覆盖结论:共执行{}轮 | 登记{}个旧AppId | {}",
+                LOG_TAG, summaries.size(), taskRegistryOldAppCount, coverage);
+        log.info("{} --- 明细结束 ---", LOG_TAG);
+    }
+
+    private void logUpdateGapConclusion(long need, long updated) {
+        long gap = need - updated;
+        if (gap == 0) {
+            log.info("{}   结论:数据处理全部成功", LOG_TAG);
+            return;
+        }
+        if (gap > 0) {
+            Set<String> missed = computeMissedSopIds();
+            log.info("{}   结论:有{}条数据未补发", LOG_TAG, gap);
+            logMissedSopIds(missed);
+            return;
+        }
+        log.info("{}   结论:已改超出需改,请核查", LOG_TAG);
+    }
+
+    private Set<String> computeMissedSopIds() {
+        Set<String> missed = new HashSet<>(needUpdateSopIds);
+        missed.removeAll(successUpdateSopIds);
+        return missed;
+    }
+
+    /** 分批打印漏改的 qw_sop_logs.id,避免单条日志过长 */
+    private void logMissedSopIds(Set<String> missedIds) {
+        if (missedIds.isEmpty()) {
+            log.info("{}   漏改qw_sop_logs.id:未能定位具体记录,请按差额在库内核查", LOG_TAG);
+            return;
+        }
+        List<String> ids = new ArrayList<>(missedIds);
+        Collections.sort(ids);
+        int logCount = Math.min(ids.size(), MAX_MISSED_IDS_LOG);
+        for (int i = 0; i < logCount; i += 100) {
+            int end = Math.min(i + 100, logCount);
+            log.info("{}   漏改qw_sop_logs.id:{}", LOG_TAG, String.join(",", ids.subList(i, end)));
+        }
+        if (ids.size() > MAX_MISSED_IDS_LOG) {
+            log.info("{}   漏改id共{}条,以上仅展示前{}条", LOG_TAG, ids.size(), MAX_MISSED_IDS_LOG);
+        }
+    }
+
+    /** 单轮 SOP 查改:历史 oldAppId 统一替换为本次 newAppId */
+    private static final class SopPass {
+        final String oldAppId;
+        final String targetNewAppId;
+        /** true=历史 oldAppId 轮,false=当前 oldAppId(始终第 1 轮) */
+        final boolean historical;
+
+        SopPass(String oldAppId, String targetNewAppId, boolean historical) {
+            this.oldAppId = oldAppId;
+            this.targetNewAppId = targetNewAppId;
+            this.historical = historical;
+        }
+    }
+
+    /** 单个 oldAppId 处理结果 */
+    private static final class SopPassSummary {
+        final String oldAppId;
+        final String targetNewAppId;
+        final String phaseLabel;
+        final long need;
+        final long updated;
+        final long durationSec;
+        final String status;
+
+        private SopPassSummary(String oldAppId, String targetNewAppId, String phaseLabel,
+                               long need, long updated, long durationSec, String status) {
+            this.oldAppId = oldAppId;
+            this.targetNewAppId = targetNewAppId;
+            this.phaseLabel = phaseLabel;
+            this.need = need;
+            this.updated = updated;
+            this.durationSec = durationSec;
+            this.status = status;
+        }
+
+        static SopPassSummary executed(SopPass pass, String phaseLabel,
+                                       long need, long updated, long durationSec, String status) {
+            return new SopPassSummary(pass.oldAppId, pass.targetNewAppId, phaseLabel,
+                    need, updated, durationSec, status);
+        }
+    }
+
+    /** log_id 闭区间 [startId, endId] */
+    private static class Partition {
+        final long startId;
+        final long endId;
+
+        Partition(long start, long end) {
+            this.startId = start;
+            this.endId = end;
+        }
+    }
+
+    private static ThreadFactory namedThreadFactory(final String prefix) {
+        final AtomicLong seq = new AtomicLong(1);
+        return r -> {
+            Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement());
+            t.setDaemon(true);
+            t.setUncaughtExceptionHandler((thread, ex) ->
+                    log.error("{} 线程未捕获异常 thread={}", LOG_TAG, thread.getName(), ex));
+            return t;
+        };
+    }
+
+    private static void shutdownExecutor(ExecutorService executor, String name, long awaitSeconds) {
+        executor.shutdown();
+        try {
+            if (!executor.awaitTermination(awaitSeconds, TimeUnit.SECONDS)) {
+                executor.shutdownNow();
+                if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
+                    log.info("{} {} 未能在限定时间内完全终止", LOG_TAG, name);
+                }
+            }
+        } catch (InterruptedException e) {
+            executor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    // ======================== 生命周期:Spring 容器关闭时回收线程池 ========================
+
+    @PreDestroy
+    public void destroy() {
+        shutdown();
+    }
+
+    /**
+     * 优雅关闭:递增 epoch → 取消分区 → 先停协调池(60s) 再停分区池(30s)。
+     * 重复调用安全(CAS destroyed)。
+     */
+    public void shutdown() {
+        if (destroyed.compareAndSet(false, true)) {
+            taskEpoch.incrementAndGet();
+            cancelled.set(true);
+            cancelActiveFutures();
+            stopProgressHeartbeat(null);
+            shutdownExecutor(historicalPassExecutor, "miniapp-replace-hist-pass", 30);
+            shutdownExecutor(sopExecutor, "miniapp-replace-sop", 60);
+            shutdownExecutor(heartbeatExecutor, "miniapp-replace-heartbeat", 5);
+            shutdownExecutor(partitionExecutor, "miniapp-replace-partition", 30);
+            shutdownExecutor(coordinatorExecutor, "miniapp-replace-coordinator", 60);
+            log.info("{} 补发小程序线程池已销毁", LOG_TAG);
+        }
+    }
+}

+ 59 - 0
fs-service/src/main/java/com/fs/system/support/MiniAppReplaceSupport.java

@@ -0,0 +1,59 @@
+package com.fs.system.support;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.regex.Pattern;
+
+/**
+ * 小程序 AppId 替换:参数校验与内部调用鉴权
+ */
+public final class MiniAppReplaceSupport {
+
+    public static final String INTERNAL_TOKEN_HEADER = "X-MiniApp-Replace-Token";
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final Pattern DATE = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
+    /** 微信 AppId 常见字符集,防止 JSON 注入 */
+    private static final Pattern APP_ID = Pattern.compile("^[a-zA-Z0-9_-]{1,64}$");
+
+    private MiniAppReplaceSupport() {
+    }
+
+    /**
+     * @return 错误信息;null 表示校验通过
+     */
+    public static String validateReplaceParams(String oldAppId, String newAppId, String startTime) {
+        if (StringUtils.isAnyBlank(oldAppId, newAppId, startTime)) {
+            return "oldAppId、newAppId 和 startTime 不能为空";
+        }
+        if (oldAppId.length() > 64 || newAppId.length() > 64) {
+            return "AppId 长度非法";
+        }
+        if (!APP_ID.matcher(oldAppId).matches() || !APP_ID.matcher(newAppId).matches()) {
+            return "AppId 格式非法,仅允许字母数字下划线横线";
+        }
+        if (oldAppId.equals(newAppId)) {
+            return "新旧 AppId 不能相同";
+        }
+        if (!DATE.matcher(startTime).matches()) {
+            return "startTime 格式须为 yyyy-MM-dd";
+        }
+        try {
+            LocalDate date = LocalDate.parse(startTime, DATE_FMT);
+            if (date.isAfter(LocalDate.now())) {
+                return "startTime 不能晚于今天";
+            }
+        } catch (DateTimeParseException e) {
+            return "startTime 日期无效";
+        }
+        return null;
+    }
+
+    public static boolean isInternalTokenValid(String configuredToken, String providedToken) {
+        return StringUtils.isNotBlank(configuredToken)
+                && StringUtils.isNotBlank(providedToken)
+                && configuredToken.equals(providedToken);
+    }
+}

+ 92 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java

@@ -0,0 +1,92 @@
+package com.fs.xiaoshouyi.client;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONUtil;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.util.Map;
+
+@Slf4j
+@Component
+@Data
+public class XiaoShouYiHttpClient {
+
+    private static final int TIMEOUT = 30000;
+
+    public HttpResult postForm(String url, Map<String, Object> form) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .form(form)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postForm请求异常, url={}", url, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public HttpResult postJson(String url, String authHeader, Object bodyObj) {
+        String bodyJson = JSONUtil.toJsonStr(bodyObj);
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .body(bodyJson)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postJson请求异常, url={}, body={}", url, bodyJson, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public HttpResult get(String url, String authHeader) {
+        try (HttpResponse response = HttpRequest.get(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易get请求异常, url={}", url, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * multipart/form-data 上传文件
+     */
+    public HttpResult postMultipart(String url, String authHeader, File file, Boolean isVideo) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Authorization", authHeader)
+                .form("file", file)
+                .form("isVideo", isVideo == null ? Boolean.FALSE : isVideo)
+                .timeout(TIMEOUT)
+                .execute()) {
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postMultipart请求异常, url={}, file={}, isVideo={}",
+                    url, file == null ? null : file.getAbsolutePath(), isVideo, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    @Data
+    public static class HttpResult {
+        private final int status;
+        private final String body;
+
+        public HttpResult(int status, String body) {
+            this.status = status;
+            this.body = body;
+        }
+
+    }
+}

+ 61 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/config/XiaoShouYiProperties.java

@@ -0,0 +1,61 @@
+package com.fs.xiaoshouyi.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "xiaoshouyi")
+public class XiaoShouYiProperties {
+
+    /**
+     * OAuth授权地址
+     */
+    private String authUrl;
+
+    /**
+     * Token接口地址
+     */
+    private String tokenUrl;
+
+    /**
+     * 默认业务API地址
+     */
+    private String defaultApiBaseUrl;
+
+    /**
+     * 应用ID
+     */
+    private String clientId;
+
+    /**
+     * 应用密钥
+     */
+    private String clientSecret;
+
+    /**
+     * 授权回调地址
+     */
+    private String redirectUri;
+
+    /**
+     * scope
+     */
+    private String scope = "all";
+
+    /**
+     * oauth类型
+     */
+    private String oauthType = "standard";
+
+    /**
+     * offline时返回refresh_token
+     */
+    private String accessType = "offline";
+
+    /**
+     * refresh_token 剩余天数低于该值时标记为即将过期
+     */
+    private Integer tokenExpireWarnDays = 7;
+}

+ 29 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/constant/XiaoShouYiRedisKey.java

@@ -0,0 +1,29 @@
+package com.fs.xiaoshouyi.constant;
+
+public interface XiaoShouYiRedisKey {
+
+    /**
+     * token完整信息
+     */
+    String TOKEN_INFO = "xsy:oauth:token_info";
+
+    /**
+     * access token快捷缓存
+     */
+    String ACCESS_TOKEN = "xsy:oauth:access_token";
+
+    /**
+     * 刷新token分布式锁
+     */
+    String REFRESH_LOCK = "xsy:oauth:refresh_lock";
+
+    /**
+     * OAuth state前缀
+     */
+    String OAUTH_STATE_PREFIX = "xsy:oauth:state:";
+
+    /**
+     * 授权状态快照(供前端展示与告警)
+     */
+    String TOKEN_STATUS = "xsy:oauth:token_status";
+}

+ 131 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialRequest.java

@@ -0,0 +1,131 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class CreateMaterialRequest {
+
+    /**
+     * 素材所属企业主体名称
+     */
+    private String corpName;
+
+    /**
+     * 素材类型
+     * 2:图片
+     * 5:视频
+     * 6:文件
+     * 7:小程序
+     * 31:自建网页
+     * 32:转载其他外链
+     * 33:转载公众号图文
+     */
+    private Integer materialType;
+
+    /**
+     * 素材所属分类名称,多级分类格式:父分类名称/子分类名称
+     */
+    private String categoryName;
+
+    /**
+     * 素材名称
+     */
+    private String title;
+
+    /**
+     * 已上传素材文件ID
+     * 图片/视频/文件类型时必传
+     */
+    private Long fileId;
+
+    /**
+     * 启用状态
+     * 0:未启用
+     * 1:已启用
+     */
+    private Integer enableStatus;
+
+    /**
+     * 配文文本
+     */
+    private String sendingText;
+
+    /**
+     * 是否开启素材名片模板
+     * 0:未启用
+     * 1:已启用
+     */
+    private Integer enableCardTemplateStatus;
+
+    /**
+     * 摘要,仅视频、网页类素材有效
+     */
+    private String summary;
+
+    /**
+     * 封面图片ID
+     * 视频、网页、外链等类型时有效
+     */
+    private Long coverFileId;
+
+    /**
+     * 自建网页正文
+     */
+    private String content;
+
+    /**
+     * 外链地址,materialType=32/33 时使用
+     */
+    private String url;
+
+    /**
+     * 小程序 appId,materialType=7 时使用
+     */
+    private String appId;
+
+    /**
+     * 小程序 path,materialType=7 时使用
+     */
+    private String path;
+
+    /**
+     * 行为追踪配置
+     */
+    private MaterialBehaviorTrack materialBehaviorTrack;
+
+    @Data
+    public static class MaterialBehaviorTrack {
+
+        /**
+         * 行为通知配置
+         */
+        private MaterialBehaviorNotify materialBehaviorNotify;
+
+        /**
+         * 是否开启通知免打扰
+         * true:开启
+         * false:不开启
+         */
+        private Boolean disturbFlg;
+    }
+
+    @Data
+    public static class MaterialBehaviorNotify {
+
+        /**
+         * 通知范围
+         * 1:进入页面
+         * 2:停留时长
+         * 3:浏览到底部
+         * 4:转发页面
+         * 5:进入名片
+         * 6:扫描名片二维码
+         */
+        private java.util.List<String> notifyRange;
+
+        /**
+         * 停留时长,单位秒
+         */
+        private Long stayPushTime;
+    }
+}

+ 32 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialResponse.java

@@ -0,0 +1,32 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class CreateMaterialResponse {
+
+    private String code;
+    private String msg;
+    private Boolean success;
+    private CreateMaterialData data;
+
+    public static CreateMaterialResponse error(String msg) {
+        CreateMaterialResponse response = new CreateMaterialResponse();
+        response.setCode("500");
+        response.setMsg(msg);
+        response.setSuccess(false);
+        return response;
+    }
+
+    @Data
+    public static class CreateMaterialData {
+        /**
+         * 创建素材的ID
+         */
+        private Entry entry;
+    }
+    @Data
+    public static class Entry {
+        private Long id;
+    }
+}

+ 32 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialWithUploadResponse.java

@@ -0,0 +1,32 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class CreateMaterialWithUploadResponse {
+
+    /**
+     * 创建出的素材ID
+     */
+    private Long materialId;
+
+    /**
+     * 上传后的文件ID
+     */
+    private Long fileId;
+
+    /**
+     * 上传后的封面文件ID(视频时可能有)
+     */
+    private Long coverFileId;
+
+    /**
+     * 上传文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 封面URL
+     */
+    private String coverUrl;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/FullProcessResult.java

@@ -0,0 +1,22 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class FullProcessResult {
+
+    private List<ItemResult> trackedResults;
+    private List<Long> untrackedMaterialIds;
+
+    @Data
+    public static class ItemResult {
+        private Long materialId;
+        private Integer materialType;
+        private String redirectUrl;
+        private Long forwardId;
+        private Boolean sendConfirmSuccess;
+        private String sendConfirmMsg;
+    }
+}

+ 17 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/GenerateLinkRequest.java

@@ -0,0 +1,17 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+@Data
+public class GenerateLinkRequest {
+
+    @NotEmpty(message = "materialIds不能为空")
+    private List<Long> materialIds;
+
+    @NotNull(message = "sendUserId不能为空")
+    private Long sendUserId;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/GenerateLinkResponse.java

@@ -0,0 +1,33 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import java.util.Collections;
+import java.util.List;
+
+@Data
+public class GenerateLinkResponse {
+
+    private boolean success;
+    private String code;
+    private String msg;
+    private GenerateLinkData data;
+
+    public static GenerateLinkResponse error(String msg) {
+        GenerateLinkResponse response = new GenerateLinkResponse();
+        response.setSuccess(false);
+        response.setCode("500");
+        response.setMsg(msg);
+        GenerateLinkData data = new GenerateLinkData();
+        data.setTrackedMaterials(Collections.<MaterialTrackItem>emptyList());
+        data.setUntrackedMaterials(Collections.<MaterialTrackItem>emptyList());
+        response.setData(data);
+        return response;
+    }
+
+    @Data
+    public static class GenerateLinkData {
+        private List<MaterialTrackItem> trackedMaterials;
+        private List<MaterialTrackItem> untrackedMaterials;
+    }
+}

+ 15 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialFileInfo.java

@@ -0,0 +1,15 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class MaterialFileInfo {
+
+    private Long id;
+    private String url;
+    private String originalName;
+    private String newName;
+    private String fileSize;
+    private String fileType;
+    private Integer storeType;
+}

+ 145 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialItem.java

@@ -0,0 +1,145 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class MaterialItem {
+
+    /**
+     * 素材ID
+     */
+    private Long id;
+
+    /**
+     * 素材标题
+     */
+    private String title;
+
+    /**
+     * 素材类型
+     */
+    private Integer materialType;
+
+    /**
+     * 启用状态
+     */
+    private Integer enableStatus;
+
+    /**
+     * 分类ID
+     */
+    private Long categoryId;
+
+    /**
+     * 分类名称
+     */
+    private String category;
+
+    /**
+     * 创建时间戳
+     */
+    private Long createTime;
+
+    /**
+     * 创建人
+     */
+    private Long createdBy;
+
+    /**
+     * 作者、分享人
+     */
+    private String author;
+
+    /**
+     * 摘要
+     */
+    private String summary;
+
+    /**
+     * 正文
+     */
+    private String content;
+
+    /**
+     * 外链
+     */
+    private String url;
+
+    /**
+     * 配文文本
+     */
+    private String sendingText;
+
+    /**
+     * 网页分类
+     */
+    private Integer type;
+
+    /**
+     * 行为跟踪开关 1跟踪 0不跟踪
+     */
+    private Integer behaviorTrackSwitch;
+
+    /**
+     * 行为通知 1通知 0不通知
+     */
+    private Integer behaviorNotify;
+
+    /**
+     * 通知范围
+     */
+    private List<String> notifyRange;
+
+    /**
+     * 是否自动打标签 1是 0否
+     */
+    private Integer autoTagFlg;
+
+    /**
+     * 封面图
+     */
+    private MaterialFileInfo fileDto;
+
+    /**
+     * 视频地址
+     */
+    private MaterialFileInfo videoFileDto;
+
+    /**
+     * 海报地址
+     */
+    private MaterialFileInfo baseFileDto;
+
+    /**
+     * 小程序封面
+     */
+    private MaterialFileInfo coverFileDto;
+
+    /**
+     * 标签列表/规则等,先用Object接,避免后续字段不一致报错
+     */
+    private Object tagList;
+    private Object tagRuleList;
+
+    /**
+     * 其他扩展字段,按需后面再补
+     */
+    private Integer templateType;
+    private Integer isEmbedIframe;
+    private Integer stayPushTime;
+    private Boolean fav;
+    private String corpId;
+    private Integer creationType;
+    private Integer disturbFlg;
+    private Long createdAt;
+    private Integer showCardStatus;
+    private Long fileId;
+
+    /**
+     * 如果你担心后续字段变化,也可以额外保留一些 map 型扩展
+     */
+    private Map<String, Object> extra;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialPageData.java

@@ -0,0 +1,20 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class MaterialPageData {
+
+    private Integer startNo;
+    private Integer pageSize;
+    private Integer pageNo;
+    private Integer pageCount;
+    private Integer dataCount;
+
+    /**
+     * 实际素材数据
+     */
+    private List<MaterialItem> currentPageDatas;
+}

+ 10 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialTrackItem.java

@@ -0,0 +1,10 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class MaterialTrackItem {
+    private Long materialId;
+    private Integer materialType;
+    private TrackLinkInfo trackLink;
+}

+ 97 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/QueryMaterialRequest.java

@@ -0,0 +1,97 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+@Data
+public class QueryMaterialRequest {
+
+    /**
+     * 关键字,模糊匹配素材标题、名称
+     */
+    private String key;
+
+    /**
+     * 启用状态:0未启用 1启用 2停用
+     */
+    private Integer enableStatus;
+
+    /**
+     * 素材类型
+     * 2:图像
+     * 3:网页
+     * 5:视频
+     * 6:文件
+     * 7:小程序
+     * 8:海报
+     */
+    private List<Integer> materialTypes;
+
+    /**
+     * 素材分类ID,多个逗号分隔
+     */
+    private String categoryIds;
+
+    /**
+     * 是否返回分类名称
+     * 1:是
+     * 0或不填:否
+     */
+    private Integer needCategory;
+
+    /**
+     * 网页类型
+     * 1:自建网页
+     * 2:一般外链
+     * 3:图文外链
+     */
+    private Integer webType;
+
+    /**
+     * 外链地址
+     */
+    private String webUrl;
+
+    /**
+     * 创建时间起 yyyy-MM-dd
+     */
+    private String createdBegin;
+
+    /**
+     * 创建时间止 yyyy-MM-dd
+     */
+    private String createdEnd;
+
+    /**
+     * 更新时间起 yyyy-MM-dd
+     */
+    private String updatedBegin;
+
+    /**
+     * 更新时间止 yyyy-MM-dd
+     */
+    private String updatedEnd;
+
+    /**
+     * 创建人ID
+     */
+    private Long userId;
+
+    /**
+     * 客户端标记,固定为0
+     */
+    @NotNull(message = "from不能为空,且固定为0")
+    private Integer from = 0;
+
+    /**
+     * 页码,默认1
+     */
+    private Integer pageNo = 1;
+
+    /**
+     * 每页条数,默认10
+     */
+    private Integer pageSize = 10;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/QueryMaterialResponse.java

@@ -0,0 +1,20 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class QueryMaterialResponse {
+
+    private String code;
+    private String msg;
+    private Boolean success;
+    private MaterialPageData data;
+
+    public static QueryMaterialResponse error(String msg) {
+        QueryMaterialResponse response = new QueryMaterialResponse();
+        response.setCode("500");
+        response.setMsg(msg);
+        response.setSuccess(false);
+        return response;
+    }
+}

+ 19 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/SendConfirmResponse.java

@@ -0,0 +1,19 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class SendConfirmResponse {
+
+    private boolean success;
+    private String code;
+    private String msg;
+
+    public static SendConfirmResponse error(String msg) {
+        SendConfirmResponse response = new SendConfirmResponse();
+        response.setSuccess(false);
+        response.setCode("500");
+        response.setMsg(msg);
+        return response;
+    }
+}

+ 39 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/TokenInfo.java

@@ -0,0 +1,39 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class TokenInfo implements Serializable {
+
+    /**
+     * access_token
+     */
+    private String accessToken;
+
+    /**
+     * token类型,通常是Bearer
+     */
+    private String tokenType;
+
+    /**
+     * access_token过期时间戳(毫秒)
+     */
+    private Long expiresAt;
+
+    /**
+     * refresh_token
+     */
+    private String refreshToken;
+
+    /**
+     * refresh_token过期时间戳(毫秒)
+     */
+    private Long refreshTokenExpiresAt;
+
+    /**
+     * 实例API地址
+     */
+    private String instanceUri;
+}

+ 30 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/TokenResponse.java

@@ -0,0 +1,30 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class TokenResponse {
+
+    private boolean success;
+    private String error;
+
+    private String accessToken;
+    private String tokenType;
+    private Long expiresIn;
+    private String refreshToken;
+    private Long refreshTokenExpiresIn;
+    private String instanceUri;
+
+    public static TokenResponse ok() {
+        TokenResponse response = new TokenResponse();
+        response.setSuccess(true);
+        return response;
+    }
+
+    public static TokenResponse fail(String error) {
+        TokenResponse response = new TokenResponse();
+        response.setSuccess(false);
+        response.setError(error);
+        return response;
+    }
+}

+ 9 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/TrackLinkInfo.java

@@ -0,0 +1,9 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class TrackLinkInfo {
+    private Long forwardId;
+    private String redirectUrl;
+}

+ 56 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/UploadMaterialFileResponse.java

@@ -0,0 +1,56 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class UploadMaterialFileResponse {
+
+    private String code;
+    private String msg;
+    private Boolean success;
+    private UploadMaterialFileData data;
+
+    public static UploadMaterialFileResponse error(String msg) {
+        UploadMaterialFileResponse response = new UploadMaterialFileResponse();
+        response.setCode("500");
+        response.setMsg(msg);
+        response.setSuccess(false);
+        return response;
+    }
+
+    @Data
+    public static class UploadMaterialFileData {
+        /**
+         * 已上传素材文件ID
+         */
+        private Long fileId;
+
+        /**
+         * 文件key
+         */
+        private String fileKey;
+
+        /**
+         * 文件url
+         */
+        private String url;
+
+        /**
+         * 视频封面
+         */
+        private CoverFileDto coverFileDto;
+    }
+
+    @Data
+    public static class CoverFileDto {
+        /**
+         * 封面文件ID
+         */
+        private Long fileId;
+
+        /**
+         * 封面url
+         */
+        private String url;
+    }
+}

+ 50 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/dto/XiaoShouYiAuthStatusVO.java

@@ -0,0 +1,50 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+/**
+ * 销售易 OAuth 授权状态
+ */
+@Data
+public class XiaoShouYiAuthStatusVO {
+
+    /**
+     * 是否已授权且 refresh_token 仍有效
+     */
+    private boolean authorized;
+
+    /**
+     * 状态:NORMAL-正常,EXPIRING_SOON-即将过期,UNAUTHORIZED-未授权,EXPIRED-已失效
+     */
+    private String status;
+
+    /**
+     * 状态说明
+     */
+    private String message;
+
+    /**
+     * access_token 过期时间(毫秒时间戳)
+     */
+    private Long accessTokenExpiresAt;
+
+    /**
+     * refresh_token 过期时间(毫秒时间戳)
+     */
+    private Long refreshTokenExpiresAt;
+
+    /**
+     * refresh_token 剩余天数
+     */
+    private Integer refreshTokenRemainDays;
+
+    /**
+     * 最近一次成功刷新时间(毫秒时间戳)
+     */
+    private Long lastRefreshAt;
+
+    /**
+     * OAuth 授权地址(未授权或已失效时返回,供前端跳转)
+     */
+    private String authUrl;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/enums/XiaoShouYiAuthStatus.java

@@ -0,0 +1,15 @@
+package com.fs.xiaoshouyi.enums;
+
+/**
+ * 销售易 OAuth 授权状态
+ */
+public interface XiaoShouYiAuthStatus {
+
+    String NORMAL = "NORMAL";
+
+    String EXPIRING_SOON = "EXPIRING_SOON";
+
+    String UNAUTHORIZED = "UNAUTHORIZED";
+
+    String EXPIRED = "EXPIRED";
+}

+ 39 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/enums/XiaoShouYiForwardType.java

@@ -0,0 +1,39 @@
+package com.fs.xiaoshouyi.enums;
+
+public interface XiaoShouYiForwardType {
+
+    /**
+     * 预发送,不纳入统计
+     */
+    int PRE_SEND = -1;
+
+    /**
+     * 单聊
+     */
+    int SINGLE_CHAT = 1;
+
+    /**
+     * 群聊/群发
+     */
+    int GROUP_CHAT = 2;
+
+    /**
+     * 转发
+     */
+    int FORWARD = 3;
+
+    /**
+     * 客户朋友圈
+     */
+    int MOMENTS = 4;
+
+    /**
+     * 公众号群发
+     */
+    int OFFICIAL_ACCOUNT = 5;
+
+    /**
+     * 名片小程序
+     */
+    int MINI_PROGRAM = 6;
+}

+ 11 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/exception/XiaoShouYiSendVideoLimitExceededException.java

@@ -0,0 +1,11 @@
+package com.fs.xiaoshouyi.exception;
+
+/**
+ * 销售易发课校验失败(小节未开启公开课)
+ */
+public class XiaoShouYiSendVideoLimitExceededException extends RuntimeException {
+
+    public XiaoShouYiSendVideoLimitExceededException(String message) {
+        super(message);
+    }
+}

+ 669 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiMaterialService.java

@@ -0,0 +1,669 @@
+package com.fs.xiaoshouyi.service;
+
+import cn.hutool.json.JSONArray;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.fs.xiaoshouyi.client.XiaoShouYiHttpClient;
+import com.fs.xiaoshouyi.config.XiaoShouYiProperties;
+import com.fs.xiaoshouyi.dto.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XiaoShouYiMaterialService {
+
+    private static final String GET_LINK_PATH = "/rest/mc/v2.0/cms/api/buried-point/generateMaterialTrackLink";
+    private static final String SEND_PATH = "/rest/mc/v2.0/cms/api/buried-point/forwarded";
+    private static final String QUERY_ALL_PATH = "/rest/mc/v2.0/cms/material-center/material/queryAll";
+    private static final String GET_LINK = "https://api.xiaoshouyi.com";
+    private static final String UPLOAD_FILE_PATH = "/rest/mc/v2.0/cms/api/action/uploadFile";
+    private static final String CREATE_MATERIAL_PATH = "/rest/mc/v2.0/cms/api/action/createMaterial";
+
+    private final XiaoShouYiOAuthService oAuthService;
+    private final XiaoShouYiHttpClient httpClient;
+    private final XiaoShouYiProperties properties;
+
+    public GenerateLinkResponse generateMaterialTrackLink(List<Long> materialIdList, Long sendUserId) {
+        String authHeader = oAuthService.getAuthorizationHeader();
+        if (authHeader == null) {
+            return GenerateLinkResponse.error("未获取到有效token,请先授权");
+        }
+//        String url = GET_LINK + GET_LINK_PATH;
+//        String url = oAuthService.getApiBaseUrl() + GET_LINK_PATH;
+        String url = properties.getDefaultApiBaseUrl() + GET_LINK_PATH;
+        Map<String, Object> body = new HashMap<String, Object>();
+        body.put("materialIdList", materialIdList);
+        body.put("sendUserId", sendUserId);
+
+        //自动补协议
+        if (!url.startsWith("http")) {
+            url = "https://" + url;
+        }
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postJson(url, authHeader, body);
+
+        if (result.getStatus() == 200) {
+            return parseGenerateResponse(result.getBody());
+        }
+
+        if (result.getStatus() == 401) {
+            log.warn("generateMaterialTrackLink遇到401,尝试刷新token后重试");
+
+            TokenResponse refreshResp = oAuthService.refreshAccessToken();
+            if (!refreshResp.isSuccess()) {
+                log.error("刷新token失败: {}", refreshResp.getError());
+                oAuthService.clearTokenCache();
+                return GenerateLinkResponse.error("token失效,请重新授权");
+            }
+
+            authHeader = oAuthService.getAuthorizationHeader();
+            if (authHeader == null) {
+                oAuthService.clearTokenCache();
+                return GenerateLinkResponse.error("token失效,请重新授权");
+            }
+
+            XiaoShouYiHttpClient.HttpResult retry = httpClient.postJson(url, authHeader, body);
+            if (retry.getStatus() == 200) {
+                return parseGenerateResponse(retry.getBody());
+            }
+
+            return GenerateLinkResponse.error(
+                    "重试失败,HTTP状态码:" + retry.getStatus() + ",响应:" + retry.getBody()
+            );
+        }
+
+        return GenerateLinkResponse.error("HTTP状态码:" + result.getStatus() + ",响应:" + result.getBody());
+    }
+
+    public SendConfirmResponse sendConfirm(Long forwardId, Integer forwardType) {
+        String authHeader = oAuthService.getAuthorizationHeader();
+        if (authHeader == null) {
+            return SendConfirmResponse.error("未获取到有效token,请先授权");
+        }
+
+        String url = String.format("%s%s?forwardId=%d&forwardType=%d",
+                oAuthService.getApiBaseUrl(), SEND_PATH, forwardId, forwardType);
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.get(url, authHeader);
+
+        if (result.getStatus() == 200) {
+            return parseSendResponse(result.getBody());
+        }
+
+        if (result.getStatus() == 401) {
+            log.warn("sendConfirm遇到401,尝试刷新token后重试");
+
+            TokenResponse refreshResp = oAuthService.refreshAccessToken();
+            if (!refreshResp.isSuccess()) {
+                log.error("刷新token失败: {}", refreshResp.getError());
+                oAuthService.clearTokenCache();
+                return SendConfirmResponse.error("token失效,请重新授权");
+            }
+
+            authHeader = oAuthService.getAuthorizationHeader();
+            if (authHeader == null) {
+                oAuthService.clearTokenCache();
+                return SendConfirmResponse.error("token失效,请重新授权");
+            }
+
+            XiaoShouYiHttpClient.HttpResult retry = httpClient.get(url, authHeader);
+            if (retry.getStatus() == 200) {
+                return parseSendResponse(retry.getBody());
+            }
+            return SendConfirmResponse.error("重试失败,HTTP状态码:" + retry.getStatus() + ",响应:" + retry.getBody());
+        }
+
+        return SendConfirmResponse.error("HTTP状态码:" + result.getStatus() + ",响应:" + result.getBody());
+    }
+
+
+    public QueryMaterialResponse queryMaterials(QueryMaterialRequest request) {
+        String authHeader = oAuthService.getAuthorizationHeader();
+        if (authHeader == null) {
+            return QueryMaterialResponse.error("未获取到有效token,请先授权");
+        }
+
+        String url = oAuthService.getApiBaseUrl() + QUERY_ALL_PATH;
+//        String url = BASE_URL + QUERY_ALL_PATH;
+        //自动补协议
+        if (!url.startsWith("http")) {
+            url = "https://" + url;
+        }
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postJson(url, authHeader, request);
+
+        if (result.getStatus() == 200) {
+            return parseQueryMaterialResponse(result.getBody());
+        }
+
+        if (result.getStatus() == 401) {
+            log.warn("queryMaterials遇到401,尝试刷新token后重试");
+
+            TokenResponse refreshResp = oAuthService.refreshAccessToken();
+            if (!refreshResp.isSuccess()) {
+                log.error("刷新token失败: {}", refreshResp.getError());
+                oAuthService.clearTokenCache();
+                return QueryMaterialResponse.error("token失效,请重新授权");
+            }
+
+            authHeader = oAuthService.getAuthorizationHeader();
+            if (authHeader == null) {
+                oAuthService.clearTokenCache();
+                return QueryMaterialResponse.error("获取刷新后的token失败,请重新授权");
+            }
+
+            XiaoShouYiHttpClient.HttpResult retry =
+                    httpClient.postJson(url, authHeader, request);
+
+            if (retry.getStatus() == 200) {
+                return parseQueryMaterialResponse(retry.getBody());
+            }
+
+            if (retry.getStatus() == 401) {
+                oAuthService.clearTokenCache();
+                return QueryMaterialResponse.error("刷新后仍未授权,请重新授权");
+            }
+
+            return QueryMaterialResponse.error(
+                    "重试失败,HTTP状态码:" + retry.getStatus() + ",响应:" + retry.getBody()
+            );
+        }
+
+        return QueryMaterialResponse.error(
+                "HTTP状态码:" + result.getStatus() + ",响应:" + result.getBody()
+        );
+    }
+
+    private GenerateLinkResponse parseGenerateResponse(String jsonStr) {
+        JSONObject json = JSONUtil.parseObj(jsonStr);
+        GenerateLinkResponse response = new GenerateLinkResponse();
+        response.setSuccess(json.getBool("success", false));
+        response.setCode(json.getStr("code"));
+        response.setMsg(json.getStr("msg"));
+
+        GenerateLinkResponse.GenerateLinkData data = new GenerateLinkResponse.GenerateLinkData();
+        if (json.containsKey("data")) {
+            JSONObject jsonData = json.getJSONObject("data");
+            data.setTrackedMaterials(parseMaterialList(jsonData.getJSONArray("trackedMaterials"), true));
+            data.setUntrackedMaterials(parseMaterialList(jsonData.getJSONArray("untrackedMaterials"), false));
+        } else {
+            data.setTrackedMaterials(Collections.<MaterialTrackItem>emptyList());
+            data.setUntrackedMaterials(Collections.<MaterialTrackItem>emptyList());
+        }
+
+        response.setData(data);
+        return response;
+    }
+
+    private List<MaterialTrackItem> parseMaterialList(JSONArray array, boolean hasTrackLink) {
+        if (array == null || array.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<MaterialTrackItem> list = new ArrayList<MaterialTrackItem>();
+        for (Object obj : array) {
+            JSONObject item = (JSONObject) obj;
+            MaterialTrackItem trackItem = new MaterialTrackItem();
+            trackItem.setMaterialId(item.getLong("materialId"));
+            trackItem.setMaterialType(item.getInt("materialType"));
+
+            if (hasTrackLink && item.containsKey("trackLink")) {
+                JSONObject link = item.getJSONObject("trackLink");
+                TrackLinkInfo info = new TrackLinkInfo();
+                info.setForwardId(link.getLong("forwardId"));
+                info.setRedirectUrl(link.getStr("redirectUrl"));
+                trackItem.setTrackLink(info);
+            }
+            list.add(trackItem);
+        }
+        return list;
+    }
+
+    private SendConfirmResponse parseSendResponse(String jsonStr) {
+        JSONObject json = JSONUtil.parseObj(jsonStr);
+        SendConfirmResponse response = new SendConfirmResponse();
+        response.setSuccess(json.getBool("success", false));
+        response.setCode(json.getStr("code"));
+        response.setMsg(json.getStr("msg"));
+        return response;
+    }
+
+    private QueryMaterialResponse parseQueryMaterialResponse(String jsonStr) {
+        try {
+            return JSONUtil.toBean(jsonStr, QueryMaterialResponse.class);
+        } catch (Exception e) {
+            log.error("解析素材列表响应失败, json={}", jsonStr, e);
+            return QueryMaterialResponse.error("解析素材列表响应失败:" + e.getMessage());
+        }
+    }
+
+
+    /**
+     * 上传素材资源
+     */
+    public UploadMaterialFileResponse uploadMaterialFile(File file, Boolean isVideo) {
+        if (file == null || !file.exists() || !file.isFile()) {
+            return UploadMaterialFileResponse.error("上传文件不存在");
+        }
+
+        String authHeader = oAuthService.getAuthorizationHeader();
+        if (authHeader == null) {
+            return UploadMaterialFileResponse.error("未获取到有效token,请先授权");
+        }
+
+        String url = properties.getDefaultApiBaseUrl() + UPLOAD_FILE_PATH;
+
+        log.info("上传素材资源请求URL: {}", url);
+        log.info("上传素材资源文件: {}, isVideo={}", file.getAbsolutePath(), isVideo);
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postMultipart(url, authHeader, file, isVideo);
+
+        if (result.getStatus() == 200) {
+            return parseUploadMaterialFileResponse(result.getBody());
+        }
+
+        if (result.getStatus() == 401) {
+            log.warn("uploadMaterialFile遇到401,尝试刷新token后重试");
+
+            TokenResponse refreshResp = oAuthService.refreshAccessToken();
+            if (!refreshResp.isSuccess()) {
+                log.error("刷新token失败: {}", refreshResp.getError());
+                oAuthService.clearTokenCache();
+                return UploadMaterialFileResponse.error("token失效,请重新授权");
+            }
+
+            authHeader = oAuthService.getAuthorizationHeader();
+            if (authHeader == null) {
+                oAuthService.clearTokenCache();
+                return UploadMaterialFileResponse.error("获取刷新后的token失败,请重新授权");
+            }
+
+            XiaoShouYiHttpClient.HttpResult retry =
+                    httpClient.postMultipart(url, authHeader, file, isVideo);
+
+            if (retry.getStatus() == 200) {
+                return parseUploadMaterialFileResponse(retry.getBody());
+            }
+
+            if (retry.getStatus() == 401) {
+                oAuthService.clearTokenCache();
+                return UploadMaterialFileResponse.error("刷新后仍未授权,请重新授权");
+            }
+
+            return UploadMaterialFileResponse.error(
+                    "重试失败,HTTP状态码:" + retry.getStatus() + ",响应:" + retry.getBody()
+            );
+        }
+
+        return UploadMaterialFileResponse.error(
+                "HTTP状态码:" + result.getStatus() + ",响应:" + result.getBody()
+        );
+    }
+
+    private UploadMaterialFileResponse parseUploadMaterialFileResponse(String jsonStr) {
+        try {
+            return JSONUtil.toBean(jsonStr, UploadMaterialFileResponse.class);
+        } catch (Exception e) {
+            log.error("解析上传素材资源响应失败, json={}", jsonStr, e);
+            return UploadMaterialFileResponse.error("解析上传素材资源响应失败:" + e.getMessage());
+        }
+    }
+
+
+    public CreateMaterialResponse createMaterial(CreateMaterialRequest request) {
+        String validateMsg = validateCreateMaterialRequest(request);
+        if (validateMsg != null) {
+            return CreateMaterialResponse.error(validateMsg);
+        }
+
+        String authHeader = oAuthService.getAuthorizationHeader();
+        if (authHeader == null) {
+            return CreateMaterialResponse.error("未获取到有效token,请先授权");
+        }
+
+        String url = properties.getDefaultApiBaseUrl() + CREATE_MATERIAL_PATH;
+
+        log.info("创建素材请求URL: {}", url);
+        log.info("创建素材请求参数: {}", JSONUtil.toJsonStr(request));
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postJson(url, authHeader, request);
+        if (result.getStatus() == 200) {
+            return parseCreateMaterialResponse(result.getBody());
+        }
+
+        if (result.getStatus() == 401) {
+            log.warn("createMaterial遇到401,尝试刷新token后重试");
+
+            TokenResponse refreshResp = oAuthService.refreshAccessToken();
+            if (!refreshResp.isSuccess()) {
+                log.error("刷新token失败: {}", refreshResp.getError());
+                oAuthService.clearTokenCache();
+                return CreateMaterialResponse.error("token失效,请重新授权");
+            }
+
+            authHeader = oAuthService.getAuthorizationHeader();
+            if (authHeader == null) {
+                oAuthService.clearTokenCache();
+                return CreateMaterialResponse.error("获取刷新后的token失败,请重新授权");
+            }
+
+            XiaoShouYiHttpClient.HttpResult retry =
+                    httpClient.postJson(url, authHeader, request);
+
+            if (retry.getStatus() == 200) {
+                return parseCreateMaterialResponse(retry.getBody());
+            }
+
+            if (retry.getStatus() == 401) {
+                oAuthService.clearTokenCache();
+                return CreateMaterialResponse.error("刷新后仍未授权,请重新授权");
+            }
+
+            return CreateMaterialResponse.error(
+                    "重试失败,HTTP状态码:" + retry.getStatus() + ",响应:" + retry.getBody()
+            );
+        }
+
+        return CreateMaterialResponse.error(
+                "HTTP状态码:" + result.getStatus() + ",响应:" + result.getBody()
+        );
+    }
+
+    private CreateMaterialResponse parseCreateMaterialResponse(String jsonStr) {
+        try {
+            return JSONUtil.toBean(jsonStr, CreateMaterialResponse.class);
+        } catch (Exception e) {
+            log.error("解析创建素材响应失败, json={}", jsonStr, e);
+            return CreateMaterialResponse.error("解析创建素材响应失败:" + e.getMessage());
+        }
+    }
+
+
+    private String validateCreateMaterialRequest(CreateMaterialRequest request) {
+        if (request == null) {
+            return "请求参数不能为空";
+        }
+        if (isBlank(request.getCorpName())) {
+            return "corpName不能为空";
+        }
+        if (request.getMaterialType() == null) {
+            return "materialType不能为空";
+        }
+        if (isBlank(request.getCategoryName())) {
+            return "categoryName不能为空";
+        }
+        if (isBlank(request.getTitle())) {
+            return "title不能为空";
+        }
+
+        Integer materialType = request.getMaterialType();
+
+        // 图片/视频/文件:必须 fileId
+        if ((materialType == 2 || materialType == 5 || materialType == 6) && request.getFileId() == null) {
+            return "当前素材类型必须传fileId";
+        }
+
+        // 视频:建议 coverFileId
+        if (materialType == 5 && request.getCoverFileId() == null) {
+            return "视频素材必须传coverFileId";
+        }
+
+        // 小程序:必须 coverFileId + appId + path
+        if (materialType == 7) {
+            if (request.getCoverFileId() == null) {
+                return "小程序素材必须传coverFileId";
+            }
+            if (isBlank(request.getAppId())) {
+                return "小程序素材必须传appId";
+            }
+            if (isBlank(request.getPath())) {
+                return "小程序素材必须传path";
+            }
+        }
+
+        // 自建网页:必须 coverFileId + content
+        if (materialType == 31) {
+            if (request.getCoverFileId() == null) {
+                return "自建网页素材必须传coverFileId";
+            }
+            if (isBlank(request.getContent())) {
+                return "自建网页素材必须传content";
+            }
+        }
+
+        // 外链/公众号图文:必须 coverFileId + url
+        if (materialType == 32 || materialType == 33) {
+            if (request.getCoverFileId() == null) {
+                return "外链类素材必须传coverFileId";
+            }
+            if (isBlank(request.getUrl())) {
+                return "外链类素材必须传url";
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 聚合:上传素材资源 + 创建素材
+     */
+    public CreateMaterialWithUploadResponse createMaterialWithUpload(File file,
+                                                                     Boolean isVideo,
+                                                                     CreateMaterialRequest request) {
+        if (file == null || !file.exists() || !file.isFile()) {
+            throw new RuntimeException("上传文件不存在");
+        }
+        if (request == null) {
+            throw new RuntimeException("创建素材参数不能为空");
+        }
+
+        // 1. 先上传素材资源
+        UploadMaterialFileResponse uploadResp = uploadMaterialFile(file, isVideo);
+        log.info("上传图片数据:{}", uploadResp);
+        if (!Boolean.TRUE.equals(uploadResp.getSuccess())) {
+            throw new RuntimeException("上传素材资源失败: " + uploadResp.getMsg());
+        }
+
+        if (uploadResp.getData() == null) {
+            throw new RuntimeException("上传素材资源成功,但返回数据为空");
+        }
+
+        // 2. 回填 fileId / coverFileId
+        request.setFileId(uploadResp.getData().getFileId());
+        request.setCoverFileId(uploadResp.getData().getFileId());
+
+        if (uploadResp.getData().getCoverFileDto() != null) {
+            request.setCoverFileId(uploadResp.getData().getCoverFileDto().getFileId());
+        }
+
+        // 3. 创建素材
+        CreateMaterialResponse createResp = createMaterial(request);
+        log.info("素材返回:{}", createResp);
+        if (!Boolean.TRUE.equals(createResp.getSuccess())) {
+            throw new RuntimeException("创建素材失败: " + createResp.getMsg());
+        }
+
+        if (createResp.getData().getEntry().getId() == null) {
+            throw new RuntimeException("创建素材成功,但返回素材ID为空");
+        }
+
+        // 4. 组装聚合返回
+        CreateMaterialWithUploadResponse response = new CreateMaterialWithUploadResponse();
+        response.setMaterialId(createResp.getData().getEntry().getId());
+        response.setFileId(uploadResp.getData().getFileId());
+        response.setFileUrl(uploadResp.getData().getUrl());
+
+        if (uploadResp.getData().getCoverFileDto() != null) {
+            response.setCoverFileId(uploadResp.getData().getCoverFileDto().getFileId());
+            response.setCoverUrl(uploadResp.getData().getCoverFileDto().getUrl());
+        }
+
+        return response;
+    }
+
+
+    /**
+     * 聚合:通过文件URL下载后,转临时文件,再上传素材资源 + 创建素材
+     */
+    public CreateMaterialWithUploadResponse createMaterialWithUploadByUrl(String fileUrl,
+                                                                          Boolean isVideo,
+                                                                          CreateMaterialRequest request) {
+        if (fileUrl == null || fileUrl.trim().isEmpty()) {
+            throw new RuntimeException("文件URL不能为空");
+        }
+        if (request == null) {
+            throw new RuntimeException("创建素材参数不能为空");
+        }
+
+        HttpURLConnection conn = null;
+        InputStream inputStream = null;
+        FileOutputStream outputStream = null;
+        File tempFile = null;
+
+        try {
+            URL url = new URL(fileUrl);
+            conn = (HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("GET");
+            conn.setConnectTimeout(10000);
+            conn.setReadTimeout(20000);
+            conn.setInstanceFollowRedirects(true);
+
+            // 防止部分CDN拦截
+            conn.setRequestProperty("User-Agent", "Mozilla/5.0");
+            conn.setRequestProperty("Accept", "*/*");
+            conn.setRequestProperty("Connection", "Keep-Alive");
+            conn.setRequestProperty("Referer", fileUrl);
+
+            int code = conn.getResponseCode();
+            if (code != HttpURLConnection.HTTP_OK) {
+                String errorMsg = readErrorMsg(conn);
+                throw new RuntimeException("下载文件失败,HTTP状态码:" + code + ",错误信息:" + errorMsg);
+            }
+
+            inputStream = conn.getInputStream();
+
+            // 根据URL和Content-Type推断后缀
+            String suffix = getSuffix(fileUrl, conn.getContentType());
+            tempFile = File.createTempFile("material_upload_", suffix);
+
+            outputStream = new FileOutputStream(tempFile);
+
+            byte[] buffer = new byte[8192];
+            int len;
+            while ((len = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, len);
+            }
+            outputStream.flush();
+
+            // 调用你原来的聚合方法
+            return createMaterialWithUpload(tempFile, isVideo, request);
+
+        } catch (Exception e) {
+            throw new RuntimeException("通过URL上传并创建素材失败: " + e.getMessage(), e);
+        } finally {
+            try {
+                if (inputStream != null) {
+                    inputStream.close();
+                }
+            } catch (Exception ignored) {
+            }
+            try {
+                if (outputStream != null) {
+                    outputStream.close();
+                }
+            } catch (Exception ignored) {
+            }
+            if (conn != null) {
+                conn.disconnect();
+            }
+            if (tempFile != null && tempFile.exists()) {
+                if (!tempFile.delete()) {
+                    tempFile.deleteOnExit();
+                }
+            }
+        }
+    }
+
+    private String getSuffix(String fileUrl, String contentType) {
+        if (fileUrl != null && !fileUrl.trim().isEmpty()) {
+            String pureUrl = fileUrl;
+            int queryIndex = pureUrl.indexOf("?");
+            if (queryIndex > -1) {
+                pureUrl = pureUrl.substring(0, queryIndex);
+            }
+
+            int dotIndex = pureUrl.lastIndexOf('.');
+            int slashIndex = pureUrl.lastIndexOf('/');
+            if (dotIndex > slashIndex) {
+                String suffix = pureUrl.substring(dotIndex);
+                if (suffix.length() <= 10) {
+                    return suffix;
+                }
+            }
+        }
+
+        if (contentType != null) {
+            String lowerContentType = contentType.toLowerCase();
+            if (lowerContentType.contains("jpeg")) return ".jpg";
+            if (lowerContentType.contains("jpg")) return ".jpg";
+            if (lowerContentType.contains("png")) return ".png";
+            if (lowerContentType.contains("gif")) return ".gif";
+            if (lowerContentType.contains("bmp")) return ".bmp";
+            if (lowerContentType.contains("webp")) return ".webp";
+            if (lowerContentType.contains("mp4")) return ".mp4";
+            if (lowerContentType.contains("quicktime")) return ".mov";
+            if (lowerContentType.contains("mpeg")) return ".mpg";
+        }
+
+        return ".tmp";
+    }
+
+    private String readErrorMsg(HttpURLConnection conn) {
+        InputStream errorStream = null;
+        ByteArrayOutputStream baos = null;
+        try {
+            errorStream = conn.getErrorStream();
+            if (errorStream == null) {
+                return "";
+            }
+
+            baos = new ByteArrayOutputStream();
+            byte[] buffer = new byte[1024];
+            int len;
+            while ((len = errorStream.read(buffer)) != -1) {
+                baos.write(buffer, 0, len);
+            }
+            return baos.toString("UTF-8");
+        } catch (Exception e) {
+            return "";
+        } finally {
+            try {
+                if (errorStream != null) {
+                    errorStream.close();
+                }
+            } catch (Exception ignored) {
+            }
+            try {
+                if (baos != null) {
+                    baos.close();
+                }
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+
+    private boolean isBlank(String str) {
+        return str == null || str.trim().isEmpty();
+    }
+
+}

+ 489 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiOAuthService.java

@@ -0,0 +1,489 @@
+package com.fs.xiaoshouyi.service;
+
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.fs.xiaoshouyi.client.XiaoShouYiHttpClient;
+import com.fs.xiaoshouyi.config.XiaoShouYiProperties;
+import com.fs.xiaoshouyi.constant.XiaoShouYiRedisKey;
+import com.fs.xiaoshouyi.dto.TokenInfo;
+import com.fs.xiaoshouyi.dto.TokenResponse;
+import com.fs.xiaoshouyi.dto.XiaoShouYiAuthStatusVO;
+import com.fs.xiaoshouyi.enums.XiaoShouYiAuthStatus;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XiaoShouYiOAuthService {
+
+    private final XiaoShouYiProperties properties;
+    private final XiaoShouYiHttpClient httpClient;
+    private final StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * 提前5分钟刷新
+     */
+    private static final long ACCESS_TOKEN_REFRESH_ADVANCE_MILLIS = 5 * 60 * 1000L;
+
+    /**
+     * 分布式锁过期时间
+     */
+    private static final long LOCK_EXPIRE_SECONDS = 20L;
+
+    /**
+     * 构建授权URL(New 版回调地址)
+     */
+    public String buildAuthUrl() {
+        return buildAuthUrl(properties.getRedirectUri());
+    }
+
+    /**
+     * 构建授权URL,指定回调地址
+     */
+    public String buildAuthUrl(String redirectUri) {
+        return properties.getAuthUrl()
+                + "?response_type=code"
+                + "&client_id=" + urlEncode(properties.getClientId())
+                + "&redirect_uri=" + urlEncode(redirectUri)
+                + "&scope=" + urlEncode(properties.getScope())
+                + "&oauthType=" + urlEncode(properties.getOauthType())
+                + "&access_type=" + urlEncode(properties.getAccessType());
+    }
+
+    /**
+     * 校验state
+     */
+    public boolean validateState(String state) {
+        if (!StringUtils.hasText(state)) {
+            return false;
+        }
+        String key = XiaoShouYiRedisKey.OAUTH_STATE_PREFIX + state;
+        String value = stringRedisTemplate.opsForValue().get(key);
+        if (value == null) {
+            return false;
+        }
+        stringRedisTemplate.delete(key);
+        return true;
+    }
+
+    /**
+     * 授权码换token(New 版回调地址),并写入 Redis
+     */
+    public TokenResponse exchangeCodeForToken(String code) {
+        return exchangeCodeForToken(code, properties.getRedirectUri());
+    }
+
+    /**
+     * 授权码换token,指定回调地址,并写入 Redis
+     */
+    public TokenResponse exchangeCodeForToken(String code, String redirectUri) {
+        log.info("开始用授权码换取token, code={}, redirectUri={}", code, redirectUri);
+
+        Map<String, Object> form = new HashMap<>();
+        form.put("grant_type", "authorization_code");
+        form.put("client_id", properties.getClientId());
+        form.put("client_secret", properties.getClientSecret());
+        form.put("redirect_uri", redirectUri);
+        form.put("code", code);
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postForm(properties.getTokenUrl(), form);
+
+        if (result.getStatus() != 200) {
+            log.error("授权码换token失败, status={}, body={}", result.getStatus(), result.getBody());
+            return TokenResponse.fail("授权码换token失败, HTTP状态码: " + result.getStatus());
+        }
+
+        return parseAndSaveToken(result.getBody());
+    }
+
+    /**
+     * 刷新token
+     */
+    public TokenResponse refreshAccessToken() {
+        TokenInfo oldToken = getTokenInfo();
+        if (oldToken == null || !StringUtils.hasText(oldToken.getRefreshToken())) {
+            return TokenResponse.fail("refresh_token不存在,请重新授权");
+        }
+
+        if (oldToken.getRefreshTokenExpiresAt() != null
+                && System.currentTimeMillis() >= oldToken.getRefreshTokenExpiresAt()) {
+            return TokenResponse.fail("refresh_token已过期,请重新授权");
+        }
+
+        Map<String, Object> form = new HashMap<>();
+        form.put("grant_type", "refresh_token");
+        form.put("client_id", properties.getClientId());
+        form.put("client_secret", properties.getClientSecret());
+        form.put("refresh_token", oldToken.getRefreshToken());
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postForm(properties.getTokenUrl(), form);
+
+        if (result.getStatus() != 200) {
+            log.error("刷新token失败, status={}, body={}", result.getStatus(), result.getBody());
+            return TokenResponse.fail("刷新token失败, HTTP状态码: " + result.getStatus());
+        }
+
+        return parseAndSaveToken(result.getBody());
+    }
+
+    /**
+     * 获取有效token信息
+     */
+    public TokenInfo getValidTokenInfo() {
+        TokenInfo tokenInfo = getTokenInfo();
+        long now = System.currentTimeMillis();
+
+        if (isAccessTokenValid(tokenInfo, now)) {
+            return tokenInfo;
+        }
+
+        String lockValue = UUID.randomUUID().toString();
+        Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
+                XiaoShouYiRedisKey.REFRESH_LOCK,
+                lockValue,
+                LOCK_EXPIRE_SECONDS,
+                TimeUnit.SECONDS
+        );
+
+        if (Boolean.TRUE.equals(locked)) {
+            try {
+                tokenInfo = getTokenInfo();
+                if (isAccessTokenValid(tokenInfo, System.currentTimeMillis())) {
+                    return tokenInfo;
+                }
+
+                TokenResponse refreshResp = refreshAccessToken();
+                if (!refreshResp.isSuccess()) {
+                    log.error("刷新token失败: {}", refreshResp.getError());
+                    return null;
+                }
+
+                return getTokenInfo();
+            } finally {
+                String currentLockValue = stringRedisTemplate.opsForValue().get(XiaoShouYiRedisKey.REFRESH_LOCK);
+                if (lockValue.equals(currentLockValue)) {
+                    stringRedisTemplate.delete(XiaoShouYiRedisKey.REFRESH_LOCK);
+                }
+            }
+        } else {
+            try {
+                Thread.sleep(500L);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            return getTokenInfo();
+        }
+    }
+
+    /**
+     * 获取 Authorization 请求头,统一从 Redis 读取 Token(xsy:oauth:token_info)
+     */
+    public String getAuthorizationHeader() {
+        TokenInfo tokenInfo = getValidTokenInfo();
+        if (tokenInfo == null || !StringUtils.hasText(tokenInfo.getAccessToken())) {
+            return null;
+        }
+
+        String tokenType = StringUtils.hasText(tokenInfo.getTokenType()) ? tokenInfo.getTokenType() : "Bearer";
+        return tokenType + " " + tokenInfo.getAccessToken();
+    }
+
+    /**
+     * 获取业务API地址
+     */
+    public String getApiBaseUrl() {
+        TokenInfo tokenInfo = getTokenInfo();
+        if (tokenInfo != null && StringUtils.hasText(tokenInfo.getInstanceUri())) {
+            return tokenInfo.getInstanceUri();
+        }
+        return properties.getDefaultApiBaseUrl();
+    }
+
+    /**
+     * 清除token缓存
+     */
+    public void clearTokenCache() {
+        stringRedisTemplate.delete(XiaoShouYiRedisKey.TOKEN_INFO);
+        stringRedisTemplate.delete(XiaoShouYiRedisKey.ACCESS_TOKEN);
+        saveTokenStatus(buildUnauthorizedStatus("凭证已清除,请重新授权"));
+    }
+
+    /**
+     * 获取 Token 状态;可选先刷新凭证。未授权或已失效时附带 OAuth 授权地址。
+     */
+    public XiaoShouYiAuthStatusVO getTokenStatus(boolean refresh) {
+        if (refresh) {
+            proactiveRefreshToken();
+        }
+        XiaoShouYiAuthStatusVO status = getAuthStatus();
+        if (!status.isAuthorized()
+                || XiaoShouYiAuthStatus.EXPIRED.equals(status.getStatus())
+                || XiaoShouYiAuthStatus.EXPIRING_SOON.equals(status.getStatus())) {
+            status.setAuthUrl(buildAuthUrl());
+        }
+        return status;
+    }
+
+    /**
+     * 查询当前授权状态(供前端展示)
+     */
+    public XiaoShouYiAuthStatusVO getAuthStatus() {
+        TokenInfo tokenInfo = getTokenInfo();
+        if (tokenInfo == null) {
+            XiaoShouYiAuthStatusVO cached = getCachedTokenStatus();
+            if (cached != null) {
+                return cached;
+            }
+            return buildUnauthorizedStatus("未授权,请点击「获取授权」完成销售易登录");
+        }
+        return buildStatusFromToken(tokenInfo);
+    }
+
+    /**
+     * 定时/手动主动刷新 Token,避免长期无业务调用导致 refresh_token 过期
+     */
+    public TokenResponse proactiveRefreshToken() {
+        String lockValue = UUID.randomUUID().toString();
+        Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
+                XiaoShouYiRedisKey.REFRESH_LOCK,
+                lockValue,
+                LOCK_EXPIRE_SECONDS,
+                TimeUnit.SECONDS
+        );
+        if (!Boolean.TRUE.equals(locked)) {
+            log.info("销售易Token保活:其它节点/线程正在刷新,跳过本次");
+            return TokenResponse.ok();
+        }
+
+        try {
+            return doProactiveRefreshToken();
+        } finally {
+            String currentLockValue = stringRedisTemplate.opsForValue().get(XiaoShouYiRedisKey.REFRESH_LOCK);
+            if (lockValue.equals(currentLockValue)) {
+                stringRedisTemplate.delete(XiaoShouYiRedisKey.REFRESH_LOCK);
+            }
+        }
+    }
+
+    private TokenResponse doProactiveRefreshToken() {
+        TokenInfo tokenInfo = getTokenInfo();
+        if (tokenInfo == null) {
+            XiaoShouYiAuthStatusVO status = buildUnauthorizedStatus("未授权,无法刷新");
+            saveTokenStatus(status);
+            log.warn("销售易Token保活跳过:Redis中无Token,请重新授权");
+            return TokenResponse.fail(status.getMessage());
+        }
+
+        long now = System.currentTimeMillis();
+        if (isRefreshTokenExpired(tokenInfo, now)) {
+            XiaoShouYiAuthStatusVO status = buildExpiredStatus(tokenInfo, "refresh_token已过期,请重新授权");
+            saveTokenStatus(status);
+            log.error("销售易Token保活失败:refresh_token已过期,请重新授权");
+            return TokenResponse.fail(status.getMessage());
+        }
+
+        if (isAccessTokenValid(tokenInfo, now) && !isRefreshTokenExpiringSoon(tokenInfo, now)) {
+            XiaoShouYiAuthStatusVO status = buildStatusFromToken(tokenInfo);
+            saveTokenStatus(status);
+            log.info("销售易Token保活:凭证仍有效,无需刷新");
+            return TokenResponse.ok();
+        }
+
+        TokenResponse refreshResp = refreshAccessToken();
+        if (!refreshResp.isSuccess()) {
+            XiaoShouYiAuthStatusVO status = buildExpiredStatus(tokenInfo, refreshResp.getError());
+            saveTokenStatus(status);
+            log.error("销售易Token保活刷新失败:{}", refreshResp.getError());
+            return refreshResp;
+        }
+
+        TokenInfo refreshed = getTokenInfo();
+        XiaoShouYiAuthStatusVO status = buildStatusFromToken(refreshed);
+        status.setLastRefreshAt(now);
+        saveTokenStatus(status);
+        log.info("销售易Token保活刷新成功");
+        return refreshResp;
+    }
+
+    /**
+     * 获取redis中的token
+     */
+    public TokenInfo getTokenInfo() {
+        String json = stringRedisTemplate.opsForValue().get(XiaoShouYiRedisKey.TOKEN_INFO);
+        if (!StringUtils.hasText(json)) {
+            return null;
+        }
+        return JSONUtil.toBean(json, TokenInfo.class);
+    }
+
+    private boolean isAccessTokenValid(TokenInfo tokenInfo, long now) {
+        return tokenInfo != null
+                && StringUtils.hasText(tokenInfo.getAccessToken())
+                && tokenInfo.getExpiresAt() != null
+                && now < tokenInfo.getExpiresAt() - ACCESS_TOKEN_REFRESH_ADVANCE_MILLIS;
+    }
+
+    private TokenResponse parseAndSaveToken(String responseJson) {
+        JSONObject json = JSONUtil.parseObj(responseJson);
+
+        if (!json.containsKey("access_token")) {
+            String error = json.getStr("error_description", json.getStr("error", "未知错误"));
+            log.error("解析token失败, response={}", responseJson);
+            return TokenResponse.fail(error);
+        }
+
+        long now = System.currentTimeMillis();
+        Long expiresIn = json.getLong("expires_in", 7200L);
+        Long refreshTokenExpiresIn = json.getLong("refresh_token_expires_in", 30L * 24 * 3600L);
+
+        TokenInfo tokenInfo = new TokenInfo();
+        tokenInfo.setAccessToken(json.getStr("access_token"));
+        tokenInfo.setTokenType(json.getStr("token_type", "Bearer"));
+        tokenInfo.setExpiresAt(now + expiresIn * 1000L);
+        tokenInfo.setRefreshToken(json.getStr("refresh_token"));
+        tokenInfo.setRefreshTokenExpiresAt(now + refreshTokenExpiresIn * 1000L);
+        tokenInfo.setInstanceUri(json.getStr("instance_uri", properties.getDefaultApiBaseUrl()));
+
+        stringRedisTemplate.opsForValue().set(
+                XiaoShouYiRedisKey.TOKEN_INFO,
+                JSONUtil.toJsonStr(tokenInfo),
+                refreshTokenExpiresIn,
+                TimeUnit.SECONDS
+        );
+
+        stringRedisTemplate.opsForValue().set(
+                XiaoShouYiRedisKey.ACCESS_TOKEN,
+                tokenInfo.getAccessToken(),
+                expiresIn,
+                TimeUnit.SECONDS
+        );
+
+        TokenResponse response = TokenResponse.ok();
+        response.setAccessToken(tokenInfo.getAccessToken());
+        response.setTokenType(tokenInfo.getTokenType());
+        response.setExpiresIn(expiresIn);
+        response.setRefreshToken(tokenInfo.getRefreshToken());
+        response.setRefreshTokenExpiresIn(refreshTokenExpiresIn);
+        response.setInstanceUri(tokenInfo.getInstanceUri());
+
+        log.info("销售易token已保存到redis, instanceUri={}", tokenInfo.getInstanceUri());
+
+        XiaoShouYiAuthStatusVO status = buildStatusFromToken(tokenInfo);
+        status.setLastRefreshAt(now);
+        saveTokenStatus(status);
+
+        return response;
+    }
+
+    private XiaoShouYiAuthStatusVO buildStatusFromToken(TokenInfo tokenInfo) {
+        long now = System.currentTimeMillis();
+        XiaoShouYiAuthStatusVO status = new XiaoShouYiAuthStatusVO();
+        status.setAccessTokenExpiresAt(tokenInfo.getExpiresAt());
+        status.setRefreshTokenExpiresAt(tokenInfo.getRefreshTokenExpiresAt());
+        status.setRefreshTokenRemainDays(calcRefreshRemainDays(tokenInfo.getRefreshTokenExpiresAt(), now));
+
+        if (isRefreshTokenExpired(tokenInfo, now)) {
+            status.setAuthorized(false);
+            status.setStatus(XiaoShouYiAuthStatus.EXPIRED);
+            status.setMessage("授权已失效,请重新点击「获取授权」");
+            return status;
+        }
+
+        if (isRefreshTokenExpiringSoon(tokenInfo, now)) {
+            status.setAuthorized(true);
+            status.setStatus(XiaoShouYiAuthStatus.EXPIRING_SOON);
+            status.setMessage(String.format("授权即将过期(剩余约%d天),请尽快重新授权", status.getRefreshTokenRemainDays()));
+            return status;
+        }
+
+        status.setAuthorized(true);
+        status.setStatus(XiaoShouYiAuthStatus.NORMAL);
+        status.setMessage("授权正常,总后台定时任务会自动保活刷新凭证");
+        return status;
+    }
+
+    private XiaoShouYiAuthStatusVO buildUnauthorizedStatus(String message) {
+        XiaoShouYiAuthStatusVO status = new XiaoShouYiAuthStatusVO();
+        status.setAuthorized(false);
+        status.setStatus(XiaoShouYiAuthStatus.UNAUTHORIZED);
+        status.setMessage(message);
+        return status;
+    }
+
+    private XiaoShouYiAuthStatusVO buildExpiredStatus(TokenInfo tokenInfo, String message) {
+        XiaoShouYiAuthStatusVO status = new XiaoShouYiAuthStatusVO();
+        status.setAuthorized(false);
+        status.setStatus(XiaoShouYiAuthStatus.EXPIRED);
+        status.setMessage(message);
+        if (tokenInfo != null) {
+            status.setAccessTokenExpiresAt(tokenInfo.getExpiresAt());
+            status.setRefreshTokenExpiresAt(tokenInfo.getRefreshTokenExpiresAt());
+            status.setRefreshTokenRemainDays(calcRefreshRemainDays(tokenInfo.getRefreshTokenExpiresAt(), System.currentTimeMillis()));
+        }
+        return status;
+    }
+
+    private void saveTokenStatus(XiaoShouYiAuthStatusVO status) {
+        if (status == null) {
+            return;
+        }
+        stringRedisTemplate.opsForValue().set(
+                XiaoShouYiRedisKey.TOKEN_STATUS,
+                JSONUtil.toJsonStr(status),
+                35,
+                TimeUnit.DAYS
+        );
+    }
+
+    private XiaoShouYiAuthStatusVO getCachedTokenStatus() {
+        String json = stringRedisTemplate.opsForValue().get(XiaoShouYiRedisKey.TOKEN_STATUS);
+        if (!StringUtils.hasText(json)) {
+            return null;
+        }
+        return JSONUtil.toBean(json, XiaoShouYiAuthStatusVO.class);
+    }
+
+    private boolean isRefreshTokenExpired(TokenInfo tokenInfo, long now) {
+        return tokenInfo.getRefreshTokenExpiresAt() != null && now >= tokenInfo.getRefreshTokenExpiresAt();
+    }
+
+    private boolean isRefreshTokenExpiringSoon(TokenInfo tokenInfo, long now) {
+        if (tokenInfo.getRefreshTokenExpiresAt() == null) {
+            return false;
+        }
+        int warnDays = properties.getTokenExpireWarnDays() != null ? properties.getTokenExpireWarnDays() : 7;
+        long warnMillis = warnDays * 24L * 3600L * 1000L;
+        return tokenInfo.getRefreshTokenExpiresAt() - now <= warnMillis;
+    }
+
+    private Integer calcRefreshRemainDays(Long refreshTokenExpiresAt, long now) {
+        if (refreshTokenExpiresAt == null) {
+            return null;
+        }
+        long diff = refreshTokenExpiresAt - now;
+        if (diff <= 0) {
+            return 0;
+        }
+        return (int) ((diff + 86400000L - 1) / 86400000L);
+    }
+
+    private String urlEncode(String value) {
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            throw new RuntimeException("URL编码失败", e);
+        }
+    }
+}

+ 275 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiTrackLinkCacheService.java

@@ -0,0 +1,275 @@
+package com.fs.xiaoshouyi.service;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.FsCourseRealLink;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.sop.domain.QwSopLogs;
+import com.fs.xiaoshouyi.dto.CreateMaterialRequest;
+import com.fs.xiaoshouyi.dto.CreateMaterialWithUploadResponse;
+import com.fs.xiaoshouyi.dto.GenerateLinkResponse;
+import com.fs.xiaoshouyi.exception.XiaoShouYiSendVideoLimitExceededException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 销售易素材追踪链接:相同销售+课程维度只请求一次,基础链接永久缓存,差异化参数拼接到 redirectUrl。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XiaoShouYiTrackLinkCacheService {
+
+    private static final String XSY_MATERIAL_TRACK_CACHE_PREFIX = "xsy:material:track:";
+    private static final String XSY_MATERIAL_TRACK_LOCK_PREFIX = "xsy:material:track:lock:";
+    private static final int XSY_MATERIAL_TRACK_LOCK_SECONDS = 30;
+    private static final long XSY_MATERIAL_SEND_USER_ID = 4387900849066774L;
+
+    private final RedisCache redisCache;
+    private final XiaoShouYiMaterialService xiaoShouYiMaterialService;
+    private final IFsUserCourseVideoService fsUserCourseVideoService;
+
+    /**
+     * 相同 corpId+companyUserId+qwUserId+courseId+videoId 只调用一次销售易,后续走 Redis 缓存并拼接 _X_PARAM_*。
+     */
+    public String resolveAndCacheTrackRedirectUrl(String corpId, String companyUserId, String qwUserId,
+                                                  Long courseId, Long videoId, String externalId,
+                                                  String rawLink, String imgUrl, String linkTitle,
+                                                  String smsTemplateTitle, String smsTemplateContent, String linkNo) {
+        assertVideoOpenClassForXsySend(courseId, videoId);
+
+        String cacheKey = buildCacheKey(corpId, companyUserId, qwUserId, courseId, videoId);
+        String extraParams = buildExtraParamsQuery(rawLink, externalId, linkNo);
+
+        String cachedBaseUrl = redisCache.getCacheObject(cacheKey);
+        if (StringUtils.isNotEmpty(cachedBaseUrl)) {
+            log.info("销售易追踪链接缓存命中, cacheKey={}", cacheKey);
+            return appendQueryToUrl(cachedBaseUrl, extraParams);
+        }
+
+        String lockKey = XSY_MATERIAL_TRACK_LOCK_PREFIX + cacheKey;
+        boolean locked = redisCache.setIfAbsent(lockKey, 1, XSY_MATERIAL_TRACK_LOCK_SECONDS, TimeUnit.SECONDS);
+        if (!locked) {
+            log.info("销售易追踪链接未获取到锁,等待其它节点/线程写入缓存, lockKey={}", lockKey);
+            return waitCacheFromRedis(cacheKey, extraParams, XSY_MATERIAL_TRACK_LOCK_SECONDS);
+        }
+
+        try {
+            cachedBaseUrl = redisCache.getCacheObject(cacheKey);
+            if (StringUtils.isNotEmpty(cachedBaseUrl)) {
+                log.info("销售易追踪链接缓存命中(加锁后二次检查), cacheKey={}", cacheKey);
+                return appendQueryToUrl(cachedBaseUrl, extraParams);
+            }
+
+            CreateMaterialRequest createMaterialRequest = new CreateMaterialRequest();
+            createMaterialRequest.setCorpName("销售易营销云");
+            createMaterialRequest.setMaterialType(32);
+            createMaterialRequest.setCategoryName("看课");
+            createMaterialRequest.setTitle(StringUtils.isEmpty(linkTitle) ? "看课素材" : linkTitle);
+            createMaterialRequest.setEnableStatus(1);
+            CreateMaterialRequest.MaterialBehaviorTrack materialBehaviorTrack = new CreateMaterialRequest.MaterialBehaviorTrack();
+            CreateMaterialRequest.MaterialBehaviorNotify materialBehaviorNotify = new CreateMaterialRequest.MaterialBehaviorNotify();
+            materialBehaviorTrack.setDisturbFlg(true);
+            materialBehaviorNotify.setNotifyRange(Collections.singletonList("1"));
+            materialBehaviorTrack.setMaterialBehaviorNotify(materialBehaviorNotify);
+            createMaterialRequest.setMaterialBehaviorTrack(materialBehaviorTrack);
+            createMaterialRequest.setSendingText(smsTemplateTitle);
+            createMaterialRequest.setEnableCardTemplateStatus(0);
+            createMaterialRequest.setSummary(smsTemplateContent);
+            createMaterialRequest.setUrl(rawLink);
+
+            CreateMaterialWithUploadResponse materialWithUpload =
+                    xiaoShouYiMaterialService.createMaterialWithUploadByUrl(imgUrl, false, createMaterialRequest);
+            if (materialWithUpload == null || materialWithUpload.getMaterialId() == null) {
+                throw new RuntimeException("创建销售易素材失败,返回materialId为空");
+            }
+
+            ArrayList<Long> materialIdList = new ArrayList<>();
+            materialIdList.add(materialWithUpload.getMaterialId());
+            GenerateLinkResponse response =
+                    xiaoShouYiMaterialService.generateMaterialTrackLink(materialIdList, XSY_MATERIAL_SEND_USER_ID);
+            log.info("追踪链接参数:{}", response);
+            if (response == null) {
+                throw new RuntimeException("generateMaterialTrackLink返回为空");
+            }
+            if (!response.isSuccess()) {
+                throw new RuntimeException("生成追踪链接失败,msg=" + response.getMsg());
+            }
+            if (response.getData() == null
+                    || response.getData().getTrackedMaterials() == null
+                    || response.getData().getTrackedMaterials().isEmpty()
+                    || response.getData().getTrackedMaterials().get(0).getTrackLink() == null
+                    || StringUtils.isEmpty(response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl())) {
+                throw new RuntimeException("生成追踪链接成功,但返回redirectUrl为空");
+            }
+
+            String xsyBaseRedirectUrl = response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl();
+            redisCache.setCacheObject(cacheKey, xsyBaseRedirectUrl);
+            log.info("销售易追踪链接已写入永久缓存, cacheKey={}, materialId={}, baseRedirectUrl={}",
+                    cacheKey, materialWithUpload.getMaterialId(), xsyBaseRedirectUrl);
+            return appendQueryToUrl(xsyBaseRedirectUrl, extraParams);
+        } finally {
+            try {
+                redisCache.deleteObject(lockKey);
+            } catch (Exception ex) {
+                log.warn("释放销售易追踪链接锁失败, lockKey={}", lockKey, ex);
+            }
+        }
+    }
+
+    /**
+     * 手动一键群发 / qw-task 定时生成共用:尝试将原始短链替换为销售易追踪链接。
+     * 失败时不抛异常,按手动群发逻辑更新 sopLogs 状态并返回 null。
+     */
+    public String applySopTrackRedirectUrl(QwSopLogs sopLogs, String corpId, String companyUserId, String qwUserId,
+                                           Long courseId, Long videoId, String externalId, String rawLink, String imgUrl,
+                                           String linkTitle, String smsTemplateTitle, String smsTemplateContent,
+                                           String linkNo) {
+        try {
+            if (StringUtils.isEmpty(rawLink)) {
+                log.warn("销售易素材处理跳过,原因:生成原始链接为空。sopId={}, userId={}, externalId={}",
+                        sopLogs != null ? sopLogs.getSopId() : null,
+                        sopLogs != null ? sopLogs.getFsUserId() : null,
+                        externalId);
+                return null;
+            }
+            if (StringUtils.isEmpty(imgUrl)) {
+                log.warn("销售易素材处理跳过,原因:imgUrl为空。sopId={}, userId={}, externalId={}, link={}",
+                        sopLogs != null ? sopLogs.getSopId() : null,
+                        sopLogs != null ? sopLogs.getFsUserId() : null,
+                        externalId, rawLink);
+                return null;
+            }
+            String xsyRedirectUrl = resolveAndCacheTrackRedirectUrl(
+                    corpId, companyUserId, qwUserId, courseId, videoId, externalId,
+                    rawLink, imgUrl, linkTitle, smsTemplateTitle, smsTemplateContent, linkNo);
+            log.info("销售易追踪链接处理成功。sopId={}, userId={}, externalId={}, redirectUrl={}",
+                    sopLogs != null ? sopLogs.getSopId() : null,
+                    sopLogs != null ? sopLogs.getFsUserId() : null,
+                    externalId, xsyRedirectUrl);
+            return xsyRedirectUrl;
+        } catch (XiaoShouYiSendVideoLimitExceededException e) {
+            log.warn("销售易素材处理跳过,小节未开启公开课。sopId={}, userId={}, externalId={}, msg={}",
+                    sopLogs != null ? sopLogs.getSopId() : null,
+                    sopLogs != null ? sopLogs.getFsUserId() : null,
+                    externalId, e.getMessage());
+            if (sopLogs != null) {
+                sopLogs.setRemark("该小节未开启公开课,不允许销售易发课");
+                sopLogs.setSendStatus(5L);
+            }
+            return null;
+        } catch (Exception e) {
+            log.error("销售易素材处理失败,不影响主流程。sopId={}, userId={}, externalId={}, imgUrl={}, rawLink={}",
+                    sopLogs != null ? sopLogs.getSopId() : null,
+                    sopLogs != null ? sopLogs.getFsUserId() : null,
+                    externalId, imgUrl, rawLink, e);
+            if (sopLogs != null) {
+                sopLogs.setRemark("销售易生成失败:" + e.getMessage());
+                sopLogs.setSendStatus(5L);
+            }
+            return null;
+        }
+    }
+
+    private String waitCacheFromRedis(String cacheKey, String extraParams, int waitSeconds) {
+        long deadline = System.currentTimeMillis() + waitSeconds * 1000L;
+        while (System.currentTimeMillis() < deadline) {
+            String cached = redisCache.getCacheObject(cacheKey);
+            if (StringUtils.isNotEmpty(cached)) {
+                log.info("销售易追踪链接等待后缓存命中, cacheKey={}", cacheKey);
+                return appendQueryToUrl(cached, extraParams);
+            }
+            try {
+                Thread.sleep(200);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException("等待销售易追踪链接缓存被中断, cacheKey=" + cacheKey, e);
+            }
+        }
+        throw new RuntimeException("等待销售易追踪链接缓存超时, cacheKey=" + cacheKey);
+    }
+
+    private String buildCacheKey(String corpId, String companyUserId, String qwUserId,
+                                   Long courseId, Long videoId) {
+        return XSY_MATERIAL_TRACK_CACHE_PREFIX
+                + corpId + ":" + companyUserId + ":" + qwUserId + ":" + courseId + ":" + videoId;
+    }
+
+    private String buildExtraParamsQuery(String rawLink, String externalId, String linkNo) {
+        String param1 = StringUtils.isNotEmpty(externalId) ? externalId : "";
+        if (StringUtils.isNotEmpty(rawLink)) {
+            int courseIdx = rawLink.indexOf("?course=");
+            if (courseIdx >= 0) {
+                String courseJson = rawLink.substring(courseIdx + 8);
+                try {
+                    FsCourseRealLink courseMap = JSON.parseObject(courseJson, FsCourseRealLink.class);
+                    if (courseMap != null && courseMap.getQwExternalId() != null) {
+                        param1 = String.valueOf(courseMap.getQwExternalId());
+                    }
+                } catch (Exception e) {
+                    log.warn("解析看课链接course参数失败, rawLink={}", rawLink, e);
+                }
+            }
+        }
+        return "_X_PARAM_1=" + urlEncode(param1) + "&_X_PARAM_2=" + urlEncode(linkNo);
+    }
+
+    private String appendQueryToUrl(String baseUrl, String queryString) {
+        if (StringUtils.isEmpty(baseUrl) || StringUtils.isEmpty(queryString)) {
+            return baseUrl;
+        }
+        if (baseUrl.contains("?")) {
+            return baseUrl + "&" + queryString;
+        }
+        return baseUrl + "?" + queryString;
+    }
+
+    /**
+     * 校验当前小节是否为公开课(openClass=1),否则不允许销售易发课。
+     */
+    private void assertVideoOpenClassForXsySend(Long courseId, Long videoId) {
+        if (videoId == null) {
+            throw new XiaoShouYiSendVideoLimitExceededException("视频ID为空,不允许销售易发课");
+        }
+
+        FsUserCourseVideo video = fsUserCourseVideoService.selectFsUserCourseVideoByVideoId(videoId);
+        if (video == null) {
+            throw new XiaoShouYiSendVideoLimitExceededException(
+                    String.format("未找到课程小节,不允许销售易发课,courseId=%d, videoId=%d", courseId, videoId));
+        }
+
+        if (courseId != null && video.getCourseId() != null && !courseId.equals(video.getCourseId())) {
+            throw new XiaoShouYiSendVideoLimitExceededException(
+                    String.format("课程小节与课程不匹配,不允许销售易发课,courseId=%d, videoId=%d", courseId, videoId));
+        }
+
+        if (!Integer.valueOf(1).equals(video.getOpenClass())) {
+            throw new XiaoShouYiSendVideoLimitExceededException(
+                    String.format("该小节未开启公开课(openClass=%s),不允许销售易发课,courseId=%d, videoId=%d",
+                            video.getOpenClass(), courseId, videoId));
+        }
+
+        log.info("销售易公开课校验通过,courseId={}, videoId={}, openClass={}", courseId, videoId, video.getOpenClass());
+    }
+
+    private String urlEncode(String value) {
+        if (value == null) {
+            return "";
+        }
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            return value;
+        }
+    }
+}

+ 14 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/vo/LinkParamVo.java

@@ -0,0 +1,14 @@
+package com.fs.xiaoshouyi.vo;
+
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class LinkParamVo {
+
+    private List<Long> materialIdList;
+    private Long sendUserId;
+
+}

+ 1 - 1
fs-service/src/main/resources/application-druid-syysy.yml

@@ -230,7 +230,7 @@ xiaoshouyi:
     default-api-base-url: https://api-scrm.xiaoshouyi.com
     client-id: 07f30bc3ef46dfb657c0536e3cc23898
     client-secret: 969430240f3c80528bae607e9ee80047
-    redirect-uri: https://company.ysya.top/xiaoShouYi/New/auth/callback
+    redirect-uri: http://company.ysya.top/prod-api/xiaoShouYi/New/auth/callback
     scope: all
     oauth-type: standard
     access-type: offline

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

@@ -48,6 +48,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="createOpen"    column="create_open"    />
         <result property="cidServerId"    column="cid_server_id"    />
         <result property="aiSipCallUserId"    column="ai_sip_call_user_id"    />
+        <result property="mainAppId" column="main_app_id" />
+        <result property="backupAppId" column="backup_app_id" />
         <association property="dept"    column="dept_id" javaType="CompanyDept" resultMap="deptResult" />
         <collection  property="roles"   javaType="java.util.List"        resultMap="RoleResult" />
     </resultMap>
@@ -146,6 +148,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="addressId != null">address_id,</if>
             <if test="domain != null">domain,</if>
             <if test="isAudit != null">`is_audit`,</if>
+            <if test="mainAppId != null">main_app_id,</if>
+            <if test="backupAppId != null">backup_app_id,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="companyId != null">#{companyId},</if>
@@ -179,6 +183,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="addressId != null">#{addressId},</if>
             <if test="domain != null">#{domain},</if>
             <if test="isAudit != null">#{isAudit},</if>
+            <if test="mainAppId != null">#{mainAppId},</if>
+            <if test="backupAppId != null">#{backupAppId},</if>
         </trim>
     </insert>
     <insert id="insertQwIpadTotal">
@@ -226,6 +232,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="doctorId != null">`doctor_id` = #{doctorId},</if>
             <if test="createOpen != null">`create_open` = #{createOpen},</if>
             <if test="cidServerId != null">`cid_server_id` = #{cidServerId},</if>
+            <if test="mainAppId != null">`main_app_id` = #{mainAppId},</if>
+            <if test="backupAppId != null">`backup_app_id` = #{backupAppId},</if>
         </trim>
         where user_id = #{userId}
     </update>
@@ -316,7 +324,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select u.user_id,u.company_id,u.qw_user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time,u.id_card, u.remark,u.user_type,u.open_id,u.qr_code_weixin,u.qr_code_wecom,u.jpush_id,u.domain,u.is_audit,u.address_id,
                d.dept_id, d.parent_id, d.dept_name, d.order_num, d.leader, d.status as dept_status,
                r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status,
-               u.is_need_register_member, u.is_allowed_all_register,u.doctor_id,u.is_allowed_everyone_view,u.create_open,u.cid_server_id,u.ai_sip_call_user_id
+               u.is_need_register_member, u.is_allowed_all_register,u.doctor_id,u.is_allowed_everyone_view,u.create_open,u.cid_server_id,u.ai_sip_call_user_id,u.main_app_id,u.backup_app_id
         from company_user u
                  left join company_dept d on u.dept_id = d.dept_id
                  left join company_user_role ur on u.user_id = ur.user_id

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

@@ -1092,5 +1092,43 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
           AND l.create_time &lt; CURDATE() + INTERVAL 1 DAY
         order by l.create_time desc
     </select>
+    <!-- 小程序替换:看课表扫描条件 -->
+    <sql id="miniAppReplaceWatchLogWhere">
+        WHERE update_time >= #{startTime}
+        AND log_type > 2
+        AND (watch_type = 2 OR watch_type IS NULL)
+    </sql>
+    <!-- 小程序替换:一次查询 min/max/count -->
+    <select id="selectWatchLogScanBounds" resultType="com.fs.system.domain.vo.WatchLogScanBounds">
+        SELECT MIN(log_id) AS minId, MAX(log_id) AS maxId, COUNT(*) AS rowCount
+        FROM fs_course_watch_log
+        <include refid="miniAppReplaceWatchLogWhere"/>
+    </select>
+
+    <!-- 按行号步长取 log_id 分界点(MySQL 8+ ROW_NUMBER,仅多分区时使用) -->
+    <select id="selectLogIdsAtRowStep" resultType="java.lang.Long">
+        SELECT log_id
+        FROM (
+        SELECT log_id, ROW_NUMBER() OVER (ORDER BY log_id) AS rn
+        FROM fs_course_watch_log
+        <include refid="miniAppReplaceWatchLogWhere"/>
+        ) t
+        WHERE MOD(rn, #{stepSize}) = 0
+        AND rn &lt; #{totalCount}
+        ORDER BY log_id
+    </select>
+
+    <!-- 游标查询:log_id 上界隔离并行分区 -->
+    <select id="selectWatchLogsByCursorUpTo" resultMap="FsCourseWatchLogResult">
+        SELECT log_id, qw_user_id, sop_id, user_id, qw_external_contact_id
+        FROM fs_course_watch_log
+        WHERE update_time >= #{startOfDay}
+          AND log_type > 2
+          AND (watch_type = 2 OR watch_type IS NULL)
+          AND log_id > #{lastLogId}
+          AND log_id &lt;= #{maxId}
+        ORDER BY log_id
+            LIMIT #{batchSize}
+    </select>
 
 </mapper>

+ 4 - 0
fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml

@@ -37,6 +37,7 @@
         <result property="lastJoinTime"    column="last_join_time"    />
         <result property="projectId"    column="project_id"    />
         <result property="isFirst"    column="is_first"    />
+        <result property="openClass" column="open_class" />
     </resultMap>
 
     <sql id="selectFsUserCourseVideoVo">
@@ -107,6 +108,7 @@
             <if test="listingEndTime != null">listing_end_time,</if>
             <if test="userId != null">user_id,</if>
             <if test="isFirst != null">is_first,</if>
+            <if test="openClass != null">open_class,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="fileId != null">#{fileId},</if>
@@ -145,6 +147,7 @@
             <if test="projectId != null">#{projectId},</if>
             <if test="userId != null">#{userId},</if>
             <if test="isFirst != null">#{isFirst},</if>
+            <if test="openClass != null">#{openClass},</if>
         </trim>
     </insert>
     <insert id="insertBatchFsUserCourseVideo" parameterType="FsUserCourseVideo" useGeneratedKeys="true" keyProperty="videoId">
@@ -232,6 +235,7 @@
             <if test="listingEndTime != null">listing_end_time = #{listingEndTime},</if>
             <if test="projectId != null">project_id = #{projectId},</if>
             <if test="isFirst != null">is_first = #{isFirst},</if>
+            <if test="openClass != null">open_class = #{openClass},</if>
         </trim>
         where video_id = #{videoId}
     </update>

+ 62 - 0
fs-service/src/main/resources/mapper/sop/QwSopLogsMapper.xml

@@ -939,4 +939,66 @@
         </foreach>
     </select>
 
+    <select id="selectByKeysForAppId" resultType="com.fs.sop.domain.QwSopLogs">
+        SELECT id,content_json,send_mini_app_id
+        FROM qw_sop_logs
+        WHERE send_status IN (1, 3)
+        AND send_time >= #{startTime}
+        AND send_mini_app_id = #{oldAppId}
+        AND (qw_user_key, sop_id, fs_user_id, external_id) IN
+        <foreach collection="list" item="key" open="(" separator="," close=")">
+            (#{key.qwUserId}, #{key.sopId}, #{key.userId}, #{key.externalId})
+        </foreach>
+    </select>
+
+    <update id="batchUpdateQwSopLogsByIdInMiniAppId" parameterType="java.util.List">
+        UPDATE qw_sop_logs
+        SET
+        send_mini_app_id = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.sendMiniAppId}
+        </foreach>
+        END,
+        content_json = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.contentJson}
+        </foreach>
+        END,
+        send_status = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.sendStatus}
+        </foreach>
+        END,
+        real_send_time = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.realSendTime}
+        </foreach>
+        END,
+        remark = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.remark}
+        </foreach>
+        END,
+        memo = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.memo}
+        </foreach>
+        END,
+        app_send_status = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.appSendStatus}
+        </foreach>
+        END,
+        app_send_remark = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.appSendRemark}
+        </foreach>
+        END,
+        receiving_status = 1
+        WHERE id IN
+        <foreach collection="list" item="item" open="(" close=")" separator=",">
+            #{item.id}
+        </foreach>
+    </update>
+
 </mapper>