Parcourir la source

销售易相关代码

zyy il y a 1 jour
Parent
commit
f1e795c7cf
56 fichiers modifiés avec 4317 ajouts et 2 suppressions
  1. 259 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  2. 113 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java
  3. 36 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyCompanyBindController.java
  4. 96 0
      fs-company/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java
  5. 259 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  6. 131 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java
  7. 61 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyBindController.java
  8. 2 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  9. 6 0
      fs-service/src/main/java/com/fs/company/vo/CompanyUserQwListVO.java
  10. 2 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVO.java
  11. 5 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  12. 2 0
      fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java
  13. 175 0
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  14. 225 0
      fs-service/src/main/java/com/fs/xiaoshouyi/XiaoShouYiApiUtils.java
  15. 286 0
      fs-service/src/main/java/com/fs/xiaoshouyi/XiaoShouYiOAuthUtils.java
  16. 92 0
      fs-service/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java
  17. 42 0
      fs-service/src/main/java/com/fs/xiaoshouyi/config/XiaoShouYiProperties.java
  18. 34 0
      fs-service/src/main/java/com/fs/xiaoshouyi/constant/XiaoShouYiRedisKey.java
  19. 73 0
      fs-service/src/main/java/com/fs/xiaoshouyi/domain/XsyAccount.java
  20. 26 0
      fs-service/src/main/java/com/fs/xiaoshouyi/domain/XsyUserBind.java
  21. 9 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/BindCompanyXsyAccountRequest.java
  22. 9 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/BindXsyAccountRequest.java
  23. 131 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialRequest.java
  24. 110 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialResponse.java
  25. 32 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/CreateMaterialWithUploadResponse.java
  26. 22 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/FullProcessResult.java
  27. 17 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/GenerateLinkRequest.java
  28. 33 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/GenerateLinkResponse.java
  29. 15 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialFileInfo.java
  30. 145 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialItem.java
  31. 20 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialPageData.java
  32. 10 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/MaterialTrackItem.java
  33. 97 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/QueryMaterialRequest.java
  34. 20 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/QueryMaterialResponse.java
  35. 19 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/SendConfirmResponse.java
  36. 39 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/TokenInfo.java
  37. 30 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/TokenResponse.java
  38. 9 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/TrackLinkInfo.java
  39. 56 0
      fs-service/src/main/java/com/fs/xiaoshouyi/dto/UploadMaterialFileResponse.java
  40. 39 0
      fs-service/src/main/java/com/fs/xiaoshouyi/enums/XiaoShouYiForwardType.java
  41. 40 0
      fs-service/src/main/java/com/fs/xiaoshouyi/mapper/XsyAccountMapper.java
  42. 18 0
      fs-service/src/main/java/com/fs/xiaoshouyi/mapper/XsyCompanyBindMapper.java
  43. 37 0
      fs-service/src/main/java/com/fs/xiaoshouyi/mapper/XsyUserBindMapper.java
  44. 610 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiMaterialService.java
  45. 350 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XiaoShouYiOAuthService.java
  46. 39 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XsyAccountResolver.java
  47. 43 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XsyAccountService.java
  48. 14 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XsyCompanyBindService.java
  49. 60 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/XsyUserBindService.java
  50. 52 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/impl/XsyAccountServiceImpl.java
  51. 43 0
      fs-service/src/main/java/com/fs/xiaoshouyi/service/impl/XsyCompanyBindServiceImpl.java
  52. 14 0
      fs-service/src/main/java/com/fs/xiaoshouyi/vo/LinkParamVo.java
  53. 3 1
      fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml
  54. 146 0
      fs-service/src/main/resources/mapper/xiaoshouyi/XsyAccountMapper.xml
  55. 33 0
      fs-service/src/main/resources/mapper/xiaoshouyi/XsyCompanyAccountMapper.xml
  56. 28 0
      fs-service/src/main/resources/mapper/xiaoshouyi/XsyUserBindMapper.xml

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

@@ -0,0 +1,259 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.dto.*;
+import com.fs.xiaoshouyi.service.XiaoShouYiMaterialService;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.view.RedirectView;
+
+import java.io.File;
+
+@Slf4j
+@RestController
+@RequestMapping("/xiaoShouYi")
+@RequiredArgsConstructor
+public class XiaoShouYiController {
+
+    private final XiaoShouYiOAuthService oAuthService;
+    private final XiaoShouYiMaterialService materialService;
+
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:auth:url')")
+    @GetMapping("/auth/url/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(oAuthService.buildAuthUrl(accountId));
+    }
+
+    /**
+     * 授权回调
+     */
+    @GetMapping("/auth/callback/{accountId}")
+    public RedirectView authCallback(@PathVariable Long accountId,
+                                     @RequestParam("code") String code) {
+
+        try {
+            TokenResponse tokenResp = oAuthService.exchangeCodeForToken(accountId, code);
+
+            if (tokenResp.isSuccess()) {
+                return new RedirectView("/xiaoShouYi/success");
+            }
+
+            return new RedirectView("/xiaoShouYi/error?msg=" + tokenResp.getError());
+
+        } catch (Exception e) {
+            log.error("授权回调异常 accountId={}", accountId, e);
+            return new RedirectView("/xiaoShouYi/error?msg=" + e.getMessage());
+        }
+    }
+
+
+
+    /**
+     * 生成追踪链接
+     */
+    @PostMapping("/generateLink")
+    public AjaxResult generateLink(@RequestParam Long companyUserId,
+                                   @Validated @RequestBody GenerateLinkRequest request) {
+
+        try {
+            if (request.getMaterialIds() == null || request.getMaterialIds().isEmpty()) {
+                return AjaxResult.error("materialIds不能为空");
+            }
+
+            GenerateLinkResponse response = materialService.generateMaterialTrackLink(
+                                                                    companyUserId,
+                                                                    request.getMaterialIds(),
+                                                                    request.getSendUserId());
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("生成追踪链接异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 发送确认
+     */
+    @PostMapping("/sendConfirm")
+    public AjaxResult sendConfirm(@RequestParam Long companyUserId,
+                                  @RequestParam Long forwardId,
+                                  @RequestParam Integer forwardType) {
+
+        try {
+            SendConfirmResponse response =
+                    materialService.sendConfirmAuto(companyUserId, forwardId, forwardType);
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("发送确认异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询素材
+     */
+    @PostMapping("/materials/query")
+    public AjaxResult queryMaterials(@RequestParam Long companyUserId,
+                                     @RequestBody QueryMaterialRequest request) {
+
+        try {
+            QueryMaterialResponse response =
+                    materialService.queryMaterialsAuto(companyUserId, request);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("查询素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传素材文件
+     */
+    @PostMapping("/uploadFile")
+    public AjaxResult uploadFile(@RequestParam Long companyUserId,
+                                 @RequestParam("file") MultipartFile multipartFile,
+                                 @RequestParam(defaultValue = "false") Boolean isVideo) {
+
+        if (multipartFile == null || multipartFile.isEmpty()) {
+            return AjaxResult.error("文件不能为空");
+        }
+
+        File tempFile = null;
+        try {
+            // 保留文件后缀
+            String suffix = getSuffix(multipartFile.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+
+            multipartFile.transferTo(tempFile);
+
+            UploadMaterialFileResponse response =
+                    materialService.uploadMaterialFileAuto(companyUserId, tempFile, isVideo);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("上传素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+    /**
+     * 创建素材
+     */
+    @PostMapping("/createMaterial")
+    public AjaxResult createMaterial(@RequestParam Long companyUserId,
+                                     @RequestBody CreateMaterialRequest request) {
+
+        try {
+            CreateMaterialResponse response =
+                    materialService.createMaterialAuto(companyUserId, request);
+
+            return response.getSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传 + 创建素材
+     */
+    @PostMapping("/createMaterialWithUpload")
+    public AjaxResult createMaterialWithUpload(@RequestParam Long companyUserId,
+                                               @RequestParam("file") MultipartFile file,
+                                               @RequestParam(defaultValue = "false") Boolean isVideo,
+                                               @RequestParam String corpName,
+                                               @RequestParam Integer materialType,
+                                               @RequestParam String categoryName,
+                                               @RequestParam String title) {
+
+        File tempFile = null;
+
+        try {
+            if (!StringUtils.hasText(corpName) || !StringUtils.hasText(categoryName) || !StringUtils.hasText(title)) {
+                return AjaxResult.error("必填参数不能为空");
+            }
+
+            String suffix = getSuffix(file.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+            file.transferTo(tempFile);
+
+            CreateMaterialRequest req = new CreateMaterialRequest();
+            req.setCorpName(corpName);
+            req.setMaterialType(materialType);
+            req.setCategoryName(categoryName);
+            req.setTitle(title);
+
+            CreateMaterialWithUploadResponse response =
+                    materialService.createMaterialWithUploadAuto(
+                            companyUserId, tempFile, isVideo, req);
+
+            return AjaxResult.success(response);
+
+        } catch (Exception e) {
+            log.error("上传+创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+
+
+    @GetMapping("/success")
+    public String success() {
+        return "授权成功";
+    }
+
+    @GetMapping("/error")
+    public String error(String msg) {
+        return "授权失败: " + msg;
+    }
+
+
+
+    private String getSuffix(String fileName) {
+        if (!StringUtils.hasText(fileName)) {
+            return ".tmp";
+        }
+        int index = fileName.lastIndexOf(".");
+        return index > 0 ? fileName.substring(index) : ".tmp";
+    }
+}

+ 113 - 0
fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java

@@ -0,0 +1,113 @@
+package com.fs.xiaoshouyi.controller;
+
+import cn.hutool.core.lang.Snowflake;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.StringUtils;
+import com.fs.xiaoshouyi.domain.XsyAccount;
+import com.fs.xiaoshouyi.service.XsyAccountService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 销售易账号管理
+ */
+@RestController
+@RequestMapping("/xsy/account")
+@RequiredArgsConstructor
+public class XsyAccountController extends BaseController {
+
+    private final XsyAccountService xsyAccountService;
+
+
+    /**
+     * 分页查询账号列表(总后台)
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(XsyAccount query) {
+        startPage();
+        List<XsyAccount> list = xsyAccountService.selectList(query);
+        return getDataTable(list);
+    }
+
+
+
+    /**
+     * 查询详情
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/get/{id}")
+    public AjaxResult get(@PathVariable Long id) {
+        return AjaxResult.success(xsyAccountService.selectById(id));
+    }
+
+    /**
+     * 新增
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:add')")
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody XsyAccount account) {
+        String uri = account.getRedirectUri();
+        Snowflake snowflake = IdUtil.getSnowflake();
+        Long id = snowflake.nextId();
+        account.setId(id);
+
+        if (StringUtils.isNotEmpty(uri) && ObjectUtil.isNotEmpty(id)) {
+            uri = uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri;
+            account.setRedirectUri(uri + "/" + id);
+        }
+        xsyAccountService.insert(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 修改
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/update")
+    public AjaxResult update(@RequestBody XsyAccount account) {
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 删除
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:delete')")
+    @PostMapping("/delete/{id}")
+    public AjaxResult delete(@PathVariable Long id) {
+        xsyAccountService.deleteById(id);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 启用/禁用
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/status")
+    public AjaxResult updateStatus(@RequestParam Long id,
+                                   @RequestParam Integer status) {
+        XsyAccount account = new XsyAccount();
+        account.setId(id);
+        account.setStatus(status);
+
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/authUrl/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(xsyAccountService.getAuthUrl(accountId));
+    }
+}

+ 36 - 0
fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyCompanyBindController.java

@@ -0,0 +1,36 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.dto.BindCompanyXsyAccountRequest;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/xsy/companyBind")
+@RequiredArgsConstructor
+public class XsyCompanyBindController {
+
+    private final XsyCompanyBindService bindService;
+
+    @PreAuthorize("@ss.hasPermi('xsy:companybind:bind')")
+    @PostMapping("/bind")
+    public AjaxResult bind(@RequestBody BindCompanyXsyAccountRequest req) {
+        bindService.bind(req.getCompanyId(), req.getAccountId());
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('xsy:companybind:unbind')")
+    @PostMapping("/unbind")
+    public AjaxResult unbind(@RequestBody BindCompanyXsyAccountRequest req) {
+        bindService.unbind(req.getCompanyId(), req.getAccountId());
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('xsy:companybind:query')")
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam Long companyId) {
+        return AjaxResult.success(bindService.getAccountIdsByCompanyId(companyId));
+    }
+}

+ 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;
+        }
+    }
+}

+ 259 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java

@@ -0,0 +1,259 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.dto.*;
+import com.fs.xiaoshouyi.service.XiaoShouYiMaterialService;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.view.RedirectView;
+
+import java.io.File;
+
+@Slf4j
+@RestController
+@RequestMapping("/xiaoShouYi")
+@RequiredArgsConstructor
+public class XiaoShouYiController {
+
+    private final XiaoShouYiOAuthService oAuthService;
+    private final XiaoShouYiMaterialService materialService;
+
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:auth:url')")
+    @GetMapping("/auth/url/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(oAuthService.buildAuthUrl(accountId));
+    }
+
+    /**
+     * 授权回调
+     */
+    @GetMapping("/auth/callback/{accountId}")
+    public RedirectView authCallback(@PathVariable Long accountId,
+                                     @RequestParam("code") String code) {
+
+        try {
+            TokenResponse tokenResp = oAuthService.exchangeCodeForToken(accountId, code);
+
+            if (tokenResp.isSuccess()) {
+                return new RedirectView("/xiaoShouYi/success");
+            }
+
+            return new RedirectView("/xiaoShouYi/error?msg=" + tokenResp.getError());
+
+        } catch (Exception e) {
+            log.error("授权回调异常 accountId={}", accountId, e);
+            return new RedirectView("/xiaoShouYi/error?msg=" + e.getMessage());
+        }
+    }
+
+
+
+    /**
+     * 生成追踪链接
+     */
+    @PostMapping("/generateLink")
+    public AjaxResult generateLink(@RequestParam Long companyUserId,
+                                   @Validated @RequestBody GenerateLinkRequest request) {
+
+        try {
+            if (request.getMaterialIds() == null || request.getMaterialIds().isEmpty()) {
+                return AjaxResult.error("materialIds不能为空");
+            }
+
+            GenerateLinkResponse response = materialService.generateMaterialTrackLink(
+                                                                    companyUserId,
+                                                                    request.getMaterialIds(),
+                                                                    request.getSendUserId());
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("生成追踪链接异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 发送确认
+     */
+    @PostMapping("/sendConfirm")
+    public AjaxResult sendConfirm(@RequestParam Long companyUserId,
+                                  @RequestParam Long forwardId,
+                                  @RequestParam Integer forwardType) {
+
+        try {
+            SendConfirmResponse response =
+                    materialService.sendConfirmAuto(companyUserId, forwardId, forwardType);
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("发送确认异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询素材
+     */
+    @PostMapping("/materials/query")
+    public AjaxResult queryMaterials(@RequestParam Long companyUserId,
+                                     @RequestBody QueryMaterialRequest request) {
+
+        try {
+            QueryMaterialResponse response =
+                    materialService.queryMaterialsAuto(companyUserId, request);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("查询素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传素材文件
+     */
+    @PostMapping("/uploadFile")
+    public AjaxResult uploadFile(@RequestParam Long companyUserId,
+                                 @RequestParam("file") MultipartFile multipartFile,
+                                 @RequestParam(defaultValue = "false") Boolean isVideo) {
+
+        if (multipartFile == null || multipartFile.isEmpty()) {
+            return AjaxResult.error("文件不能为空");
+        }
+
+        File tempFile = null;
+        try {
+            // 保留文件后缀
+            String suffix = getSuffix(multipartFile.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+
+            multipartFile.transferTo(tempFile);
+
+            UploadMaterialFileResponse response =
+                    materialService.uploadMaterialFileAuto(companyUserId, tempFile, isVideo);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("上传素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+    /**
+     * 创建素材
+     */
+    @PostMapping("/createMaterial")
+    public AjaxResult createMaterial(@RequestParam Long companyUserId,
+                                     @RequestBody CreateMaterialRequest request) {
+
+        try {
+            CreateMaterialResponse response =
+                    materialService.createMaterialAuto(companyUserId, request);
+
+            return response.getSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传 + 创建素材
+     */
+    @PostMapping("/createMaterialWithUpload")
+    public AjaxResult createMaterialWithUpload(@RequestParam Long companyUserId,
+                                               @RequestParam("file") MultipartFile file,
+                                               @RequestParam(defaultValue = "false") Boolean isVideo,
+                                               @RequestParam String corpName,
+                                               @RequestParam Integer materialType,
+                                               @RequestParam String categoryName,
+                                               @RequestParam String title) {
+
+        File tempFile = null;
+
+        try {
+            if (!StringUtils.hasText(corpName) || !StringUtils.hasText(categoryName) || !StringUtils.hasText(title)) {
+                return AjaxResult.error("必填参数不能为空");
+            }
+
+            String suffix = getSuffix(file.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+            file.transferTo(tempFile);
+
+            CreateMaterialRequest req = new CreateMaterialRequest();
+            req.setCorpName(corpName);
+            req.setMaterialType(materialType);
+            req.setCategoryName(categoryName);
+            req.setTitle(title);
+
+            CreateMaterialWithUploadResponse response =
+                    materialService.createMaterialWithUploadAuto(
+                            companyUserId, tempFile, isVideo, req);
+
+            return AjaxResult.success(response);
+
+        } catch (Exception e) {
+            log.error("上传+创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+
+
+    @GetMapping("/success")
+    public String success() {
+        return "授权成功";
+    }
+
+    @GetMapping("/error")
+    public String error(String msg) {
+        return "授权失败: " + msg;
+    }
+
+
+
+    private String getSuffix(String fileName) {
+        if (!StringUtils.hasText(fileName)) {
+            return ".tmp";
+        }
+        int index = fileName.lastIndexOf(".");
+        return index > 0 ? fileName.substring(index) : ".tmp";
+    }
+}

+ 131 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java

@@ -0,0 +1,131 @@
+package com.fs.xiaoshouyi.controller;
+
+import cn.hutool.core.lang.Snowflake;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.service.TokenService;
+import com.fs.xiaoshouyi.domain.XsyAccount;
+import com.fs.xiaoshouyi.service.XsyAccountService;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 销售易账号管理
+ */
+@RestController
+@RequestMapping("/xsy/account")
+@RequiredArgsConstructor
+public class XsyAccountController extends BaseController {
+
+    private final XsyAccountService xsyAccountService;
+    @Autowired
+    private XsyCompanyBindService xsyCompanyBindService;
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 分页查询账号列表(销售后台)
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/company/list")
+    public TableDataInfo companyList() {
+        Long companyId = tokenService.getLoginUser(ServletUtils.getRequest()).getCompany().getCompanyId();
+        List<Long> accountIdsByCompanyIds = xsyCompanyBindService.getAccountIdsByCompanyId(companyId);
+        startPage();
+        List<XsyAccount> list = xsyAccountService.selectListByIds(accountIdsByCompanyIds);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分页查询账号列表(查所有)
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(XsyAccount query) {
+        startPage();
+        List<XsyAccount> list = xsyAccountService.selectList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询详情
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/get/{id}")
+    public AjaxResult get(@PathVariable Long id) {
+        return AjaxResult.success(xsyAccountService.selectById(id));
+    }
+
+    /**
+     * 新增
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:add')")
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody XsyAccount account) {
+        String uri = account.getRedirectUri();
+        Snowflake snowflake = IdUtil.getSnowflake();
+        Long id = snowflake.nextId();
+        account.setId(id);
+
+        if (StringUtils.isNotEmpty(uri) && ObjectUtil.isNotEmpty(id)) {
+            uri = uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri;
+            account.setRedirectUri(uri + "/" + id);
+        }
+        xsyAccountService.insert(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 修改
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/update")
+    public AjaxResult update(@RequestBody XsyAccount account) {
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 删除
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:delete')")
+    @PostMapping("/delete/{id}")
+    public AjaxResult delete(@PathVariable Long id) {
+        xsyAccountService.deleteById(id);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 启用/禁用
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/status")
+    public AjaxResult updateStatus(@RequestParam Long id,
+                                   @RequestParam Integer status) {
+        XsyAccount account = new XsyAccount();
+        account.setId(id);
+        account.setStatus(status);
+
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/authUrl/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(xsyAccountService.getAuthUrl(accountId));
+    }
+}

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

@@ -0,0 +1,61 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.framework.service.TokenService;
+import com.fs.xiaoshouyi.dto.BindXsyAccountRequest;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import com.fs.xiaoshouyi.service.XsyUserBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/xsy/bind")
+@RequiredArgsConstructor
+public class XsyBindController {
+
+    @Autowired
+    private XsyUserBindService bindService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private XsyCompanyBindService xsyCompanyBindService;
+
+
+    /**
+     * 绑定销售易账号
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:bind:bind')")
+    @PostMapping("/bind")
+    public AjaxResult bind(@RequestBody BindXsyAccountRequest req) {
+        Long companyId = tokenService.getLoginUser(ServletUtils.getRequest()).getCompany().getCompanyId();
+        boolean account = xsyCompanyBindService.isCompanyBoundAccount(companyId, req.getAccountId());
+        if (!account) {
+            throw new RuntimeException("当前员工所在公司未绑定该销售易账号,不能绑定");
+        }
+        bindService.bind(req.getCompanyUserId(), req.getAccountId());
+        return AjaxResult.success();
+    }
+
+    /**
+     * 解绑
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:bind:unbind')")
+    @PostMapping("/unbind")
+    public AjaxResult unbind(@RequestBody BindXsyAccountRequest req) {
+        bindService.unbind(req.getCompanyUserId());
+        return AjaxResult.success();
+    }
+
+    /**
+     * 查询绑定
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:bind:query')")
+    @GetMapping("/get")
+    public AjaxResult get(@RequestParam Long companyUserId) {
+        Long accountId = bindService.getBindAccountId(companyUserId);
+        return AjaxResult.success(accountId);
+    }
+}

+ 2 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java

@@ -155,10 +155,11 @@ public interface CompanyMapper
 
 
     @Select({"<script> " +
-            "select c.*,cu.user_name,qu.used_num," +
+            "select c.*,cu.user_name,qu.used_num,xcb.account_id," +
             "CASE WHEN JSON_VALID(t1.config_value) THEN t1.config_value->>'$.mchId' ELSE NULL END as mchId" +
             " FROM company c LEFT JOIN company_user cu ON c.user_id =cu.user_id  " +
             " left join company_config t1 on t1.config_key = 'redPacket:config' and t1.company_id = c.company_id " +
+            "left join xsy_company_bind xcb on xcb.company_id = c.company_id " +
             "LEFT JOIN (select company_id, count(id) as used_num from qw_user where server_id is not null and server_status = 1 group by company_id) qu ON qu.company_id = c.company_id " +
             "where c.is_del=0 " +
             "            <if test=\"companyName != null  and companyName != ''\"> and c.company_name like concat('%', #{companyName}, '%')</if>\n" +

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

@@ -155,4 +155,10 @@ public class CompanyUserQwListVO extends BaseEntity {
      */
     private  Integer bindStatus;
 
+    /**
+     * 绑定销售易账号
+     */
+    private Long accountId;
+
+
 }

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

@@ -107,4 +107,6 @@ public class CompanyVO implements Serializable
 
     // 控制休息提示是否打开要暂停  0-关闭 1-打开 null-默认打开
     private Integer isOpenRestReminder;
+
+    private Integer accountId;
 }

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

@@ -54,6 +54,11 @@ public class CourseConfig implements Serializable {
     private Integer isOpen;
     private Boolean completionCountdown;
 
+    /**
+     * 外网通用看课链接域名
+     */
+    private String realLinkGjDomainName;
+
     /**
      * 侧边栏是否仅展示当天课程
      */

+ 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;
         //文件地址

+ 175 - 0
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.config.FSSysConfig;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.PubFun;
@@ -64,6 +65,10 @@ import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.fs.voice.utils.StringUtil;
+import com.fs.xiaoshouyi.dto.CreateMaterialRequest;
+import com.fs.xiaoshouyi.dto.CreateMaterialWithUploadResponse;
+import com.fs.xiaoshouyi.dto.GenerateLinkResponse;
+import com.fs.xiaoshouyi.service.XiaoShouYiMaterialService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
@@ -80,12 +85,14 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
 import static com.fs.course.utils.LinkUtil.generateRandomNumberWithLock;
 import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
+import static com.fs.xiaoshouyi.constant.XiaoShouYiRedisKey.TRACKING_LINK_PREFIX;
 
 @Service
 public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
@@ -102,6 +109,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private static final String appLiveShortLink = "/pages_live/livingList?link=";
     private static final String h5miniappLink = "/pages_course/shortLink.html?s=";
 //    private static final String miniappRealLink = "/pages/index/index?course=";
+    private static final String gjminiappLink = "/pages/course/learning?course=";
 
     @Autowired
     private ISysConfigService configService;
@@ -196,6 +204,12 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private FsUserMapper fsUserMapper;
 
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private XiaoShouYiMaterialService xiaoShouYiMaterialService;
+
 
 
     @Override
@@ -2066,6 +2080,124 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 //文字和短链一起
                 case "1":
                 case "3":
+                    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, 2);
+
+                        String link = createH5GjLinkByMiniApp(st, param.getCorpId(), dataTime, param.getCourseId(), param.getVideoId(),
+                                String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);;
+                        String cacheKey = buildXsyLinkCacheKey(param.getCorpId(),param.getCourseId(),param.getVideoId(),companyUserId,companyId);
+                        //从redis中查询收否已经存在追踪链接
+                        String xsyTrackUrl = redisCache.getCacheObject(cacheKey).toString();
+                        if (StringUtils.isBlank(xsyTrackUrl)){
+                            // 失败不影响主流程
+                            try {
+                                if (StringUtils.isEmpty(link)) {
+                                    log.warn("销售易素材处理跳过,原因:生成原始链接为空。sopId={}, userId={}, externalId={}",
+                                            item.getSopId(), item.getFsUserId(), item.getExternalId());
+                                } else if (StringUtils.isEmpty(st.getLinkImageUrl())) {
+                                    log.warn("销售易素材处理跳过,原因:imgUrl为空。sopId={}, userId={}, externalId={}, link={}",
+                                            item.getSopId(), item.getFsUserId(), item.getExternalId(), link);
+                                } else {
+                                    String imgUrl = st.getLinkImageUrl();
+
+                                    // 2. 创建素材
+                                    CreateMaterialRequest createMaterialRequest = new CreateMaterialRequest();
+                                    createMaterialRequest.setCorpName("销售易营销云");
+                                    createMaterialRequest.setMaterialType(32);
+                                    createMaterialRequest.setCategoryName("看课");
+                                    createMaterialRequest.setTitle(StringUtils.isEmpty(st.getLinkTitle()) ? "看课素材" : st.getLinkTitle());
+                                    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(st.getSmsTemplateTitle());
+                                    createMaterialRequest.setEnableCardTemplateStatus(0);
+                                    createMaterialRequest.setSummary(st.getSmsTemplateContent());
+                                    // 32 外链素材通常不需要 content
+                                    createMaterialRequest.setUrl(link);
+
+                                    CreateMaterialWithUploadResponse materialWithUpload = xiaoShouYiMaterialService.createMaterialWithUploadByUrl(Long.valueOf(companyUserId),imgUrl, false, createMaterialRequest);
+
+                                    if (materialWithUpload == null || materialWithUpload.getMaterialId() == null) {
+                                        throw new RuntimeException("创建销售易素材失败,返回materialId为空");
+                                    }
+
+                                    // 3. 获取追踪链接
+                                    ArrayList<Long> materialIdList = new ArrayList<>();
+                                    materialIdList.add(materialWithUpload.getMaterialId());
+
+                                    GenerateLinkResponse response =
+                                            xiaoShouYiMaterialService.generateMaterialTrackLink(Long.valueOf(companyUserId),materialIdList, 4256378833539950L);
+
+                                    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为空");
+                                    }
+
+                                    // 4. 覆盖成销售易追踪链接
+                                    String xsyRedirectUrl = response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl();
+                                    st.setXsyLinkUrl(xsyRedirectUrl);
+                                    st.setLinkUrl(xsyRedirectUrl);
+
+                                    //将追踪链接存入redis
+                                    redisCache.setCacheObject(cacheKey, xsyRedirectUrl, 10, TimeUnit.DAYS);
+                                    log.info("销售易追踪链接写入缓存成功,cacheKey={}, xsyRedirectUrl={}", cacheKey, xsyRedirectUrl);
+
+                                    log.info("销售易素材创建并生成追踪链接成功。sopId={}, userId={}, externalId={}, materialId={}, redirectUrl={}",
+                                            item.getSopId(), item.getFsUserId(), item.getExternalId(),
+                                            materialWithUpload.getMaterialId(), xsyRedirectUrl);
+                                }
+                            } catch (Exception e) {
+                                log.error("销售易素材处理失败,不影响主流程。sopId={}, userId={}, externalId={}, contentType={}, imgUrl={}, rawLink={}",
+                                        item.getSopId(), item.getFsUserId(), item.getExternalId(), st.getContentType(), st.getImgUrl(), link, e);
+
+                                // 保底:销售易失败时,仍然使用原始 link
+                                if (StringUtils.isNotEmpty(link)) {
+                                    st.setLinkUrl(link);
+                                    st.setXsyLinkUrl(link);
+                                }
+                            }
+                        }
+
+                        st.setXsyLinkUrl(xsyTrackUrl);
+                        st.setLinkUrl(xsyTrackUrl);
+
+
+                        if (StringUtils.isNotEmpty(link)) {
+                            if ("3".equals(st.getContentType())) {
+//                                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(link);
+                                } else {
+                                    st.setValue(currentValue
+                                            .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText())
+                                            + "\n" + link);
+                                }
+                            }
+                        } else {
+                            log.error("生成短链失败,跳过设置 URL。");
+                        }
+                    }
 //                    if ("1".equals(st.getIsBindUrl())) {
 //
 //                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
@@ -3222,4 +3354,47 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return link.getRealLink();
     }
 
+    private 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);
+        return link.getRealLink();
+    }
+
+    private String buildXsyLinkCacheKey(String corpId,
+                                        Integer courseId,
+                                        Integer videoId,
+                                        String companyUserId,
+                                        String companyId) {
+        return TRACKING_LINK_PREFIX
+                + safeVal(corpId) + ":"
+                + safeVal(courseId) + ":"
+                + safeVal(videoId) + ":"
+                + safeVal(companyUserId) + ":"
+                + safeVal(companyId);
+    }
+
+    private String safeVal(Object obj) {
+        return obj == null ? "0" : String.valueOf(obj);
+    }
+
 }

+ 225 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/XiaoShouYiApiUtils.java

@@ -0,0 +1,225 @@
+package com.fs.xiaoshouyi;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+public class XiaoShouYiApiUtils {
+
+    // 业务接口路径
+    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";
+
+    /**
+     * 发送场景枚举
+     */
+    public static class ForwardType {
+        public static final int PRE_SEND = -1;           // 预发送,不纳入统计
+        public static final int SINGLE_CHAT = 1;         // 单聊
+        public static final int GROUP_CHAT = 2;          // 群聊或群发
+        public static final int FORWARD = 3;             // 转发
+        public static final int MOMENTS = 4;             // 客户朋友圈
+        public static final int OFFICIAL_ACCOUNT = 5;    // 公众号群发
+        public static final int MINI_PROGRAM = 6;        // 名片小程序
+    }
+
+    /**
+     * 生成素材追踪链接
+     */
+    public static GenerateLinkResponse generateMaterialTrackLink(List<Long> materialIds, Long sendUserId) {
+        String authHeader = XiaoShouYiOAuthUtils.getAuthorizationHeader();
+        if (authHeader == null) {
+            log.error("未获取到有效token");
+            return GenerateLinkResponse.error("未获取到有效token,请先授权");
+        }
+
+        String url = XiaoShouYiOAuthUtils.getApiBaseUrl() + GET_LINK_PATH;
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("materialIdList", materialIds);
+        body.put("sendUserId", sendUserId);
+
+        String bodyJson = JSONUtil.toJsonStr(body);
+        log.info("请求URL: {}", url);
+        log.info("请求参数: {}", bodyJson);
+
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .body(bodyJson)
+                .timeout(30000)
+                .execute()) {
+
+            String result = response.body();
+            log.info("响应结果: {}", result);
+
+            if (response.getStatus() == 200) {
+                return parseGenerateResponse(result);
+            } else if (response.getStatus() == 401) {
+                log.warn("token失效,清除缓存");
+                XiaoShouYiOAuthUtils.clearCache();
+                return GenerateLinkResponse.error("token失效,请重新授权");
+            } else {
+                return GenerateLinkResponse.error("HTTP状态码: " + response.getStatus() + ", 响应: " + result);
+            }
+        } catch (Exception e) {
+            log.error("请求异常", e);
+            return GenerateLinkResponse.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 素材发送确认
+     */
+    public static SendConfirmResponse sendConfirm(Long forwardId, Integer forwardType) {
+        String authHeader = XiaoShouYiOAuthUtils.getAuthorizationHeader();
+        if (authHeader == null) {
+            return SendConfirmResponse.error("未获取到有效token,请先授权");
+        }
+
+        String url = String.format("%s%s?forwardId=%d&forwardType=%d",
+                XiaoShouYiOAuthUtils.getApiBaseUrl(), SEND_PATH, forwardId, forwardType);
+
+        log.info("请求URL: {}", url);
+
+        try (HttpResponse response = HttpRequest.get(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .timeout(30000)
+                .execute()) {
+
+            String result = response.body();
+            log.info("响应结果: {}", result);
+
+            if (response.getStatus() == 200) {
+                return parseSendResponse(result);
+            } else if (response.getStatus() == 401) {
+                XiaoShouYiOAuthUtils.clearCache();
+                return SendConfirmResponse.error("token失效,请重新授权");
+            } else {
+                return SendConfirmResponse.error("HTTP状态码: " + response.getStatus());
+            }
+        } catch (Exception e) {
+            log.error("请求异常", e);
+            return SendConfirmResponse.error(e.getMessage());
+        }
+    }
+
+    // ==================== 解析方法 ====================
+
+    private static 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"));
+
+        if (response.isSuccess() && json.containsKey("data")) {
+            JSONObject data = json.getJSONObject("data");
+
+            // 解析成功获取链接的素材
+            if (data.containsKey("trackedMaterials")) {
+                response.setTrackedMaterials(parseMaterialList(data.getJSONArray("trackedMaterials"), true));
+            }
+
+            // 解析无法获取链接的素材
+            if (data.containsKey("untrackedMaterials")) {
+                response.setUntrackedMaterials(parseMaterialList(data.getJSONArray("untrackedMaterials"), false));
+            }
+        }
+
+        return response;
+    }
+
+    private static List<MaterialTrackItem> parseMaterialList(cn.hutool.json.JSONArray array, boolean hasTrackLink) {
+        if (array == null || array.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+
+        return array.stream().map(obj -> {
+            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);
+            }
+
+            return trackItem;
+        }).collect(Collectors.toList());
+    }
+
+    private static 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;
+    }
+
+    // ==================== 内部类 ====================
+
+    @Data
+    public static class GenerateLinkResponse {
+        private boolean success;
+        private String code;
+        private String msg;
+        private List<MaterialTrackItem> trackedMaterials;
+        private List<MaterialTrackItem> untrackedMaterials;
+
+        public static GenerateLinkResponse error(String errorMsg) {
+            GenerateLinkResponse resp = new GenerateLinkResponse();
+            resp.setSuccess(false);
+            resp.setCode("500");
+            resp.setMsg(errorMsg);
+            return resp;
+        }
+
+        public boolean hasTrackedMaterials() {
+            return trackedMaterials != null && !trackedMaterials.isEmpty();
+        }
+    }
+
+    @Data
+    public static class SendConfirmResponse {
+        private boolean success;
+        private String code;
+        private String msg;
+
+        public static SendConfirmResponse error(String errorMsg) {
+            SendConfirmResponse resp = new SendConfirmResponse();
+            resp.setSuccess(false);
+            resp.setCode("500");
+            resp.setMsg(errorMsg);
+            return resp;
+        }
+    }
+
+    @Data
+    public static class MaterialTrackItem {
+        private Long materialId;
+        private Integer materialType;
+        private TrackLinkInfo trackLink;
+    }
+
+    @Data
+    public static class TrackLinkInfo {
+        private Long forwardId;
+        private String redirectUrl;
+    }
+}

+ 286 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/XiaoShouYiOAuthUtils.java

@@ -0,0 +1,286 @@
+package com.fs.xiaoshouyi;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.locks.ReentrantLock;
+
+@Slf4j
+public class XiaoShouYiOAuthUtils {
+
+    // 销售易认证服务器地址
+    private static final String AUTH_URL = "https://login.xiaoshouyi.com/auc/oauth2/auth";
+    private static final String TOKEN_URL = "https://login.xiaoshouyi.com/auc/oauth2/token";
+
+    // 业务API基础地址
+    private static String apiBaseUrl = "https://api.xiaoshouyi.com";
+
+    private static final String CLIENT_ID = "3d03b17acc06116f274b0f61797fd3b3";
+    private static final String CLIENT_SECRET = "68a5ee9a9544054b691e24186d158bb5";
+    private static final String REDIRECT_URI = "http://n9d2e4bc.natappfree.cc/xiaoShouYi/auth/callback";
+
+    // Token缓存
+    private static volatile String accessToken;
+    private static volatile String tokenType;
+    private static volatile long expiresAt;  // 过期时间戳
+    private static volatile String refreshToken;
+    private static volatile long refreshTokenExpiresAt;
+
+    private static final ReentrantLock lock = new ReentrantLock();
+
+    /**
+     * 生成授权URL(前端跳转用)
+     */
+    public static String buildAuthUrl() {
+        return buildAuthUrl("offline");
+    }
+
+    /**
+     * 生成授权URL
+     * @param accessType online:不返回refresh_token, offline:返回refresh_token
+     */
+    public static String buildAuthUrl(String accessType) {
+        try {
+            StringBuilder url = new StringBuilder(AUTH_URL);
+            url.append("?response_type=code")
+                    .append("&client_id=").append(CLIENT_ID)
+                    .append("&redirect_uri=").append(URLEncoder.encode(REDIRECT_URI, StandardCharsets.UTF_8.name()))
+                    .append("&scope=all")
+                    .append("&oauthType=standard");
+
+            if (accessType != null && !accessType.isEmpty()) {
+                url.append("&access_type=").append(accessType);
+            }
+
+            log.info("生成授权URL: {}", url);
+            return url.toString();
+        } catch (UnsupportedEncodingException e) {
+            log.error("URL编码异常", e);
+            return "";
+        }
+    }
+
+    /**
+     * 用授权码换取token
+     */
+    public static TokenResponse exchangeCodeForToken(String code) {
+        log.info("开始用授权码换取token, code={}", code);
+
+        String body = String.format(
+                "grant_type=authorization_code&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s",
+                CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, code
+        );
+
+        try (HttpResponse response = HttpRequest.post(TOKEN_URL)
+                .header("Content-Type", "application/x-www-form-urlencoded")
+                .body(body)
+                .timeout(30000)
+                .execute()) {
+
+            String result = response.body();
+            log.info("换取token响应: {}", result);
+
+            if (response.getStatus() == 200) {
+                return parseAndCacheToken(result);
+            } else {
+                log.error("换取token失败, 状态码: {}, 响应: {}", response.getStatus(), result);
+                TokenResponse errorResp = new TokenResponse();
+                errorResp.setSuccess(false);
+                errorResp.setError("HTTP状态码: " + response.getStatus());
+                return errorResp;
+            }
+        } catch (Exception e) {
+            log.error("换取token异常", e);
+            TokenResponse errorResp = new TokenResponse();
+            errorResp.setSuccess(false);
+            errorResp.setError(e.getMessage());
+            return errorResp;
+        }
+    }
+
+    /**
+     * 刷新access_token
+     */
+    public static TokenResponse refreshAccessToken() {
+        if (refreshToken == null) {
+            log.error("refresh_token为空,无法刷新");
+            TokenResponse errorResp = new TokenResponse();
+            errorResp.setSuccess(false);
+            errorResp.setError("refresh_token为空");
+            return errorResp;
+        }
+
+        log.info("开始刷新access_token");
+
+        String body = String.format(
+                "grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s",
+                CLIENT_ID, CLIENT_SECRET, refreshToken
+        );
+
+        try (HttpResponse response = HttpRequest.post(TOKEN_URL)
+                .header("Content-Type", "application/x-www-form-urlencoded")
+                .body(body)
+                .timeout(30000)
+                .execute()) {
+
+            String result = response.body();
+            log.info("刷新token响应: {}", result);
+
+            if (response.getStatus() == 200) {
+                return parseAndCacheToken(result);
+            } else {
+                log.error("刷新token失败, 状态码: {}", response.getStatus());
+                TokenResponse errorResp = new TokenResponse();
+                errorResp.setSuccess(false);
+                errorResp.setError("刷新失败");
+                return errorResp;
+            }
+        } catch (Exception e) {
+            log.error("刷新token异常", e);
+            TokenResponse errorResp = new TokenResponse();
+            errorResp.setSuccess(false);
+            errorResp.setError(e.getMessage());
+            return errorResp;
+        }
+    }
+
+    /**
+     * 解析token响应并缓存
+     */
+    private static TokenResponse parseAndCacheToken(String responseJson) {
+        JSONObject json = JSONUtil.parseObj(responseJson);
+        TokenResponse response = new TokenResponse();
+
+        lock.lock();
+        try {
+            if (json.containsKey("access_token")) {
+                accessToken = json.getStr("access_token");
+                tokenType = json.getStr("token_type");
+                Long expiresIn = json.getLong("expires_in");
+                expiresAt = System.currentTimeMillis() + (expiresIn != null ? expiresIn * 1000 : 7200 * 1000);
+
+                refreshToken = json.getStr("refresh_token");
+                Long refreshExpiresIn = json.getLong("refresh_token_expires_in");
+
+                //TODO: 不清楚给的是具体到多久过期还是说是给的
+                if (refreshExpiresIn != null) {
+                    refreshTokenExpiresAt = System.currentTimeMillis() + refreshExpiresIn * 1000;
+                }
+
+                // 更新业务API地址
+                if (json.containsKey("instance_uri") && json.getStr("instance_uri") != null) {
+                    String instanceUri = json.getStr("instance_uri");
+                    if (!instanceUri.isEmpty()) {
+                        apiBaseUrl = instanceUri;
+                    }
+                }
+
+                response.setSuccess(true);
+                response.setAccessToken(accessToken);
+                response.setTokenType(tokenType);
+                response.setExpiresIn(expiresIn);
+                response.setRefreshToken(refreshToken);
+                response.setInstanceUri(apiBaseUrl);
+
+                log.info("Token缓存成功, access_token={}, 有效期至: {}, apiBaseUrl={}",
+                        accessToken.substring(0, Math.min(20, accessToken.length())),
+                        new java.util.Date(expiresAt), apiBaseUrl);
+            } else {
+                response.setSuccess(false);
+                response.setError(json.getStr("error_description", json.getStr("error", "未知错误")));
+                log.error("Token响应解析失败: {}", responseJson);
+            }
+        } finally {
+            lock.unlock();
+        }
+
+        return response;
+    }
+
+    /**
+     * 获取有效的access_token(自动刷新)
+     */
+    public static String getValidAccessToken() {
+        // 检查缓存是否有效(提前5分钟刷新)
+        if (accessToken != null && System.currentTimeMillis() < expiresAt - 300000) {
+            return accessToken;
+        }
+
+        lock.lock();
+        try {
+            // 双重检查
+            if (accessToken != null && System.currentTimeMillis() < expiresAt - 300000) {
+                return accessToken;
+            }
+
+            // 尝试用refresh_token刷新
+            if (refreshToken != null && System.currentTimeMillis() < refreshTokenExpiresAt) {
+                log.info("access_token即将过期,使用refresh_token刷新");
+                TokenResponse response = refreshAccessToken();
+                if (response.isSuccess()) {
+                    return accessToken;
+                }
+            }
+
+            log.error("无法获取有效token,需要重新授权");
+            return null;
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    /**
+     * 获取Authorization Header
+     */
+    public static String getAuthorizationHeader() {
+        String token = getValidAccessToken();
+        if (token == null) {
+            return null;
+        }
+        String type = (tokenType != null && !tokenType.isEmpty()) ? tokenType : "Bearer";
+        return type + " " + token;
+    }
+
+    /**
+     * 获取业务API基础地址
+     */
+    public static String getApiBaseUrl() {
+        return apiBaseUrl;
+    }
+
+    /**
+     * 清除缓存(授权失败时调用)
+     */
+    public static void clearCache() {
+        lock.lock();
+        try {
+            accessToken = null;
+            refreshToken = null;
+            tokenType = null;
+            expiresAt = 0;
+            refreshTokenExpiresAt = 0;
+            log.info("Token缓存已清除");
+        } finally {
+            lock.unlock();
+        }
+    }
+
+
+    @Data
+    public static class TokenResponse {
+        private boolean success;
+        private String error;
+        private String accessToken;
+        private String tokenType;
+        private Long expiresIn;
+        private String refreshToken;
+        private String instanceUri;
+    }
+}

+ 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;
+        }
+
+    }
+}

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

@@ -0,0 +1,42 @@
+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;
+
+
+    /**
+     * scope
+     */
+    private String scope = "all";
+
+    /**
+     * oauth类型
+     */
+    private String oauthType = "standard";
+
+    /**
+     * offline时返回refresh_token
+     */
+    private String accessType = "offline";
+}

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

@@ -0,0 +1,34 @@
+package com.fs.xiaoshouyi.constant;
+
+public interface XiaoShouYiRedisKey {
+
+    /**
+     * token完整信息
+     */
+    static String tokenInfoKey(Long accountId) {
+        return "xsy:oauth:token_info:" + accountId;
+    }
+
+    /**
+     * access token快捷缓存
+     */
+    static String accessTokenKey(Long accountId) {
+        return "xsy:oauth:access_token:" + accountId;
+    }
+
+    /**
+     * 刷新token分布式锁
+     */
+    static String refreshLockKey(Long accountId) {
+        return "xsy:oauth:refresh_lock:" + accountId;
+    }
+
+    /**
+     * OAuth state前缀
+     */
+    String OAUTH_STATE_PREFIX = "xsy:oauth:state:";
+    /**
+     * 追踪链接前缀
+     */
+    String TRACKING_LINK_PREFIX = "xsy:tracking_link:";
+}

+ 73 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/domain/XsyAccount.java

@@ -0,0 +1,73 @@
+package com.fs.xiaoshouyi.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 销售易账号配置
+ * 一条记录代表一个销售易账号
+ */
+@Data
+public class XsyAccount {
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * 账号名称
+     */
+    private String accountName;
+
+    /**
+     * client_id
+     */
+    private String clientId;
+
+    /**
+     * client_secret
+     */
+    private String clientSecret;
+
+    /**
+     * 回调地址
+     * 结尾带上 Id,例如:
+     * https://xxx.com/xsy/auth/callback/1
+     */
+    private String redirectUri;
+
+    /**
+     * 销售易返回的实例地址
+     */
+    private String instanceUri;
+
+    /**
+     * access_token
+     */
+    private String accessToken;
+
+    /**
+     * refresh_token
+     */
+    private String refreshToken;
+
+    /**
+     * access_token 过期时间戳(毫秒)
+     */
+    private Long expiresAt;
+
+    /**
+     * refresh_token 过期时间戳(毫秒)
+     */
+    private Long refreshTokenExpiresAt;
+
+    /**
+     * 状态:1启用 0禁用
+     */
+    private Integer status;
+
+    private Date createTime;
+    private Date updateTime;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/domain/XsyUserBind.java

@@ -0,0 +1,26 @@
+package com.fs.xiaoshouyi.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 企微销售 与 销售易账号 绑定关系
+ */
+@Data
+public class XsyUserBind {
+
+    private Long id;
+
+    /**
+     * 企微销售ID
+     */
+    private Long companyUserId;
+
+    /**
+     * 销售易账号ID
+     */
+    private Long xsyAccountId;
+
+    private Date createTime;
+}

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

@@ -0,0 +1,9 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class BindCompanyXsyAccountRequest {
+    private Long companyId;
+    private Long accountId;
+}

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

@@ -0,0 +1,9 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+@Data
+public class BindXsyAccountRequest {
+    private Long companyUserId;
+    private Long accountId;
+}

+ 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;
+    }
+}

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

@@ -0,0 +1,110 @@
+package com.fs.xiaoshouyi.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@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;
+//    }
+
+    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 {
+
+        /**
+         * 素材主体数据
+         */
+        private Entry entry;
+
+        /**
+         * 用户信息(一般用不到,可以忽略)
+         */
+        private Map<String, Object> users;
+    }
+
+    @Data
+    public static class Entry {
+
+        /**
+         * 素材ID(最重要)
+         */
+        private Long id;
+
+        /**
+         * 资源文件ID
+         */
+        private Long fileId;
+
+        /**
+         * 标题
+         */
+        private String title;
+
+        /**
+         * 外链地址
+         */
+        private String url;
+
+        /**
+         * 摘要
+         */
+        private String summary;
+
+        /**
+         * 启用状态
+         */
+        private Integer enableStatus;
+
+        /**
+         * 分类ID
+         */
+        private Long categoryId;
+
+        /**
+         * 创建人
+         */
+        private Long createdBy;
+
+        /**
+         * 创建时间
+         */
+        private Long createdAt;
+    }
+}

+ 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;
+    }
+}

+ 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;
+}

+ 40 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/mapper/XsyAccountMapper.java

@@ -0,0 +1,40 @@
+package com.fs.xiaoshouyi.mapper;
+
+import com.fs.xiaoshouyi.domain.XsyAccount;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface XsyAccountMapper {
+
+    /**
+     * 分页查询
+     */
+    List<XsyAccount> selectList(XsyAccount query);
+
+    /**
+     * 查询单个
+     */
+    XsyAccount selectById(@Param("id") Long id);
+
+    /**
+     * 新增
+     */
+    int insert(XsyAccount account);
+
+    /**
+     * 修改
+     */
+    int update(XsyAccount account);
+
+    /**
+     * 删除
+     */
+    int deleteById(@Param("id") Long id);
+
+    int updateTokenInfo(XsyAccount account);
+
+    List<XsyAccount> selectListByIds(@Param("ids") List<Long> ids);
+}

+ 18 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/mapper/XsyCompanyBindMapper.java

@@ -0,0 +1,18 @@
+package com.fs.xiaoshouyi.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface XsyCompanyBindMapper {
+
+    int insertBind(@Param("companyId") Long companyId, @Param("accountId") Long accountId);
+
+    int deleteBind(@Param("companyId") Long companyId, @Param("accountId") Long accountId);
+
+    List<Long> selectAccountIdsByCompanyId(@Param("companyId") Long companyId);
+
+    int existsBind(@Param("companyId") Long companyId, @Param("accountId") Long accountId);
+}

+ 37 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/mapper/XsyUserBindMapper.java

@@ -0,0 +1,37 @@
+package com.fs.xiaoshouyi.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 企微销售与销售易账号绑定 Mapper
+ */
+@Mapper
+public interface XsyUserBindMapper {
+
+    /**
+     * 根据企微销售ID查询绑定的销售易账号ID
+     *
+     * @param companyUserId 企微销售ID
+     * @return 销售易账号ID
+     */
+    Long selectAccountIdByCompanyUserId(@Param("companyUserId") Long companyUserId);
+
+    /**
+     * 删除绑定关系
+     *
+     * @param companyUserId 企微销售ID
+     * @return 影响行数
+     */
+    int deleteByCompanyUserId(@Param("companyUserId") Long companyUserId);
+
+    /**
+     * 新增绑定关系
+     *
+     * @param companyUserId 企微销售ID
+     * @param accountId     销售易账号ID
+     * @return 影响行数
+     */
+    int insertBind(@Param("companyUserId") Long companyUserId,
+                   @Param("accountId") Long accountId);
+}

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

@@ -0,0 +1,610 @@
+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.*;
+
+/**
+ * 销售易素材服务(自动选账号、严格模式)
+ *
+ * 设计说明:
+ * 1. 所有业务接口统一只接收 companyUserId
+ * 2. 通过 companyUserId -> 绑定的 accountId 自动选账号
+ * 3. 不做默认账号兜底,未绑定直接报错
+ * 4. 所有请求统一走 executeWithRetry,减少重复代码
+ */
+@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 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;
+    private final XsyAccountResolver xsyAccountResolver;
+
+
+    /**
+     * 自动选账号:生成素材追踪链接
+     */
+    public GenerateLinkResponse generateMaterialTrackLink(Long companyUserId,
+                                                              List<Long> materialIdList,
+                                                              Long sendUserId) {
+        Long accountId = resolveAccountId(companyUserId);
+
+        Map<String, Object> body = new HashMap<String, Object>();
+        body.put("materialIdList", materialIdList);
+        body.put("sendUserId", sendUserId);
+
+        return executeWithRetry(
+                accountId,
+                properties.getDefaultApiBaseUrl() + GET_LINK_PATH,
+                authHeader -> httpClient.postJson(normalizeUrl(properties.getDefaultApiBaseUrl() + GET_LINK_PATH), authHeader, body),
+                this::parseGenerateResponse,
+                GenerateLinkResponse::error
+        );
+    }
+
+    /**
+     * 自动选账号:发送确认
+     */
+    public SendConfirmResponse sendConfirmAuto(Long companyUserId,
+                                               Long forwardId,
+                                               Integer forwardType) {
+        Long accountId = resolveAccountId(companyUserId);
+
+        String url = String.format("%s%s?forwardId=%d&forwardType=%d",
+                properties.getDefaultApiBaseUrl(), SEND_PATH, forwardId, forwardType);
+
+        return executeWithRetry(
+                accountId,
+                url,
+                authHeader -> httpClient.get(normalizeUrl(url), authHeader),
+                this::parseSendResponse,
+                SendConfirmResponse::error
+        );
+    }
+
+    /**
+     * 自动选账号:查询素材
+     */
+    public QueryMaterialResponse queryMaterialsAuto(Long companyUserId,
+                                                    QueryMaterialRequest request) {
+        Long accountId = resolveAccountId(companyUserId);
+        String url = normalizeUrl(oAuthService.getApiBaseUrl(accountId) + QUERY_ALL_PATH);
+
+        return executeWithRetry(
+                accountId,
+                url,
+                authHeader -> httpClient.postJson(url, authHeader, request),
+                this::parseQueryMaterialResponse,
+                QueryMaterialResponse::error
+        );
+    }
+
+    /**
+     * 自动选账号:上传素材资源
+     */
+    public UploadMaterialFileResponse uploadMaterialFileAuto(Long companyUserId,
+                                                             File file,
+                                                             Boolean isVideo) {
+        if (file == null || !file.exists() || !file.isFile()) {
+            return UploadMaterialFileResponse.error("上传文件不存在");
+        }
+
+        Long accountId = resolveAccountId(companyUserId);
+        String url = normalizeUrl(properties.getDefaultApiBaseUrl() + UPLOAD_FILE_PATH);
+
+        log.info("上传素材资源请求URL: {}, accountId={}", url, accountId);
+        log.info("上传素材资源文件: {}, isVideo={}", file.getAbsolutePath(), isVideo);
+
+        return executeWithRetry(
+                accountId,
+                url,
+                authHeader -> httpClient.postMultipart(url, authHeader, file, isVideo),
+                this::parseUploadMaterialFileResponse,
+                UploadMaterialFileResponse::error
+        );
+    }
+
+    /**
+     * 自动选账号:创建素材
+     */
+    public CreateMaterialResponse createMaterialAuto(Long companyUserId,
+                                                     CreateMaterialRequest request) {
+        String validateMsg = validateCreateMaterialRequest(request);
+        if (validateMsg != null) {
+            return CreateMaterialResponse.error(validateMsg);
+        }
+
+        Long accountId = resolveAccountId(companyUserId);
+        String url = normalizeUrl(properties.getDefaultApiBaseUrl() + CREATE_MATERIAL_PATH);
+
+        log.info("创建素材请求URL: {}, accountId={}", url, accountId);
+        log.info("创建素材请求参数: {}", JSONUtil.toJsonStr(request));
+
+        return executeWithRetry(
+                accountId,
+                url,
+                authHeader -> httpClient.postJson(url, authHeader, request),
+                this::parseCreateMaterialResponse,
+                CreateMaterialResponse::error
+        );
+    }
+
+    /**
+     * 自动选账号:上传素材资源 + 创建素材
+     */
+    public CreateMaterialWithUploadResponse createMaterialWithUploadAuto(Long companyUserId,
+                                                                         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 = uploadMaterialFileAuto(companyUserId, 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());
+
+        // 外链类素材也需要封面,这里先回填 fileId 兜住
+        request.setCoverFileId(uploadResp.getData().getFileId());
+
+        if (uploadResp.getData().getCoverFileDto() != null) {
+            request.setCoverFileId(uploadResp.getData().getCoverFileDto().getFileId());
+        }
+
+        // 3. 创建素材
+        CreateMaterialResponse createResp = createMaterialAuto(companyUserId, request);
+        log.info("素材返回:{}", createResp);
+
+        if (!Boolean.TRUE.equals(createResp.getSuccess())) {
+            throw new RuntimeException("创建素材失败: " + createResp.getMsg());
+        }
+
+        if (createResp.getData() == null
+                || createResp.getData().getEntry() == null
+                || 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(Long companyUserId,
+                                                                              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();
+
+            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 createMaterialWithUploadAuto(companyUserId, 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();
+                }
+            }
+        }
+    }
+
+
+    /**
+     * 通用执行模板:
+     * 1. 先取 token 发请求
+     * 2. 遇到 401 时自动刷新 token 后重试一次
+     * 3. 统一组装错误返回
+     */
+    private <T> T executeWithRetry(Long accountId,
+                                   String url,
+                                   RequestExecutor executor,
+                                   ResponseParser<T> successParser,
+                                   ErrorResponseFactory<T> errorFactory) {
+
+        String authHeader = oAuthService.getAuthorizationHeader(accountId);
+        if (authHeader == null) {
+            return errorFactory.build("未获取到有效token,请先授权");
+        }
+
+        XiaoShouYiHttpClient.HttpResult result = executor.execute(authHeader);
+
+        if (result.getStatus() == 200) {
+            return successParser.parse(result.getBody());
+        }
+
+        if (result.getStatus() == 401) {
+            log.warn("请求遇到401,尝试刷新token后重试,accountId={}, url={}", accountId, url);
+
+            TokenResponse refreshResp = oAuthService.refreshAccessToken(accountId);
+            if (!refreshResp.isSuccess()) {
+                log.error("刷新token失败,accountId={}, url={}, error={}",
+                        accountId, url, refreshResp.getError());
+                oAuthService.clearTokenCache(accountId);
+                return errorFactory.build("token失效,请重新授权");
+            }
+
+            authHeader = oAuthService.getAuthorizationHeader(accountId);
+            if (authHeader == null) {
+                oAuthService.clearTokenCache(accountId);
+                return errorFactory.build("获取刷新后的token失败,请重新授权");
+            }
+
+            XiaoShouYiHttpClient.HttpResult retry = executor.execute(authHeader);
+
+            if (retry.getStatus() == 200) {
+                return successParser.parse(retry.getBody());
+            }
+
+            if (retry.getStatus() == 401) {
+                oAuthService.clearTokenCache(accountId);
+                return errorFactory.build("刷新后仍未授权,请重新授权");
+            }
+
+            return errorFactory.build(
+                    "重试失败,HTTP状态码:" + retry.getStatus() + ",响应:" + retry.getBody()
+            );
+        }
+
+        return errorFactory.build(
+                "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());
+        }
+    }
+
+    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());
+        }
+    }
+
+    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;
+    }
+
+    private Long resolveAccountId(Long companyUserId) {
+        return xsyAccountResolver.resolveAccountId(companyUserId);
+    }
+
+    private String normalizeUrl(String url) {
+        if (url == null || url.trim().isEmpty()) {
+            return url;
+        }
+        if (!url.startsWith("http")) {
+            return "https://" + url;
+        }
+        return url;
+    }
+
+    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();
+    }
+
+
+    @FunctionalInterface
+    private interface RequestExecutor {
+        XiaoShouYiHttpClient.HttpResult execute(String authHeader);
+    }
+
+    @FunctionalInterface
+    private interface ResponseParser<T> {
+        T parse(String body);
+    }
+
+    @FunctionalInterface
+    private interface ErrorResponseFactory<T> {
+        T build(String msg);
+    }
+}

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

@@ -0,0 +1,350 @@
+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.domain.XsyAccount;
+import com.fs.xiaoshouyi.dto.TokenInfo;
+import com.fs.xiaoshouyi.dto.TokenResponse;
+import com.fs.xiaoshouyi.mapper.XsyAccountMapper;
+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;
+
+/**
+ * 销售易 OAuth 服务(多账号)
+ *
+ * 设计说明:
+ * 1. 所有 token 都按 accountId 隔离
+ * 2. Redis 作为快速缓存,数据库作为持久化存储
+ * 3. access_token 过期前 5 分钟自动刷新
+ * 4. refresh_token 刷新时使用分布式锁,避免并发重复刷新
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XiaoShouYiOAuthService {
+
+    private final XiaoShouYiProperties properties;
+    private final XiaoShouYiHttpClient httpClient;
+    private final StringRedisTemplate stringRedisTemplate;
+    private final XsyAccountMapper xsyAccountMapper;
+
+    /**
+     * access_token 提前 5 分钟刷新
+     */
+    private static final long ACCESS_TOKEN_REFRESH_ADVANCE_MILLIS = 5 * 60 * 1000L;
+
+    /**
+     * 刷新 token 的分布式锁过期时间
+     */
+    private static final long LOCK_EXPIRE_SECONDS = 20L;
+
+    /**
+     * 构建授权 URL(按账号)
+     *
+     * 注意:
+     * redirect_uri 必须与销售易后台配置一致
+     */
+    public String buildAuthUrl(Long accountId) {
+        XsyAccount account = getEnabledAccount(accountId);
+
+        return properties.getAuthUrl()
+                + "?response_type=code"
+                + "&client_id=" + urlEncode(account.getClientId())
+                + "&redirect_uri=" + urlEncode(account.getRedirectUri())
+                + "&scope=" + urlEncode(properties.getScope())
+                + "&oauthType=" + urlEncode(properties.getOauthType())
+                + "&access_type=" + urlEncode(properties.getAccessType());
+    }
+
+    /**
+     * 授权码换 token(按账号)
+     */
+    public TokenResponse exchangeCodeForToken(Long accountId, String code) {
+        XsyAccount account = getEnabledAccount(accountId);
+
+        log.info("开始用授权码换取token, accountId={}, code={}", account, code);
+
+        Map<String, Object> form = new HashMap<>();
+        form.put("grant_type", "authorization_code");
+        form.put("client_id", account.getClientId());
+        form.put("client_secret", account.getClientSecret());
+        form.put("redirect_uri", account.getRedirectUri());
+        form.put("code", code);
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postForm(properties.getTokenUrl(), form);
+
+        if (result.getStatus() != 200) {
+            log.error("授权码换token失败, accountId={}, status={}, body={}",
+                    account, result.getStatus(), result.getBody());
+            return TokenResponse.fail("授权码换token失败, HTTP状态码: " + result.getStatus());
+        }
+
+        return parseAndSaveToken(accountId, result.getBody());
+    }
+
+    /**
+     * 刷新 token(按账号)
+     */
+    public TokenResponse refreshAccessToken(Long accountId) {
+        XsyAccount account = getEnabledAccount(accountId);
+
+        TokenInfo oldToken = getTokenInfo(accountId);
+        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", account.getClientId());
+        form.put("client_secret", account.getClientSecret());
+        form.put("refresh_token", oldToken.getRefreshToken());
+
+        XiaoShouYiHttpClient.HttpResult result = httpClient.postForm(properties.getTokenUrl(), form);
+
+        if (result.getStatus() != 200) {
+            log.error("刷新token失败, accountId={}, status={}, body={}",
+                    account, result.getStatus(), result.getBody());
+            return TokenResponse.fail("刷新token失败, HTTP状态码: " + result.getStatus());
+        }
+
+        return parseAndSaveToken(accountId, result.getBody());
+    }
+
+    /**
+     * 获取有效 token 信息(按账号)
+     *
+     * 逻辑:
+     * 1. access_token 未过期,直接返回
+     * 2. 已过期/即将过期,则尝试刷新
+     * 3. 刷新过程加分布式锁
+     */
+    public TokenInfo getValidTokenInfo(Long accountId) {
+        TokenInfo tokenInfo = getTokenInfo(accountId);
+        long now = System.currentTimeMillis();
+
+        if (isAccessTokenValid(tokenInfo, now)) {
+            return tokenInfo;
+        }
+
+        String lockKey = XiaoShouYiRedisKey.refreshLockKey(accountId);
+        String lockValue = UUID.randomUUID().toString();
+
+        Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
+                lockKey,
+                lockValue,
+                LOCK_EXPIRE_SECONDS,
+                TimeUnit.SECONDS
+        );
+
+        if (Boolean.TRUE.equals(locked)) {
+            try {
+                // 双检,避免别的线程刚刷新完
+                tokenInfo = getTokenInfo(accountId);
+                if (isAccessTokenValid(tokenInfo, System.currentTimeMillis())) {
+                    return tokenInfo;
+                }
+
+                TokenResponse refreshResp = refreshAccessToken(accountId);
+                if (!refreshResp.isSuccess()) {
+                    log.error("刷新token失败, accountId={}, error={}", accountId, refreshResp.getError());
+                    return null;
+                }
+
+                return getTokenInfo(accountId);
+            } finally {
+                String currentLockValue = stringRedisTemplate.opsForValue().get(lockKey);
+                if (lockValue.equals(currentLockValue)) {
+                    stringRedisTemplate.delete(lockKey);
+                }
+            }
+        }
+
+        // 没拿到锁,说明其他线程正在刷新,短暂等待后重取
+        try {
+            Thread.sleep(500L);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+
+        return getTokenInfo(accountId);
+    }
+
+    /**
+     * 获取 Authorization Header
+     */
+    public String getAuthorizationHeader(Long accountId) {
+        TokenInfo tokenInfo = getValidTokenInfo(accountId);
+        if (tokenInfo == null || !StringUtils.hasText(tokenInfo.getAccessToken())) {
+            return null;
+        }
+
+        String tokenType = StringUtils.hasText(tokenInfo.getTokenType())
+                ? tokenInfo.getTokenType()
+                : "Bearer";
+        return tokenType + " " + tokenInfo.getAccessToken();
+    }
+
+    /**
+     * 获取业务 API 地址
+     *
+     * 优先使用 token 中返回的 instanceUri,
+     * 若为空则退回配置中的默认地址
+     */
+    public String getApiBaseUrl(Long accountId) {
+        TokenInfo tokenInfo = getTokenInfo(accountId);
+        if (tokenInfo != null && StringUtils.hasText(tokenInfo.getInstanceUri())) {
+            return normalizeInstanceUri(tokenInfo.getInstanceUri());
+        }
+        return properties.getDefaultApiBaseUrl();
+    }
+
+    /**
+     * 清除某个账号的 token 缓存
+     */
+    public void clearTokenCache(Long accountId) {
+        stringRedisTemplate.delete(XiaoShouYiRedisKey.tokenInfoKey(accountId));
+        stringRedisTemplate.delete(XiaoShouYiRedisKey.accessTokenKey(accountId));
+    }
+
+    /**
+     * 从 Redis 获取 token 信息
+     */
+    public TokenInfo getTokenInfo(Long accountId) {
+        String json = stringRedisTemplate.opsForValue().get(XiaoShouYiRedisKey.tokenInfoKey(accountId));
+        if (!StringUtils.hasText(json)) {
+            return null;
+        }
+        return JSONUtil.toBean(json, TokenInfo.class);
+    }
+
+    /**
+     * 判断 access_token 是否仍然有效
+     */
+    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;
+    }
+
+    /**
+     * 解析 token 并保存到 Redis + 数据库
+     */
+    private TokenResponse parseAndSaveToken(Long accountId, String responseJson) {
+        JSONObject json = JSONUtil.parseObj(responseJson);
+
+        if (!json.containsKey("access_token")) {
+            String error = json.getStr("error_description", json.getStr("error", "未知错误"));
+            log.error("解析token失败, accountId={}, response={}", accountId, 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);
+
+        String instanceUri = json.getStr("instance_uri", properties.getDefaultApiBaseUrl());
+        tokenInfo.setInstanceUri(normalizeInstanceUri(instanceUri));
+
+        // 1. 写 Redis
+        stringRedisTemplate.opsForValue().set(
+                XiaoShouYiRedisKey.tokenInfoKey(accountId),
+                JSONUtil.toJsonStr(tokenInfo),
+                refreshTokenExpiresIn,
+                TimeUnit.SECONDS
+        );
+
+        stringRedisTemplate.opsForValue().set(
+                XiaoShouYiRedisKey.accessTokenKey(accountId),
+                tokenInfo.getAccessToken(),
+                expiresIn,
+                TimeUnit.SECONDS
+        );
+
+        // 2. 回写数据库
+        XsyAccount update = new XsyAccount();
+        update.setId(accountId);
+        update.setInstanceUri(tokenInfo.getInstanceUri());
+        update.setAccessToken(tokenInfo.getAccessToken());
+        update.setRefreshToken(tokenInfo.getRefreshToken());
+        update.setExpiresAt(tokenInfo.getExpiresAt());
+        update.setRefreshTokenExpiresAt(tokenInfo.getRefreshTokenExpiresAt());
+        xsyAccountMapper.updateTokenInfo(update);
+
+        // 3. 返回结果
+        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已保存, accountId={}, instanceUri={}", accountId, tokenInfo.getInstanceUri());
+        return response;
+    }
+
+    /**
+     * 获取启用中的账号配置
+     */
+    private XsyAccount getEnabledAccount(Long accountId) {
+        XsyAccount account = xsyAccountMapper.selectById(accountId);
+        if (account == null) {
+            throw new RuntimeException("销售易账号不存在,accountId=" + accountId);
+        }
+        if (account.getStatus() != null && account.getStatus() == 0) {
+            throw new RuntimeException("销售易账号已禁用,accountId=" + account);
+        }
+        return account;
+    }
+
+    /**
+     * 统一补协议,避免 301 跳转
+     */
+    private String normalizeInstanceUri(String uri) {
+        if (!StringUtils.hasText(uri)) {
+            return properties.getDefaultApiBaseUrl();
+        }
+        if (!uri.startsWith("http")) {
+            return "https://" + uri;
+        }
+        return uri;
+    }
+
+    /**
+     * URL 编码
+     */
+    private String urlEncode(String value) {
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            throw new RuntimeException("URL编码失败", e);
+        }
+    }
+}

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

@@ -0,0 +1,39 @@
+package com.fs.xiaoshouyi.service;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 销售易账号解析器(严格模式)
+ *
+ * 规则:
+ * 1. 必须通过企微销售绑定的销售易账号来调用
+ * 2. 未绑定直接报错
+ * 3. 不启用默认账号兜底
+ */
+@Component
+@RequiredArgsConstructor
+public class XsyAccountResolver {
+
+    private final XsyUserBindService xsyUserBindService;
+
+    /**
+     * 根据企微销售ID解析销售易账号ID
+     *
+     * @param companyUserId 企微销售ID
+     * @return 销售易账号ID
+     */
+    public Long resolveAccountId(Long companyUserId) {
+        if (companyUserId == null) {
+            throw new RuntimeException("companyUserId不能为空");
+        }
+
+        Long accountId = xsyUserBindService.getBindAccountId(companyUserId);
+
+        if (accountId == null) {
+            throw new RuntimeException("当前企微销售未绑定销售易账号,请先绑定后再操作");
+        }
+
+        return accountId;
+    }
+}

+ 43 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/XsyAccountService.java

@@ -0,0 +1,43 @@
+package com.fs.xiaoshouyi.service;
+
+import com.fs.xiaoshouyi.domain.XsyAccount;
+
+import java.util.List;
+
+public interface XsyAccountService {
+
+    /**
+     * 分页查询账号列表
+     */
+    List<XsyAccount> selectList(XsyAccount query);
+
+    /**
+     * 查询列表根据id列表
+     */
+    List<XsyAccount> selectListByIds(List<Long> ids);
+
+    /**
+     * 查询单个
+     */
+    XsyAccount selectById(Long id);
+
+    /**
+     * 新增
+     */
+    int insert(XsyAccount account);
+
+    /**
+     * 修改
+     */
+    int update(XsyAccount account);
+
+    /**
+     * 删除
+     */
+    int deleteById(Long id);
+
+    /**
+     * 获取授权URL
+     */
+    String getAuthUrl(Long accountId);
+}

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

@@ -0,0 +1,14 @@
+package com.fs.xiaoshouyi.service;
+
+import java.util.List;
+
+public interface XsyCompanyBindService {
+
+    void bind(Long companyId, Long accountId);
+
+    void unbind(Long companyId, Long accountId);
+
+    List<Long> getAccountIdsByCompanyId(Long companyId);
+
+    boolean isCompanyBoundAccount(Long companyId, Long accountId);
+}

+ 60 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/XsyUserBindService.java

@@ -0,0 +1,60 @@
+package com.fs.xiaoshouyi.service;
+
+import com.fs.xiaoshouyi.mapper.XsyUserBindMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+/**
+ * 企微销售与销售易账号绑定 Service
+ */
+@Service
+@RequiredArgsConstructor
+public class XsyUserBindService {
+
+    private final XsyUserBindMapper xsyUserBindMapper;
+
+    /**
+     * 绑定销售易账号
+     *
+     * @param companyUserId 企微销售ID
+     * @param accountId     销售易账号ID
+     */
+    public void bind(Long companyUserId, Long accountId) {
+        if (companyUserId == null) {
+            throw new RuntimeException("companyUserId不能为空");
+        }
+        if (accountId == null) {
+            throw new RuntimeException("accountId不能为空");
+        }
+
+        // 一个企微销售只绑定一个销售易账号
+        xsyUserBindMapper.deleteByCompanyUserId(companyUserId);
+        xsyUserBindMapper.insertBind(companyUserId, accountId);
+    }
+
+    /**
+     * 解绑销售易账号
+     *
+     * @param companyUserId 企微销售ID
+     */
+    public void unbind(Long companyUserId) {
+        if (companyUserId == null) {
+            throw new RuntimeException("companyUserId不能为空");
+        }
+
+        xsyUserBindMapper.deleteByCompanyUserId(companyUserId);
+    }
+
+    /**
+     * 查询绑定的销售易账号ID
+     *
+     * @param companyUserId 企微销售ID
+     * @return 销售易账号ID
+     */
+    public Long getBindAccountId(Long companyUserId) {
+        if (companyUserId == null) {
+            return null;
+        }
+        return xsyUserBindMapper.selectAccountIdByCompanyUserId(companyUserId);
+    }
+}

+ 52 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/impl/XsyAccountServiceImpl.java

@@ -0,0 +1,52 @@
+package com.fs.xiaoshouyi.service.impl;
+
+import com.fs.xiaoshouyi.domain.XsyAccount;
+import com.fs.xiaoshouyi.mapper.XsyAccountMapper;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import com.fs.xiaoshouyi.service.XsyAccountService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class XsyAccountServiceImpl implements XsyAccountService {
+
+    private final XsyAccountMapper xsyAccountMapper;
+    private final XiaoShouYiOAuthService oAuthService;
+
+    @Override
+    public List<XsyAccount> selectList(XsyAccount query) {
+        return xsyAccountMapper.selectList(query);
+    }
+
+    @Override
+    public List<XsyAccount> selectListByIds(List<Long> ids) {
+        return xsyAccountMapper.selectListByIds(ids);
+    }
+
+    @Override
+    public XsyAccount selectById(Long id) {
+        return xsyAccountMapper.selectById(id);
+    }
+
+    @Override
+    public int insert(XsyAccount account) {
+        return xsyAccountMapper.insert(account);
+    }
+
+    @Override
+    public int update(XsyAccount account) {
+        return xsyAccountMapper.update(account);
+    }
+
+    @Override
+    public int deleteById(Long id) {
+        return xsyAccountMapper.deleteById(id);
+    }
+
+    @Override
+    public String getAuthUrl(Long accountId) { return oAuthService.buildAuthUrl(accountId);
+    }
+}

+ 43 - 0
fs-service/src/main/java/com/fs/xiaoshouyi/service/impl/XsyCompanyBindServiceImpl.java

@@ -0,0 +1,43 @@
+package com.fs.xiaoshouyi.service.impl;
+
+import com.fs.xiaoshouyi.mapper.XsyCompanyBindMapper;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class XsyCompanyBindServiceImpl implements XsyCompanyBindService {
+
+    private final XsyCompanyBindMapper mapper;
+
+    @Override
+    public void bind(Long companyId, Long accountId) {
+        if (companyId == null) {
+            throw new RuntimeException("companyId不能为空");
+        }
+        if (accountId == null) {
+            throw new RuntimeException("accountId不能为空");
+        }
+        if (!isCompanyBoundAccount(companyId, accountId)) {
+            mapper.insertBind(companyId, accountId);
+        }
+    }
+
+    @Override
+    public void unbind(Long companyId, Long accountId) {
+        mapper.deleteBind(companyId, accountId);
+    }
+
+    @Override
+    public List<Long> getAccountIdsByCompanyId(Long companyId) {
+        return mapper.selectAccountIdsByCompanyId(companyId);
+    }
+
+    @Override
+    public boolean isCompanyBoundAccount(Long companyId, Long accountId) {
+        return mapper.existsBind(companyId, accountId) > 0;
+    }
+}

+ 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;
+
+}

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

@@ -107,7 +107,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         GROUP_CONCAT(fu.nick_name) bindUser,
         cfu.`status` bindStatus,
         u.cid_server_id,
-        u.ai_sip_call_user_id
+        u.ai_sip_call_user_id,
+        xub.account_id
         from
         company_user u
         left join
@@ -115,6 +116,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         LEFT JOIN
         company_fs_user cfu on u.user_id=cfu.company_user_id
         LEFT JOIN fs_user fu on cfu.fs_user_id=fu.user_id
+        LEFT JOIN xsy_user_bind xub on xub.company_user_id=u.user_id
         where
         u.del_flag = '0'
         <if test="userId != null ">

+ 146 - 0
fs-service/src/main/resources/mapper/xiaoshouyi/XsyAccountMapper.xml

@@ -0,0 +1,146 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+
+<mapper namespace="com.fs.xiaoshouyi.mapper.XsyAccountMapper">
+
+    <select id="selectList" resultType="com.fs.xiaoshouyi.domain.XsyAccount">
+
+        select
+        id,
+        account_name as accountName,
+        client_id as clientId,
+        client_secret as clientSecret,
+        redirect_uri as redirectUri,
+        instance_uri as instanceUri,
+        access_token as accessToken,
+        refresh_token as refreshToken,
+        expires_at as expiresAt,
+        refresh_token_expires_at as refreshTokenExpiresAt,
+        status,
+        create_time as createTime,
+        update_time as updateTime
+        from xsy_account
+
+        <where>
+
+            <if test="clientId != null and clientId != ''">
+                and client_id like concat('%', #{clientId}, '%')
+            </if>
+
+            <if test="status != null">
+                and status = #{status}
+            </if>
+
+            <if test="accountName != null and accountName != ''">
+                and account_name like concat('%', #{accountName}, '%')
+            </if>
+
+        </where>
+
+        order by id desc
+
+    </select>
+
+    <!-- 查询单个 -->
+    <select id="selectById" resultType="com.fs.xiaoshouyi.domain.XsyAccount">
+        select
+            id,
+            account_name as accountName,
+            client_id as clientId,
+            client_secret as clientSecret,
+            redirect_uri as redirectUri,
+            instance_uri as instanceUri,
+            access_token as accessToken,
+            refresh_token as refreshToken,
+            expires_at as expiresAt,
+            refresh_token_expires_at as refreshTokenExpiresAt,
+            status,
+            create_time as createTime,
+            update_time as updateTime
+        from xsy_account
+        where id = #{id}
+    </select>
+    <select id="selectListByIds" resultType="com.fs.xiaoshouyi.domain.XsyAccount">
+        select
+            id,
+            account_name as accountName,
+            client_id as clientId,
+            client_secret as clientSecret,
+            redirect_uri as redirectUri,
+            instance_uri as instanceUri,
+            access_token as accessToken,
+            refresh_token as refreshToken,
+            expires_at as expiresAt,
+            refresh_token_expires_at as refreshTokenExpiresAt,
+            status,
+            create_time as createTime,
+            update_time as updateTime
+        from xsy_account
+        where id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <!-- 新增 -->
+    <insert id="insert" parameterType="com.fs.xiaoshouyi.domain.XsyAccount">
+        insert into xsy_account (
+            id,
+            account_name,
+            client_id,
+            client_secret,
+            redirect_uri,
+            status,
+            create_time
+        ) values (
+             #{id},
+             #{accountName},
+             #{clientId},
+             #{clientSecret},
+             #{redirectUri},
+             #{status},
+             now()
+                 )
+    </insert>
+
+    <!-- 修改 -->
+    <update id="update" parameterType="com.fs.xiaoshouyi.domain.XsyAccount">
+        update xsy_account
+        <set>
+            <if test="accountName != null">account_name = #{accountName},</if>
+            <if test="clientId != null">client_id = #{clientId},</if>
+            <if test="clientSecret != null">client_secret = #{clientSecret},</if>
+            <if test="redirectUri != null">redirect_uri = #{redirectUri},</if>
+            <if test="instanceUri != null">instance_uri = #{instanceUri},</if>
+            <if test="accessToken != null">access_token = #{accessToken},</if>
+            <if test="refreshToken != null">refresh_token = #{refreshToken},</if>
+            <if test="expiresAt != null">expires_at = #{expiresAt},</if>
+            <if test="refreshTokenExpiresAt != null">refresh_token_expires_at = #{refreshTokenExpiresAt},</if>
+            <if test="status != null">status = #{status},</if>
+            update_time = now()
+        </set>
+        where id = #{id}
+    </update>
+
+    <!-- 删除 -->
+    <delete id="deleteById">
+        delete from xsy_account where id = #{id}
+    </delete>
+
+    <update id="updateTokenInfo" parameterType="com.fs.xiaoshouyi.domain.XsyAccount">
+        update xsy_account
+        <set>
+            <if test="instanceUri != null">instance_uri = #{instanceUri},</if>
+            <if test="accessToken != null">access_token = #{accessToken},</if>
+            <if test="refreshToken != null">refresh_token = #{refreshToken},</if>
+            <if test="expiresAt != null">expires_at = #{expiresAt},</if>
+            <if test="refreshTokenExpiresAt != null">refresh_token_expires_at = #{refreshTokenExpiresAt},</if>
+            update_time = now()
+        </set>
+        where client_id = #{id}
+
+    </update>
+
+</mapper>

+ 33 - 0
fs-service/src/main/resources/mapper/xiaoshouyi/XsyCompanyAccountMapper.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+
+<mapper namespace="com.fs.xiaoshouyi.mapper.XsyCompanyBindMapper">
+
+    <insert id="insertBind">
+        insert into xsy_company_bind(company_id, account_id, create_time)
+        values(#{companyId}, #{accountId}, now())
+    </insert>
+
+    <delete id="deleteBind">
+        delete from xsy_company_bind
+        where company_id = #{companyId}
+          and account_id = #{accountId}
+    </delete>
+
+    <select id="selectAccountIdsByCompanyId" resultType="java.lang.Long">
+        select account_id
+        from xsy_company_bind
+        where company_id = #{companyId}
+        order by id desc
+    </select>
+
+    <select id="existsBind" resultType="int">
+        select count(1)
+        from xsy_company_bind
+        where company_id = #{companyId}
+          and account_id = #{accountId}
+    </select>
+
+</mapper>

+ 28 - 0
fs-service/src/main/resources/mapper/xiaoshouyi/XsyUserBindMapper.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+
+<mapper namespace="com.fs.xiaoshouyi.mapper.XsyUserBindMapper">
+
+    <select id="selectAccountIdByCompanyUserId" resultType="java.lang.Long">
+        select account_id
+        from xsy_user_bind
+        where company_user_id = #{companyUserId}
+        limit 1
+    </select>
+
+    <delete id="deleteByCompanyUserId">
+        delete from xsy_user_bind
+        where company_user_id = #{companyUserId}
+    </delete>
+
+    <insert id="insertBind">
+        insert into xsy_user_bind (
+            company_user_id,
+            account_id,
+            create_time
+        ) values (#{companyUserId},#{accountId},now())
+    </insert>
+
+</mapper>