Browse Source

飞书账号管理

xw 2 days ago
parent
commit
b414ab1bfa
22 changed files with 864 additions and 230 deletions
  1. 215 0
      fs-company/src/main/java/com/fs/company/controller/feishu/FeishuAccountController.java
  2. 2 1
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  3. 3 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseLink.java
  4. 3 1
      fs-service/src/main/java/com/fs/course/mapper/FsCourseLinkMapper.java
  5. 3 0
      fs-service/src/main/java/com/fs/course/param/FsCourseLinkMiniParam.java
  6. 45 3
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  7. 12 1
      fs-service/src/main/java/com/fs/feishu/domain/FeishuAccount.java
  8. 21 9
      fs-service/src/main/java/com/fs/feishu/mapper/FeishuAccountMapper.java
  9. 13 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuClientHolder.java
  10. 84 60
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  11. 131 87
      fs-service/src/main/java/com/fs/feishu/service/FeishuClientPool.java
  12. 30 55
      fs-service/src/main/java/com/fs/feishu/service/FeishuDocApiService.java
  13. 37 0
      fs-service/src/main/java/com/fs/feishu/service/IFeishuAccountService.java
  14. 130 0
      fs-service/src/main/java/com/fs/feishu/service/impl/FeishuAccountServiceImpl.java
  15. 3 0
      fs-service/src/main/java/com/fs/qw/domain/QwUser.java
  16. 4 0
      fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java
  17. 4 0
      fs-service/src/main/java/com/fs/qw/vo/QwSopTempSetting.java
  18. 7 0
      fs-service/src/main/java/com/fs/qw/vo/QwUserVO.java
  19. 5 4
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  20. 5 1
      fs-service/src/main/resources/mapper/course/FsCourseLinkMapper.xml
  21. 104 7
      fs-service/src/main/resources/mapper/his/FeishuAccountMapper.xml
  22. 3 1
      fs-service/src/main/resources/mapper/qw/QwUserMapper.xml

+ 215 - 0
fs-company/src/main/java/com/fs/company/controller/feishu/FeishuAccountController.java

@@ -0,0 +1,215 @@
+package com.fs.company.controller.feishu;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.exception.CustomException;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.feishu.domain.FeishuAccount;
+import com.fs.feishu.service.IFeishuAccountService;
+import com.fs.framework.security.SecurityUtils;
+import com.github.pagehelper.PageHelper;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/feishu/account")
+public class FeishuAccountController extends BaseController {
+
+    @Autowired
+    private IFeishuAccountService feishuAccountService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    /** 我的飞书账号列表(当前用户数据) */
+    @GetMapping("/my/list")
+    public TableDataInfo myList(@RequestParam(required = false) Integer status,
+                                @RequestParam(required = false) String phone,
+                                @RequestParam(required = false) String appId,
+                                @RequestParam(required = false, defaultValue = "1") Integer pageNum,
+                                @RequestParam(required = false, defaultValue = "10") Integer pageSize) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        Map<String, Object> params = buildQueryParams(user.getCompanyId(), user.getUserId(), status, phone, appId, null);
+        PageHelper.startPage(pageNum, pageSize);
+        List<FeishuAccount> list = feishuAccountService.selectFeishuAccountList(params);
+        return getDataTable(list);
+    }
+
+    /** 企业飞书账号列表(管理员查看/分配) */
+    @GetMapping("/company/list")
+    public TableDataInfo companyList(@RequestParam(required = false) Integer status,
+                                     @RequestParam(required = false) String phone,
+                                     @RequestParam(required = false) String appId,
+                                     @RequestParam(required = false) String nickName,
+                                     @RequestParam(required = false) Long companyUserId,
+                                     @RequestParam(required = false, defaultValue = "1") Integer pageNum,
+                                     @RequestParam(required = false, defaultValue = "10") Integer pageSize) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        Map<String, Object> params = buildQueryParams(user.getCompanyId(), companyUserId, status, phone, appId, nickName);
+        PageHelper.startPage(pageNum, pageSize);
+        List<FeishuAccount> list = feishuAccountService.selectCompanyFeishuAccountList(params);
+        return getDataTable(list);
+    }
+
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam(required = false) Integer status,
+                              @RequestParam(required = false) String phone,
+                              @RequestParam(required = false) String appId,
+                              @RequestParam(required = false) Long companyUserId,
+                              @RequestParam(required = false, defaultValue = "1") Integer pageNum,
+                              @RequestParam(required = false, defaultValue = "10") Integer pageSize) {
+        return myList(status, phone, appId, pageNum, pageSize);
+    }
+
+    @GetMapping("/options")
+    public AjaxResult options(@RequestParam(required = false) Long companyUserId) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        Long targetUserId = companyUserId != null ? companyUserId : user.getUserId();
+        List<FeishuAccount> list = feishuAccountService.selectEnabledByCompanyUserId(targetUserId);
+        return AjaxResult.success(list);
+    }
+
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        FeishuAccount account = feishuAccountService.selectFeishuAccountById(id);
+        feishuAccountService.validateCompanyScope(account, user.getCompanyId());
+        return AjaxResult.success(account);
+    }
+
+    /** 我的飞书账号 - 新增 */
+    @Log(title = "我的飞书账号", businessType = BusinessType.INSERT)
+    @PostMapping("/my")
+    public AjaxResult addMy(@RequestBody FeishuAccount account) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        account.setCompanyId(user.getCompanyId());
+        account.setCompanyUserId(user.getUserId());
+        validateAccountPayload(account, true);
+        return toAjax(feishuAccountService.insertFeishuAccount(account));
+    }
+
+    /** 企业飞书账号 - 新增并分配销售 */
+    @Log(title = "飞书账号管理", businessType = BusinessType.INSERT)
+    @PostMapping("/company")
+    public AjaxResult addCompany(@RequestBody FeishuAccount account) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        account.setCompanyId(user.getCompanyId());
+        if (account.getCompanyUserId() == null) {
+            throw new CustomException("请选择销售人员");
+        }
+        requireCompanyUser(account.getCompanyUserId(), user.getCompanyId());
+        validateAccountPayload(account, true);
+        return toAjax(feishuAccountService.insertFeishuAccount(account));
+    }
+
+    @Log(title = "Feishu account", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FeishuAccount account) {
+        return addMy(account);
+    }
+
+    /** 我的飞书账号 - 编辑 */
+    @Log(title = "我的飞书账号", businessType = BusinessType.UPDATE)
+    @PutMapping("/my")
+    public AjaxResult editMy(@RequestBody FeishuAccount account) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        FeishuAccount existing = feishuAccountService.selectFeishuAccountById(account.getId());
+        feishuAccountService.validateOwnership(existing, user.getCompanyId(), user.getUserId());
+        account.setCompanyId(existing.getCompanyId());
+        account.setCompanyUserId(existing.getCompanyUserId());
+        fillSecretIfBlank(account, existing);
+        return toAjax(feishuAccountService.updateFeishuAccount(account));
+    }
+
+    /** 企业飞书账号 - 编辑/重新分配 */
+    @Log(title = "飞书账号管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/company")
+    public AjaxResult editCompany(@RequestBody FeishuAccount account) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        FeishuAccount existing = feishuAccountService.selectFeishuAccountById(account.getId());
+        feishuAccountService.validateCompanyScope(existing, user.getCompanyId());
+        if (account.getCompanyUserId() != null) {
+            requireCompanyUser(account.getCompanyUserId(), user.getCompanyId());
+        } else {
+            account.setCompanyUserId(existing.getCompanyUserId());
+        }
+        account.setCompanyId(existing.getCompanyId());
+        fillSecretIfBlank(account, existing);
+        return toAjax(feishuAccountService.updateFeishuAccount(account));
+    }
+
+    @Log(title = "Feishu account", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FeishuAccount account) {
+        return editMy(account);
+    }
+
+    @Log(title = "我的飞书账号", businessType = BusinessType.DELETE)
+    @DeleteMapping("/my/{id}")
+    public AjaxResult removeMy(@PathVariable Long id) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        FeishuAccount existing = feishuAccountService.selectFeishuAccountById(id);
+        feishuAccountService.validateOwnership(existing, user.getCompanyId(), user.getUserId());
+        return toAjax(feishuAccountService.deleteFeishuAccountById(id));
+    }
+
+    @Log(title = "飞书账号管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/company/{id}")
+    public AjaxResult removeCompany(@PathVariable Long id) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        FeishuAccount existing = feishuAccountService.selectFeishuAccountById(id);
+        feishuAccountService.validateCompanyScope(existing, user.getCompanyId());
+        return toAjax(feishuAccountService.deleteFeishuAccountById(id));
+    }
+
+    @Log(title = "Feishu account", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@PathVariable Long id) {
+        return removeMy(id);
+    }
+
+    private Map<String, Object> buildQueryParams(Long companyId, Long companyUserId, Integer status,
+                                                 String phone, String appId, String nickName) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("companyId", companyId);
+        if (companyUserId != null) {
+            params.put("companyUserId", companyUserId);
+        }
+        params.put("status", status);
+        params.put("phone", phone);
+        params.put("appId", appId);
+        params.put("nickName", nickName);
+        return params;
+    }
+
+    private void validateAccountPayload(FeishuAccount account, boolean requireSecret) {
+        if (StringUtils.isBlank(account.getAppId())) {
+            throw new CustomException("AppId不能为空");
+        }
+        if (requireSecret && StringUtils.isBlank(account.getAppSecret())) {
+            throw new CustomException("AppSecret不能为空");
+        }
+    }
+
+    private void fillSecretIfBlank(FeishuAccount account, FeishuAccount existing) {
+        if (StringUtils.isBlank(account.getAppSecret())) {
+            account.setAppSecret(existing.getAppSecret());
+        }
+    }
+
+    private void requireCompanyUser(Long companyUserId, Long companyId) {
+        CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+        if (companyUser == null || !companyId.equals(companyUser.getCompanyId())) {
+            throw new CustomException("所选人员不属于当前企业");
+        }
+    }
+}

+ 2 - 1
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -1605,7 +1605,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                             qwUserId, companyUserId, companyId, externalIdLong, cachedCourseConfig);
                     String shortLink = feiShuLinkMap.get("link");
 
-                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyIdLong, courseId, companyUserIdLong, shortLink);
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyIdLong, courseId, companyUserIdLong, shortLink, null);
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         setting.setLinkUrl(feishuLink);
                     }
@@ -1912,6 +1912,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                                         String companyUserId, String companyId, Long externalId, CourseConfig config) {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
                 companyUserId, companyId, externalId.toString(), 3);
+        link.setFeishuAccountId(setting.getFeishuAccountId());
 
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);

+ 3 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseLink.java

@@ -69,4 +69,7 @@ public class FsCourseLink extends BaseEntity
     @ApiModelProperty(value = "项目唯一标识(PS:MYHK)")
     private String projectCode;
 
+    @ApiModelProperty(value = "飞书应用账号id")
+    private Long feishuAccountId;
+
 }

+ 3 - 1
fs-service/src/main/java/com/fs/course/mapper/FsCourseLinkMapper.java

@@ -68,7 +68,9 @@ public interface FsCourseLinkMapper
      */
     public int deleteFsCourseLinkByLinkIds(Long[] linkIds);
 
-    @Select("select link, real_link,chat_id, update_time,company_user_id from fs_course_link where link = #{suffix} order by link_id desc limit 1 ")
+    @Select("select link_id, chat_id, is_room, link, real_link, link_type, create_time, update_time, " +
+            "company_id, company_user_id, qw_user_id, video_id, corp_id, course_id, qw_external_id, feishu_account_id " +
+            "from fs_course_link where link = #{suffix} order by link_id desc limit 1 ")
     FsCourseLink selectFsCourseLinkByLink(@Param("suffix") String suffix);
 
     void insertFsCourseLinkBatch(@Param("courseLinks")List<FsCourseLink> courseLinkToInsert);

+ 3 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseLinkMiniParam.java

@@ -26,4 +26,7 @@ public class FsCourseLinkMiniParam {
     */
     private Long fsUserId;
 
+    /** 飞书应用账号 id(contentType=17 发课时必选) */
+    private Long feishuAccountId;
+
 }

+ 45 - 3
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -52,6 +52,7 @@ import com.fs.course.service.*;
 import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.*;
 import com.fs.feishu.service.FeiShuService;
+import com.fs.feishu.service.IFeishuAccountService;
 import com.fs.enums.ExceptionCodeEnum;
 import com.fs.his.config.AppConfig;
 import com.fs.his.domain.FsUser;
@@ -292,6 +293,9 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private FeiShuService feiShuService;
 
+    @Autowired
+    private IFeishuAccountService feishuAccountService;
+
 
     @Autowired
     private IFsCourseLinkService linkService;
@@ -805,6 +809,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     }*/
     @Override
     public R isAddKf(FsUserCourseVideoAddKfUParam param) {
+        fillLinkMetaFromDb(param);
         logger.info("【判断添加客服】:{}", param);
         //查询用户
         FsUser fsUser = fsUserMapper.selectFsUserByUserId(param.getUserId());
@@ -1180,6 +1185,41 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         return null;
     }
 
+    /**
+     * 从短链记录补全 corpId / qwExternalId / qwUserId 等参数,避免 URL 缺字段导致校验失败。
+     */
+    private void fillLinkMetaFromDb(FsUserCourseVideoAddKfUParam param) {
+        if (param == null || StringUtils.isBlank(param.getLink())) {
+            return;
+        }
+        if (StringUtils.isNotBlank(param.getCorpId()) && param.getQwExternalId() != null
+                && StringUtils.isNotBlank(param.getQwUserId()) && param.getLinkType() != null) {
+            return;
+        }
+        FsCourseLink courseLink = courseLinkMapper.selectFsCourseLinkByLink(param.getLink());
+        if (courseLink == null) {
+            return;
+        }
+        if (StringUtils.isBlank(param.getCorpId()) && StringUtils.isNotBlank(courseLink.getCorpId())) {
+            param.setCorpId(courseLink.getCorpId());
+        }
+        if (param.getQwExternalId() == null && courseLink.getQwExternalId() != null) {
+            param.setQwExternalId(courseLink.getQwExternalId());
+        }
+        if (StringUtils.isBlank(param.getQwUserId()) && courseLink.getQwUserId() != null) {
+            param.setQwUserId(String.valueOf(courseLink.getQwUserId()));
+        }
+        if (param.getLinkType() == null && courseLink.getLinkType() != null) {
+            param.setLinkType(courseLink.getLinkType());
+        }
+        if (param.getIsRoom() == null && courseLink.getIsRoom() != null) {
+            param.setIsRoom(courseLink.getIsRoom());
+        }
+        if (StringUtils.isBlank(param.getChatId()) && StringUtils.isNotBlank(courseLink.getChatId())) {
+            param.setChatId(courseLink.getChatId());
+        }
+    }
+
     private R handleExt(FsUserCourseVideoAddKfUParam param, FsUser fsUser, String msg, boolean oneCompanyCourse) {
         //暂时注释应急短链逻辑
 //        if (param.getLinkType()!=null&&param.getLinkType()==1){
@@ -3607,10 +3647,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
         addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId(), 2);
 
-        Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config);
+        Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config, null);
         String shortCode = feishuH5Link.get("link");
 
-        String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortCode);
+        String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortCode, null);
 
         JSONObject news = new JSONObject(true);
         news.put("linkTitle", param.getTitle());
@@ -5546,9 +5586,11 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
     private Map<String, String> createFeishuH5Link(String corpId, Date sendTime,
                                                    Integer courseId, Integer videoId, String qwUserId,
-                                                   Long companyUserId, Long companyId, Long externalId, CourseConfig config) {
+                                                   Long companyUserId, Long companyId, Long externalId, CourseConfig config,
+                                                   Long feishuAccountId) {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, null);
+        link.setFeishuAccountId(feishuAccountId);
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(courseMap);

+ 12 - 1
fs-service/src/main/java/com/fs/feishu/domain/FeishuAccount.java

@@ -2,6 +2,7 @@ package com.fs.feishu.domain;
 
 import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
+
 import java.util.Date;
 
 @Data
@@ -10,9 +11,19 @@ public class FeishuAccount {
     private String appId;
     private String appSecret;
     private Integer status;
+    private Long companyId;
+    private Long companyUserId;
+    private String phone;
+    private Long numberUse;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date updateTime;
     private String errorMsg;
-}
+
+    /** 所属销售昵称(列表展示) */
+    private String nickName;
+
+    /** 所属销售账号(列表展示) */
+    private String userName;
+}

+ 21 - 9
fs-service/src/main/java/com/fs/feishu/mapper/FeishuAccountMapper.java

@@ -5,18 +5,30 @@ import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
+import java.util.Map;
 
 @Mapper
 public interface FeishuAccountMapper {
-    /**
-     * 查询所有启用的账号
-     */
-    List<FeishuAccount> selectEnabledAccounts();
-
-    /**
-     * 更新账号状态和错误信息
-     */
+
+    FeishuAccount selectById(@Param("id") Long id);
+
+    List<FeishuAccount> selectEnabledByCompanyUserId(@Param("companyUserId") Long companyUserId);
+
+    List<FeishuAccount> selectListByMap(Map<String, Object> params);
+
+    List<FeishuAccount> selectCompanyListByMap(Map<String, Object> params);
+
+    int countEnabledByCompanyUserId(@Param("companyUserId") Long companyUserId);
+
+    int insert(FeishuAccount account);
+
+    int update(FeishuAccount account);
+
     int updateStatusAndErrorMsg(@Param("id") Long id,
                                 @Param("status") Integer status,
                                 @Param("errorMsg") String errorMsg);
-}
+
+    int incrementNumberUse(@Param("id") Long id);
+
+    int deleteById(@Param("id") Long id);
+}

+ 13 - 0
fs-service/src/main/java/com/fs/feishu/model/FeishuClientHolder.java

@@ -0,0 +1,13 @@
+package com.fs.feishu.model;
+
+import com.lark.oapi.Client;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class FeishuClientHolder {
+    private Client client;
+    private Long accountId;
+    private Long companyUserId;
+}

+ 84 - 60
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -8,10 +8,12 @@ import com.fs.course.domain.FsCourseLink;
 import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.feishu.domain.FeishuAccount;
+import com.fs.feishu.mapper.FeishuAccountMapper;
+import com.fs.feishu.service.IFeishuAccountService;
+import com.fs.feishu.model.FeishuClientHolder;
 import com.fs.feishu.model.FeishuLinkParam;
-import com.fs.feishu.util.FeishuErrorCode;
 import com.fs.system.service.ISysConfigService;
-import com.lark.oapi.Client;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -20,12 +22,12 @@ import org.springframework.stereotype.Component;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
 /**
- * 飞书业务编排层:
- * 负责课程链接和注册链接的完整业务流程,包括缓存、URL 构建、调用底层 API 服务等。
+ * 飞书业务编排层:按销售绑定的飞书账号创建文档链接。
  */
 @Component
 @Slf4j
@@ -43,37 +45,46 @@ public class FeiShuService {
     private FeishuClientPool clientPool;
     @Autowired
     private FeishuDocApiService docApiService;
+    @Autowired
+    private FeishuAccountMapper feishuAccountMapper;
+    @Autowired
+    private IFeishuAccountService feishuAccountService;
 
     /**
-     * 001 生成飞书初始链接(注册授权链接)
-     * 每次 shortCode 唯一
+     * 生成飞书初始链接(注册授权链接)。
      */
     public String getFeishuRegisterLink(Long videoId, Long companyId, Long courseId,
-                                        Long companyUserId, String shortLink) {
-        // 检查功能开关
+                                        Long companyUserId, String shortLink, Long feishuAccountId) {
         CourseConfig config = getCourseConfig();
         if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
             throw new CustomException("未开启飞书看课");
         }
+        if (companyUserId == null) {
+            throw new CustomException("销售ID不能为空");
+        }
+        feishuAccountId = feishuAccountService.resolveSendAccountId(companyUserId, feishuAccountId);
 
         FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
         if (userCourseVideo == null) {
             throw new CustomException("视频不存在: " + videoId);
         }
 
-        int maxRetries = clientPool.getAvailableCount();
-        for (int i = 0; i < maxRetries; i++) {
-            Client client = clientPool.getClient();
+        List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
+        CustomException lastError = null;
+        for (FeishuAccount account : accounts) {
+            FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             try {
-                String documentId = docApiService.createDocument(client, userCourseVideo.getTitle() + "-注册");
+                String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle() + "-注册");
                 String authUrl = buildAuthLink(companyId, companyUserId, courseId, videoId, shortLink);
-                docApiService.createTextLinkBlock(client, documentId, authUrl);
-                docApiService.changeDocumentPermissions(client, documentId);
+                docApiService.createTextLinkBlock(holder, documentId, authUrl);
+                docApiService.changeDocumentPermissions(holder, documentId);
+                feishuAccountMapper.incrementNumberUse(account.getId());
+                updateCourseLinkFeishuAccount(shortLink, account.getId());
                 return "https://www.feishu.cn/docx/" + documentId;
             } catch (CustomException e) {
-                // 如果是账号禁用异常,继续尝试下一个账号
-                if (e.getMessage().contains("不可用") || e.getMessage().contains("已被自动禁用")) {
-                    log.warn("账号不可用,尝试下一个");
+                lastError = e;
+                if (isAccountUnavailable(e)) {
+                    log.warn("销售[{}]飞书账号[{}]不可用,尝试下一个", companyUserId, account.getId());
                     continue;
                 }
                 throw e;
@@ -81,23 +92,35 @@ public class FeiShuService {
                 throw new CustomException("创建飞书注册链接失败: " + e.getMessage(), e);
             }
         }
-        throw new CustomException("所有飞书账号均不可用,请联系管理员");
+        if (lastError != null) {
+            throw lastError;
+        }
+        throw new CustomException("当前销售没有可用的飞书账号,请先配置或更换账号");
     }
+
     /**
-     * 002 生成飞书课程链接
-     * 相同参数可能重复,使用 Redis 缓存避免重复创建文档
+     * 生成飞书课程链接(授权后看课文档)。
      */
     public String getFeishuCourseNewLink(FeishuLinkParam param) {
         if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
             throw new CustomException("参数不能为空");
         }
+        if (StringUtils.isBlank(param.getLink())) {
+            throw new CustomException("飞书课程短链不能为空");
+        }
 
-        String cacheKey = String.format("feishu:course_link:%d:%d:%d:%d:%d:%s",
+        FsCourseLink courseLink = requireCourseLink(param.getLink());
+        Long feishuAccountId = courseLink.getFeishuAccountId();
+        if (feishuAccountId == null) {
+            feishuAccountId = feishuAccountService.resolveSendAccountId(param.getCompanyUserId(), null);
+            updateCourseLinkFeishuAccount(param.getLink(), feishuAccountId);
+        }
+
+        String cacheKey = String.format("feishu:course_link:%d:%d:%d:%d:%d:%s:%d",
                 param.getCompanyId(), param.getCompanyUserId(),
                 param.getCourseId(), param.getVideoId(),
-                param.getUserId(), param.getLink());
+                param.getUserId(), param.getLink(), feishuAccountId);
 
-        // 缓存命中直接返回
         String cached = redisCache.getCacheObject(cacheKey);
         if (StringUtils.isNotBlank(cached)) {
             return cached;
@@ -107,40 +130,46 @@ public class FeiShuService {
         if (userCourseVideo == null) {
             throw new CustomException("视频不存在: " + param.getVideoId());
         }
-        //获取该视频是否有答题
+
         String questionBankId = userCourseVideo.getQuestionBankId();
         boolean hasQuestion = StringUtils.isNotBlank(questionBankId) && !"null".equals(questionBankId);
-        Integer questionFlag = hasQuestion ? 1 : 0;//0:无题 1:有题
-        int maxRetries = clientPool.getAvailableCount();
-        for (int i = 0; i < maxRetries; i++) {
-            Client client = clientPool.getClient();
-            try {
-                String documentId = docApiService.createDocument(client, userCourseVideo.getTitle());
-                String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(),
-                        param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink(),questionFlag);
-                docApiService.createIframeBlock(client, documentId, url);
-                docApiService.changeDocumentPermissions(client, documentId);
-
-                String resultUrl = "https://www.feishu.cn/docx/" + documentId;
-                redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
-                return resultUrl;
-            } catch (CustomException e) {
-                if (e.getMessage().contains("不可用") || e.getMessage().contains("已被自动禁用")) {
-                    log.warn("账号不可用,尝试下一个");
-                    continue;
-                }
-                throw e;
-            } catch (Exception e) {
-                throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
-            }
+        Integer questionFlag = hasQuestion ? 1 : 0;
+
+        FeishuClientHolder holder = clientPool.getClientByAccountId(feishuAccountId);
+        try {
+            String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle());
+            String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(),
+                    param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink(), questionFlag);
+            docApiService.createIframeBlock(holder, documentId, url);
+            docApiService.changeDocumentPermissions(holder, documentId);
+
+            String resultUrl = "https://www.feishu.cn/docx/" + documentId;
+            redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
+            feishuAccountMapper.incrementNumberUse(feishuAccountId);
+            return resultUrl;
+        } catch (CustomException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
         }
-        throw new CustomException("所有飞书账号均不可用,请联系管理员");
     }
 
-    // ==================== URL 构建(纯业务逻辑,无外部调用) ====================
+    private void updateCourseLinkFeishuAccount(String shortLink, Long feishuAccountId) {
+        FsCourseLink link = courseLinkMapper.selectFsCourseLinkByLink(shortLink);
+        if (link == null) {
+            return;
+        }
+        link.setFeishuAccountId(feishuAccountId);
+        courseLinkMapper.updateFsCourseLink(link);
+    }
+
+    private boolean isAccountUnavailable(CustomException e) {
+        String message = e.getMessage();
+        return message != null && (message.contains("不可用") || message.contains("已被自动禁用") || message.contains("已禁用"));
+    }
 
     private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId,
-                                      Long videoId, Long userId, String link,Integer questionFlag) {
+                                      Long videoId, Long userId, String link, Integer questionFlag) {
         CourseConfig config = getCourseConfig();
 
         Map<String, Object> courseParam = new LinkedHashMap<>();
@@ -154,9 +183,8 @@ public class FeiShuService {
 
         try {
             String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(courseParam), "UTF-8");
-            String fullUrl = config.getFeishuCourseDomain() +
-                    "/feishuCourse/pages_course/video?course=" + encodedJson + "&userId=" + userId;
-            return URLEncoder.encode(fullUrl, "UTF-8");
+            return URLEncoder.encode(config.getFeishuCourseDomain()
+                    + "/feishuCourse/pages_course/video?course=" + encodedJson + "&userId=" + userId, "UTF-8");
         } catch (UnsupportedEncodingException e) {
             throw new CustomException("看课URL拼接失败", e);
         }
@@ -176,9 +204,8 @@ public class FeiShuService {
 
         try {
             String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(authParam), "UTF-8");
-            String fullUrl = config.getFeishuAuthDomain() +
-                    "/feishuCourse/pages/wxauth?course=" + encodedJson;
-            return URLEncoder.encode(fullUrl, "UTF-8");
+            return URLEncoder.encode(config.getFeishuAuthDomain()
+                    + "/feishuCourse/pages/wxauth?course=" + encodedJson, "UTF-8");
         } catch (UnsupportedEncodingException e) {
             throw new CustomException("授权URL拼接失败", e);
         }
@@ -202,9 +229,6 @@ public class FeiShuService {
         }
     }
 
-    /**
-     * 读取课程配置并检查非空
-     */
     private CourseConfig getCourseConfig() {
         String json = configService.selectConfigByKey("course.config");
         if (StringUtils.isBlank(json)) {
@@ -212,4 +236,4 @@ public class FeiShuService {
         }
         return JSONUtil.toBean(json, CourseConfig.class);
     }
-}
+}

+ 131 - 87
fs-service/src/main/java/com/fs/feishu/service/FeishuClientPool.java

@@ -1,139 +1,183 @@
 package com.fs.feishu.service;
 
-
 import com.fs.common.exception.CustomException;
 import com.fs.feishu.domain.FeishuAccount;
 import com.fs.feishu.mapper.FeishuAccountMapper;
+import com.fs.feishu.model.FeishuClientHolder;
 import com.lark.oapi.Client;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Component;
 
-import javax.annotation.PostConstruct;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
 
 /**
- * 飞书账号池管理:
- * 1. 启动时从数据库加载所有启用账号,构建 Client 列表
- * 2. 使用 Redis 循环队列实现跨实例的轮询负载均衡
- * 3. 提供禁用异常账号的能力,同步更新数据库、内存和 Redis 索引
+ * 飞书账号池:按销售(companyUserId)隔离,支持指定账号或轮询。
  */
 @Component
 @Slf4j
 public class FeishuClientPool {
 
+    private static final String POOL_KEY_PREFIX = "feishu:account_pool:";
+
     @Autowired
     private FeishuAccountMapper feishuAccountMapper;
 
-
     @Autowired
     private StringRedisTemplate stringRedisTemplate;
 
-    private volatile List<Client> clients;
-    private volatile List<FeishuAccount> accountList;
-    private static final String POOL_KEY = "feishu:account_pool";
+    private final ConcurrentHashMap<Long, Client> clientCache = new ConcurrentHashMap<>();
 
-    @PostConstruct
-    public void init() {
-        refresh();
+    /**
+     * 轮询获取下一个可用飞书账号 ID(跳过已禁用账号)。
+     */
+    public Long pollNextAccountId(Long companyUserId) {
+        return pollAccount(companyUserId).getId();
     }
 
-    public synchronized void refresh() {
-        List<FeishuAccount> accounts = feishuAccountMapper.selectEnabledAccounts();
-        if (accounts.isEmpty()) {
-            throw new CustomException("没有可用的飞书应用账号");
+    /**
+     * 获取指定销售的飞书 Client。优先使用 feishuAccountId;未指定则在该销售启用账号间轮询。
+     */
+    public FeishuClientHolder getClient(Long companyUserId, Long feishuAccountId) {
+        if (companyUserId == null) {
+            throw new CustomException("销售ID不能为空");
+        }
+        FeishuAccount account;
+        if (feishuAccountId != null) {
+            account = requireEnabledAccount(feishuAccountId, companyUserId);
+        } else {
+            account = pollAccount(companyUserId);
         }
+        return new FeishuClientHolder(buildClient(account), account.getId(), companyUserId);
+    }
 
-        List<Client> newClients = new ArrayList<>(accounts.size());
-        for (FeishuAccount acc : accounts) {
-            Client client = Client.newBuilder(acc.getAppId(), acc.getAppSecret())
-                    .logReqAtDebug(true)
-                    .build();
-            newClients.add(client);
+    /**
+     * 根据账号 ID 获取 Client(看课链路从短链记录反查账号时使用)。
+     */
+    public FeishuClientHolder getClientByAccountId(Long feishuAccountId) {
+        if (feishuAccountId == null) {
+            throw new CustomException("飞书账号ID不能为空");
+        }
+        FeishuAccount account = feishuAccountMapper.selectById(feishuAccountId);
+        if (account == null || !Integer.valueOf(1).equals(account.getStatus())) {
+            throw new CustomException("飞书账号不可用,请更换账号后重新发课");
         }
+        return new FeishuClientHolder(buildClient(account), account.getId(), account.getCompanyUserId());
+    }
 
-        this.accountList = accounts;
-        this.clients = newClients;
-        resetPoolIndexes(newClients.size());
-        log.info("飞书账号池刷新完成,当前可用账号数: {}", newClients.size());
+    /**
+     * 获取销售所有启用账号(用于失败时依次重试)。
+     */
+    public List<FeishuAccount> listEnabledAccounts(Long companyUserId, Long preferredAccountId) {
+        List<FeishuAccount> accounts = feishuAccountMapper.selectEnabledByCompanyUserId(companyUserId);
+        if (accounts.isEmpty()) {
+            throw new CustomException("当前销售没有可用的飞书账号,请先配置");
+        }
+        if (preferredAccountId == null) {
+            return accounts;
+        }
+        List<FeishuAccount> ordered = new ArrayList<>();
+        for (FeishuAccount account : accounts) {
+            if (preferredAccountId.equals(account.getId())) {
+                ordered.add(account);
+            }
+        }
+        for (FeishuAccount account : accounts) {
+            if (!preferredAccountId.equals(account.getId())) {
+                ordered.add(account);
+            }
+        }
+        return ordered;
     }
 
-    private void resetPoolIndexes(int size) {
-        stringRedisTemplate.delete(POOL_KEY);
-        if (size == 0) {
-            return;
+    public int getAvailableCount(Long companyUserId) {
+        return feishuAccountMapper.countEnabledByCompanyUserId(companyUserId);
+    }
+
+    public synchronized void disableAccount(Long feishuAccountId, String errorMsg) {
+        feishuAccountMapper.updateStatusAndErrorMsg(feishuAccountId, 0, errorMsg);
+        clientCache.remove(feishuAccountId);
+        FeishuAccount account = feishuAccountMapper.selectById(feishuAccountId);
+        if (account != null && account.getCompanyUserId() != null) {
+            resetPoolIndexes(account.getCompanyUserId());
+        }
+        log.warn("飞书账号[{}]已被自动禁用,原因: {}", feishuAccountId, errorMsg);
+    }
+
+    private FeishuAccount requireEnabledAccount(Long feishuAccountId, Long companyUserId) {
+        FeishuAccount account = feishuAccountMapper.selectById(feishuAccountId);
+        if (account == null) {
+            throw new CustomException("飞书账号不存在");
+        }
+        if (!companyUserId.equals(account.getCompanyUserId())) {
+            throw new CustomException("飞书账号不属于当前销售");
         }
-        List<String> indexes = new ArrayList<>(size);
-        for (int i = 0; i < size; i++) {
-            indexes.add(String.valueOf(i));
+        if (!Integer.valueOf(1).equals(account.getStatus())) {
+            throw new CustomException("飞书账号已禁用: " + account.getErrorMsg());
         }
-        // 使用 StringRedisTemplate 的 rightPushAll,保证纯字符串存储
-        stringRedisTemplate.opsForList().rightPushAll(POOL_KEY, indexes);
-        log.info("Redis 账号索引池已重置,数量: {}", size);
+        return account;
     }
 
-    public Client getClient() {
+    private FeishuAccount pollAccount(Long companyUserId) {
+        List<FeishuAccount> accounts = feishuAccountMapper.selectEnabledByCompanyUserId(companyUserId);
+        if (accounts.isEmpty()) {
+            throw new CustomException("当前销售没有可用的飞书账号,请先配置");
+        }
+        ensurePoolInitialized(companyUserId, accounts);
+        String poolKey = poolKey(companyUserId);
         while (true) {
-            // 1. 从 Redis 循环队列取出索引(原子操作)
-            String indexStr = stringRedisTemplate.opsForList()
-                    .rightPopAndLeftPush(POOL_KEY, POOL_KEY);
-            if (indexStr == null) {
-                log.warn("账号池为空,尝试重新初始化");
-                resetPoolIndexes(clients.size());
+            String idStr = stringRedisTemplate.opsForList().rightPopAndLeftPush(poolKey, poolKey);
+            if (idStr == null) {
+                resetPoolIndexes(companyUserId);
                 continue;
             }
-            int index = Integer.parseInt(indexStr);
-
-            // 2. 获取当前 clients 快照(volatile 读)
-            List<Client> currentClients = this.clients;
-
-            // 3. 如果索引越界(说明列表已被其他线程缩小),丢弃该索引并重试
-            if (index >= currentClients.size()) {
-                log.warn("索引 {} 已失效(当前账号数 {}),重试", index, currentClients.size());
-                continue;
+            Long accountId = Long.parseLong(idStr);
+            for (FeishuAccount account : accounts) {
+                if (accountId.equals(account.getId())) {
+                    return account;
+                }
             }
-            return currentClients.get(index);
+            log.warn("销售[{}]账号池索引 {} 已失效,重试", companyUserId, accountId);
         }
     }
 
-    /**
-     * 禁用指定的 Client,并移除出账号池
-     *
-     * @param failedClient 需要禁用的 Client
-     * @param errorMsg     错误原因,会写入数据库 error_msg 字段
-     */
-    public synchronized void disableClient(Client failedClient, String errorMsg) {
-        for (int i = 0; i < clients.size(); i++) {
-            if (clients.get(i) == failedClient) {
-                FeishuAccount account = accountList.get(i);
-                // 更新数据库状态为禁用(status=0),并记录错误信息
-                feishuAccountMapper.updateStatusAndErrorMsg(account.getId(), 0, errorMsg);
-                // 从内存中移除
-                clients.remove(i);
-                accountList.remove(i);
-                // 重建索引池,保证索引仍然连续
-                resetPoolIndexes(clients.size());
-                log.warn("飞书账号[{}]已被自动禁用,原因: {}", account.getAppId(), errorMsg);
-                throw new CustomException(String.format("飞书账号[%s]不可用: %s", account.getAppId(), errorMsg));
-            }
+    private void ensurePoolInitialized(Long companyUserId, List<FeishuAccount> accounts) {
+        String poolKey = poolKey(companyUserId);
+        Long size = stringRedisTemplate.opsForList().size(poolKey);
+        if (size == null || size == 0) {
+            resetPoolIndexes(companyUserId, accounts);
         }
-        // 如果未找到,理论上不会执行到这里
-        throw new CustomException("无法找到对应的飞书账号");
     }
 
-    /**
-     * 获取当前可用账号数量的快照(用于重试上限)
-     */
-    public int getAvailableCount() {
-        return clients.size();
+    private void resetPoolIndexes(Long companyUserId) {
+        List<FeishuAccount> accounts = feishuAccountMapper.selectEnabledByCompanyUserId(companyUserId);
+        resetPoolIndexes(companyUserId, accounts);
     }
 
-    /**
-     * 获取所有可用 Client 的快照(用于定时任务等遍历场景)
-     */
-    public List<Client> getAllClients() {
-        return new ArrayList<>(clients);
+    private void resetPoolIndexes(Long companyUserId, List<FeishuAccount> accounts) {
+        String poolKey = poolKey(companyUserId);
+        stringRedisTemplate.delete(poolKey);
+        if (accounts == null || accounts.isEmpty()) {
+            return;
+        }
+        List<String> ids = new ArrayList<>(accounts.size());
+        for (FeishuAccount account : accounts) {
+            ids.add(String.valueOf(account.getId()));
+        }
+        stringRedisTemplate.opsForList().rightPushAll(poolKey, ids);
+        log.info("销售[{}]飞书账号池已重置,数量: {}", companyUserId, ids.size());
+    }
+
+    private Client buildClient(FeishuAccount account) {
+        return clientCache.computeIfAbsent(account.getId(), id -> Client.newBuilder(account.getAppId(), account.getAppSecret())
+                .logReqAtDebug(true)
+                .build());
+    }
+
+    private String poolKey(Long companyUserId) {
+        return POOL_KEY_PREFIX + companyUserId;
     }
-}
+}

+ 30 - 55
fs-service/src/main/java/com/fs/feishu/service/FeishuDocApiService.java

@@ -1,6 +1,7 @@
 package com.fs.feishu.service;
 
 import com.fs.common.exception.CustomException;
+import com.fs.feishu.model.FeishuClientHolder;
 import com.fs.feishu.model.FeishuFileDTO;
 import com.fs.feishu.util.FeishuErrorCode;
 import com.lark.oapi.Client;
@@ -21,9 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 
 /**
- * 飞书文档 API 适配层:
- * 封装所有对飞书 OpenAPI 的底层调用,不关心账号分配。
- * 当 API 返回账号级错误时,会通知账号池禁用该 Client,并抛出异常。
+ * 飞书文档 API 适配层。
  */
 @Service
 @Slf4j
@@ -32,51 +31,35 @@ public class FeishuDocApiService {
     @Autowired
     private FeishuClientPool clientPool;
 
-    /**
-     * 判断错误码是否属于需要禁用应用账号的严重错误
-     */
-    private boolean isAccountError(int code) {
-        // 99991663: 应用已被管理员停用
-        // 99991664: 应用已被管理员删除
-        // 10012   : 应用未开通所需权限
-        return code == 99991663 || code == 99991664 || code == 10012;
-    }
-
-    /**
-     * 检查 API 响应,若为账号级错误则禁用当前 Client 并抛出异常,否则只抛出业务异常
-     */
-    private void checkResponse(Client client, int code, String msg) {
-        if (code == 0) return;
-
+    private void checkResponse(FeishuClientHolder holder, int code, String msg) {
+        if (code == 0) {
+            return;
+        }
         if (FeishuErrorCode.isAccountDisable(code)) {
-            clientPool.disableClient(client, msg);
-            throw new CustomException("账号不可用: " + msg,code);
+            clientPool.disableAccount(holder.getAccountId(), msg);
+            throw new CustomException("飞书账号不可用: " + msg, code);
         }
         if (FeishuErrorCode.isRateLimit(code)) {
-            throw new CustomException("频率限制: " + msg,code);
+            throw new CustomException("频率限制: " + msg, code);
         }
         if (FeishuErrorCode.isInternalRetryable(code)) {
-            throw new CustomException("内部错误: " + msg,code);
+            throw new CustomException("内部错误: " + msg, code);
         }
-        throw new CustomException("飞书API错误: " + msg,code);
+        throw new CustomException("飞书API错误: " + msg, code);
     }
 
-    /**
-     * 创建云文档
-     */
-    public String createDocument(Client client, String title) throws Exception {
+    public String createDocument(FeishuClientHolder holder, String title) throws Exception {
+        Client client = holder.getClient();
         CreateDocumentReq req = CreateDocumentReq.newBuilder()
                 .createDocumentReqBody(CreateDocumentReqBody.newBuilder().title(title).build())
                 .build();
         CreateDocumentResp resp = client.docx().v1().document().create(req);
-        checkResponse(client, resp.getCode(), resp.getMsg());
+        checkResponse(holder, resp.getCode(), resp.getMsg());
         return resp.getData().getDocument().getDocumentId();
     }
 
-    /**
-     * 在文档中插入 iframe 块
-     */
-    public void createIframeBlock(Client client, String documentId, String url) throws Exception {
+    public void createIframeBlock(FeishuClientHolder holder, String documentId, String url) throws Exception {
+        Client client = holder.getClient();
         CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
                 .documentId(documentId)
                 .blockId(documentId)
@@ -97,13 +80,11 @@ public class FeishuDocApiService {
                         .build())
                 .build();
         CreateDocumentBlockChildrenResp resp = client.docx().v1().documentBlockChildren().create(req);
-        checkResponse(client, resp.getCode(), resp.getMsg());
+        checkResponse(holder, resp.getCode(), resp.getMsg());
     }
 
-    /**
-     * 在文档中插入 text + link 块(用于“观看课程”超链接)
-     */
-    public void createTextLinkBlock(Client client, String documentId, String url) throws Exception {
+    public void createTextLinkBlock(FeishuClientHolder holder, String documentId, String url) throws Exception {
+        Client client = holder.getClient();
         CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
                 .documentId(documentId)
                 .blockId(documentId)
@@ -133,13 +114,11 @@ public class FeishuDocApiService {
                                 .build())
                 .build();
         CreateDocumentBlockChildrenResp resp = client.docx().v1().documentBlockChildren().create(req);
-        checkResponse(client, resp.getCode(), resp.getMsg());
+        checkResponse(holder, resp.getCode(), resp.getMsg());
     }
 
-    /**
-     * 修改文档权限为互联网公开可读
-     */
-    public void changeDocumentPermissions(Client client, String documentId) throws Exception {
+    public void changeDocumentPermissions(FeishuClientHolder holder, String documentId) throws Exception {
+        Client client = holder.getClient();
         PatchPermissionPublicReq req = PatchPermissionPublicReq.newBuilder()
                 .token(documentId)
                 .type("docx")
@@ -154,13 +133,11 @@ public class FeishuDocApiService {
                         .build())
                 .build();
         PatchPermissionPublicResp resp = client.drive().v2().permissionPublic().patch(req);
-        checkResponse(client, resp.getCode(), resp.getMsg());
+        checkResponse(holder, resp.getCode(), resp.getMsg());
     }
 
-    /**
-     * 分页获取所有云文档(按创建时间升序排列,便于清理)
-     */
-    public List<FeishuFileDTO> listAllFiles(Client client) throws Exception {
+    public List<FeishuFileDTO> listAllFiles(FeishuClientHolder holder) throws Exception {
+        Client client = holder.getClient();
         List<FeishuFileDTO> allFiles = new ArrayList<>();
         String pageToken = null;
         int pageSize = 200;
@@ -174,7 +151,7 @@ public class FeishuDocApiService {
                 builder.pageToken(pageToken);
             }
             ListFileResp resp = client.drive().v1().file().list(builder.build());
-            checkResponse(client, resp.getCode(), resp.getMsg());
+            checkResponse(holder, resp.getCode(), resp.getMsg());
 
             if (resp.getData() != null && resp.getData().getFiles() != null) {
                 for (com.lark.oapi.service.drive.v1.model.File file : resp.getData().getFiles()) {
@@ -196,16 +173,14 @@ public class FeishuDocApiService {
         return allFiles;
     }
 
-    /**
-     * 根据 token 和类型删除指定文件
-     */
-    public boolean deleteFile(Client client, String fileToken, String type) throws Exception {
+    public boolean deleteFile(FeishuClientHolder holder, String fileToken, String type) throws Exception {
+        Client client = holder.getClient();
         DeleteFileReq req = DeleteFileReq.newBuilder()
                 .fileToken(fileToken)
                 .type(type)
                 .build();
         DeleteFileResp resp = client.drive().v1().file().delete(req);
-        checkResponse(client, resp.getCode(), resp.getMsg());
+        checkResponse(holder, resp.getCode(), resp.getMsg());
         return true;
     }
-}
+}

+ 37 - 0
fs-service/src/main/java/com/fs/feishu/service/IFeishuAccountService.java

@@ -0,0 +1,37 @@
+package com.fs.feishu.service;
+
+import com.fs.feishu.domain.FeishuAccount;
+import java.util.List;
+import java.util.Map;
+
+public interface IFeishuAccountService {
+
+    List<FeishuAccount> selectFeishuAccountList(Map<String, Object> params);
+
+    List<FeishuAccount> selectCompanyFeishuAccountList(Map<String, Object> params);
+
+    List<FeishuAccount> selectEnabledByCompanyUserId(Long companyUserId);
+
+    FeishuAccount selectFeishuAccountById(Long id);
+
+    int insertFeishuAccount(FeishuAccount account);
+
+    int updateFeishuAccount(FeishuAccount account);
+
+    int deleteFeishuAccountById(Long id);
+
+    void validateOwnership(FeishuAccount account, Long companyId, Long companyUserId);
+
+    void validateCompanyScope(FeishuAccount account, Long companyId);
+
+    /**
+     * 轮询查询当前用户可用飞书账号(负载均衡选取可用账号)
+     */
+    Long pollSendAccountId(Long companyUserId);
+
+    /**
+     * 解析发送账号,支持指定账号时使用指定账号,不指定则轮询
+     */
+    Long resolveSendAccountId(Long companyUserId, Long feishuAccountId);
+
+}

+ 130 - 0
fs-service/src/main/java/com/fs/feishu/service/impl/FeishuAccountServiceImpl.java

@@ -0,0 +1,130 @@
+package com.fs.feishu.service.impl;
+
+import com.fs.common.exception.CustomException;
+import com.fs.feishu.domain.FeishuAccount;
+import com.fs.feishu.mapper.FeishuAccountMapper;
+import com.fs.feishu.service.FeishuClientPool;
+import com.fs.feishu.service.IFeishuAccountService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class FeishuAccountServiceImpl implements IFeishuAccountService {
+
+    @Autowired
+    private FeishuAccountMapper feishuAccountMapper;
+
+    @Autowired
+    private FeishuClientPool clientPool;
+
+    @Override
+    public List<FeishuAccount> selectFeishuAccountList(Map<String, Object> params) {
+        return feishuAccountMapper.selectListByMap(params);
+    }
+
+    @Override
+    public List<FeishuAccount> selectCompanyFeishuAccountList(Map<String, Object> params) {
+        return feishuAccountMapper.selectCompanyListByMap(params);
+    }
+
+    @Override
+    public List<FeishuAccount> selectEnabledByCompanyUserId(Long companyUserId) {
+        return feishuAccountMapper.selectEnabledByCompanyUserId(companyUserId);
+    }
+
+    @Override
+    public FeishuAccount selectFeishuAccountById(Long id) {
+        return feishuAccountMapper.selectById(id);
+    }
+
+    @Override
+    public int insertFeishuAccount(FeishuAccount account) {
+        if (StringUtils.isBlank(account.getAppId()) || StringUtils.isBlank(account.getAppSecret())) {
+            throw new CustomException("AppId and AppSecret are required");
+        }
+        if (account.getStatus() == null) {
+            account.setStatus(1);
+        }
+        if (account.getNumberUse() == null) {
+            account.setNumberUse(0L);
+        }
+        Date now = new Date();
+        account.setCreateTime(now);
+        account.setUpdateTime(now);
+        return feishuAccountMapper.insert(account);
+    }
+
+    @Override
+    public int updateFeishuAccount(FeishuAccount account) {
+        account.setUpdateTime(new Date());
+        return feishuAccountMapper.update(account);
+    }
+
+    @Override
+    public int deleteFeishuAccountById(Long id) {
+        return feishuAccountMapper.deleteById(id);
+    }
+
+    @Override
+    public void validateOwnership(FeishuAccount account, Long companyId, Long companyUserId) {
+        if (account == null) {
+            throw new CustomException("飞书账号不存在");
+        }
+        if (companyId != null && account.getCompanyId() != null && !companyId.equals(account.getCompanyId())) {
+            throw new CustomException("无权操作该账号");
+        }
+        if (companyUserId != null && account.getCompanyUserId() != null && !companyUserId.equals(account.getCompanyUserId())) {
+            throw new CustomException("无权操作该账号");
+        }
+    }
+
+    @Override
+    public void validateCompanyScope(FeishuAccount account, Long companyId) {
+        if (account == null) {
+            throw new CustomException("飞书账号不存在");
+        }
+        if (companyId == null || account.getCompanyId() == null || !companyId.equals(account.getCompanyId())) {
+            throw new CustomException("无权操作该账号");
+        }
+    }
+
+    @Override
+    public Long pollSendAccountId(Long companyUserId) {
+        if (companyUserId == null) {
+            throw new CustomException("用户ID不能为空");
+        }
+        return clientPool.pollNextAccountId(companyUserId);
+    }
+
+    @Override
+    public Long resolveSendAccountId(Long companyUserId, Long feishuAccountId) {
+        if (companyUserId == null) {
+            throw new CustomException("用户ID不能为空");
+        }
+        if (feishuAccountId != null) {
+            requireEnabledAccount(feishuAccountId, companyUserId);
+            return feishuAccountId;
+        }
+        return pollSendAccountId(companyUserId);
+    }
+
+    private FeishuAccount requireEnabledAccount(Long feishuAccountId, Long companyUserId) {
+        FeishuAccount account = feishuAccountMapper.selectById(feishuAccountId);
+        if (account == null) {
+            throw new CustomException("飞书账号不存在");
+        }
+        if (!companyUserId.equals(account.getCompanyUserId())) {
+            throw new CustomException("该账号不属于当前用户");
+        }
+        if (!Integer.valueOf(1).equals(account.getStatus())) {
+            throw new CustomException("账号不可用: " + account.getErrorMsg());
+        }
+        return account;
+    }
+
+}

+ 3 - 0
fs-service/src/main/java/com/fs/qw/domain/QwUser.java

@@ -121,4 +121,7 @@ public class QwUser extends BaseEntity
 
     //根据ai状态判断是否启用
     private Integer aiStatus;
+
+    /** 发课使用的飞书账号 */
+    private Long feishuAccountId;
 }

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

@@ -150,6 +150,9 @@ public class QwSopCourseFinishTempSetting implements Serializable,Cloneable{
 
         private String articleUrl;
 
+        /** 飞书应用账号 id(contentType=17 时使用) */
+        private Long feishuAccountId;
+
         /**
          * 模板表 qw_sop_temp_content 中存储的整段 JSON 字符串(个微 SOP 生成日志时会原样带入)。
          * 真实 fileUrl、展示用文件名等多在该嵌套串中,发送前需合并到本对象扁平字段。
@@ -184,6 +187,7 @@ public class QwSopCourseFinishTempSetting implements Serializable,Cloneable{
                 clone.setLinkUrl(this.getLinkUrl());
                 clone.setAppLinkUrl(this.getAppLinkUrl());
                 clone.setArticleUrl(this.getArticleUrl());
+                clone.setFeishuAccountId(this.getFeishuAccountId());
                 clone.setMiniprogramAppid(this.getMiniprogramAppid());
                 clone.setMiniType(this.getMiniType());
                 clone.setMiniprogramTitle(this.getMiniprogramTitle());

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

@@ -177,6 +177,9 @@ public class QwSopTempSetting implements Serializable{
             /** 订阅号文章链接(contentType=13时使用,透传给小程序端用于跳转) */
             private String articleUrl;
 
+            /** 飞书应用账号 id(contentType=17 时使用) */
+            private Long feishuAccountId;
+
             public boolean isRandomSendEnabled() {
                 return Objects.equals(isRandomSend, 1) || "1".equals(String.valueOf(isRandomSend));
             }
@@ -208,6 +211,7 @@ public class QwSopTempSetting implements Serializable{
                     clone.setLinkUrl(this.getLinkUrl());
                     clone.setAppLinkUrl(this.getAppLinkUrl());
                     clone.setArticleUrl(this.getArticleUrl());
+                    clone.setFeishuAccountId(this.getFeishuAccountId());
                     // 小程序相关字段(contentType=4/12/13 必须)
                     clone.setMiniprogramAppid(this.getMiniprogramAppid());
                     clone.setMiniType(this.getMiniType());

+ 7 - 0
fs-service/src/main/java/com/fs/qw/vo/QwUserVO.java

@@ -124,4 +124,11 @@ public class QwUserVO {
     private Integer delayEnd;
 
     private Integer aiStatus;
+
+    /** 发课使用的飞书账号 */
+    private Long feishuAccountId;
+
+    private String feishuAccountPhone;
+
+    private String feishuAccountAppId;
 }

+ 5 - 4
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -767,7 +767,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
                                 String shortCode = feishuH5Link.get("link");
-                                String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode);
+                                String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, null);
                                 if (StringUtils.isNotEmpty(feishuLink)) {
                                     st.setLinkUrl(feishuLink);
                                 } else {
@@ -995,7 +995,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), externalUserIdGroup, config);
                                 String shortCodeGroup = feishuH5LinkGroup.get("link");
-                                String feishuLinkGroup = feiShuService.getFeishuRegisterLink(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(), companyUserIdLongGroup, shortCodeGroup);
+                                String feishuLinkGroup = feiShuService.getFeishuRegisterLink(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(), companyUserIdLongGroup, shortCodeGroup, null);
                                 if (StringUtils.isNotEmpty(feishuLinkGroup)) {
                                     st.setLinkUrl(feishuLinkGroup);
                                 } else {
@@ -1336,7 +1336,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             }
                             Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
                             String shortCode = feishuH5Link.get("link");
-                            String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode);
+                            String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, null);
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
                             } else {
@@ -1958,7 +1958,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             item.getExternalId(), item.getStartTime(), dataTime, 2);
                     Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
                     String shortLink = feishuH5Link.get("link");
-                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortLink);
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortLink, null);
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String customerTitle = contact == null ? "同学" :
@@ -1996,6 +1996,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                                    String companyUserId, String companyId, Long externalId, CourseConfig config) {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, null);
+        link.setFeishuAccountId(setting.getFeishuAccountId());
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(courseMap);

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

@@ -20,10 +20,11 @@
         <result property="linkType"    column="link_type"    />
         <result property="isRoom"    column="is_room"    />
         <result property="chatId"    column="chat_id"    />
+        <result property="feishuAccountId"    column="feishu_account_id"    />
     </resultMap>
 
     <sql id="selectFsCourseLinkVo">
-        select link_id,chat_id, is_room,link, real_link,link_type, create_time, update_time, company_id, company_user_id, qw_user_id, video_id, corp_id, course_id ,qw_external_id  from fs_course_link
+        select link_id,chat_id, is_room,link, real_link,link_type, create_time, update_time, company_id, company_user_id, qw_user_id, video_id, corp_id, course_id ,qw_external_id, feishu_account_id from fs_course_link
     </sql>
 
     <select id="selectFsCourseLinkList" parameterType="FsCourseLink" resultMap="FsCourseLinkResult">
@@ -64,6 +65,7 @@
             <if test="linkType != null">link_type,</if>
             <if test="isRoom != null">is_room,</if>
             <if test="chatId != null">chat_id,</if>
+            <if test="feishuAccountId != null">feishu_account_id,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="link != null">#{link},</if>
@@ -80,6 +82,7 @@
             <if test="linkType != null">#{linkType},</if>
             <if test="isRoom != null">#{isRoom},</if>
             <if test="chatId != null">#{chatId},</if>
+            <if test="feishuAccountId != null">#{feishuAccountId},</if>
         </trim>
     </insert>
 
@@ -188,6 +191,7 @@
             <if test="linkType != null">link_type = #{linkType},</if>
             <if test="isRoom != null">is_room = #{isRoom},</if>
             <if test="chatId != null">chat_id = #{chatId},</if>
+            <if test="feishuAccountId != null">feishu_account_id = #{feishuAccountId},</if>
         </trim>
         where link_id = #{linkId}
     </update>

+ 104 - 7
fs-service/src/main/resources/mapper/his/FeishuAccountMapper.xml

@@ -2,14 +2,111 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.fs.feishu.mapper.FeishuAccountMapper">
-    <select id="selectEnabledAccounts" resultType="com.fs.feishu.domain.FeishuAccount">
-        SELECT id, app_id, app_secret, status, create_time, update_time,error_msg
-        FROM feishu_account
-        WHERE status = 1
+
+    <resultMap id="FeishuAccountResult" type="com.fs.feishu.domain.FeishuAccount">
+        <id property="id" column="id"/>
+        <result property="appId" column="app_id"/>
+        <result property="appSecret" column="app_secret"/>
+        <result property="status" column="status"/>
+        <result property="companyId" column="company_id"/>
+        <result property="companyUserId" column="company_user_id"/>
+        <result property="phone" column="phone"/>
+        <result property="numberUse" column="number_use"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="errorMsg" column="error_msg"/>
+        <result property="nickName" column="nick_name"/>
+        <result property="userName" column="user_name"/>
+    </resultMap>
+
+    <sql id="selectFeishuAccountVo">
+        select id, app_id, app_secret, status, company_id, company_user_id, phone, number_use,
+               create_time, update_time, error_msg
+        from feishu_account
+    </sql>
+
+    <select id="selectById" resultMap="FeishuAccountResult">
+        <include refid="selectFeishuAccountVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectEnabledByCompanyUserId" resultMap="FeishuAccountResult">
+        <include refid="selectFeishuAccountVo"/>
+        where status = 1 and company_user_id = #{companyUserId}
+        order by id asc
+    </select>
+
+    <select id="selectListByMap" resultMap="FeishuAccountResult">
+        <include refid="selectFeishuAccountVo"/>
+        <where>
+            <if test="companyId != null">and company_id = #{companyId}</if>
+            <if test="companyUserId != null">and company_user_id = #{companyUserId}</if>
+            <if test="status != null">and status = #{status}</if>
+            <if test="phone != null and phone != ''">and phone like concat('%', #{phone}, '%')</if>
+            <if test="appId != null and appId != ''">and app_id like concat('%', #{appId}, '%')</if>
+        </where>
+        order by id desc
+    </select>
+
+    <select id="selectCompanyListByMap" resultMap="FeishuAccountResult">
+        select fa.id, fa.app_id, fa.app_secret, fa.status, fa.company_id, fa.company_user_id, fa.phone,
+               fa.number_use, fa.create_time, fa.update_time, fa.error_msg,
+               cu.nick_name as nick_name, cu.user_name as user_name
+        from feishu_account fa
+        left join company_user cu on cu.user_id = fa.company_user_id
+        <where>
+            <if test="companyId != null">and fa.company_id = #{companyId}</if>
+            <if test="companyUserId != null">and fa.company_user_id = #{companyUserId}</if>
+            <if test="status != null">and fa.status = #{status}</if>
+            <if test="phone != null and phone != ''">and fa.phone like concat('%', #{phone}, '%')</if>
+            <if test="appId != null and appId != ''">and fa.app_id like concat('%', #{appId}, '%')</if>
+            <if test="nickName != null and nickName != ''">and cu.nick_name like concat('%', #{nickName}, '%')</if>
+        </where>
+        order by fa.id desc
     </select>
 
+    <select id="countEnabledByCompanyUserId" resultType="int">
+        select count(1) from feishu_account
+        where status = 1 and company_user_id = #{companyUserId}
+    </select>
+
+    <insert id="insert" parameterType="com.fs.feishu.domain.FeishuAccount" useGeneratedKeys="true" keyProperty="id">
+        insert into feishu_account
+        (app_id, app_secret, status, company_id, company_user_id, phone, number_use, create_time, update_time, error_msg)
+        values
+        (#{appId}, #{appSecret}, #{status}, #{companyId}, #{companyUserId}, #{phone},
+         #{numberUse,jdbcType=BIGINT}, #{createTime}, #{updateTime}, #{errorMsg})
+    </insert>
+
+    <update id="update" parameterType="com.fs.feishu.domain.FeishuAccount">
+        update feishu_account
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="appId != null">app_id = #{appId},</if>
+            <if test="appSecret != null">app_secret = #{appSecret},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="companyUserId != null">company_user_id = #{companyUserId},</if>
+            <if test="phone != null">phone = #{phone},</if>
+            <if test="numberUse != null">number_use = #{numberUse},</if>
+            <if test="errorMsg != null">error_msg = #{errorMsg},</if>
+            update_time = now()
+        </trim>
+        where id = #{id}
+    </update>
+
     <update id="updateStatusAndErrorMsg">
-        UPDATE feishu_account SET status = #{status}, error_msg = #{errorMsg}
-        WHERE id = #{id}
+        update feishu_account
+        set status = #{status}, error_msg = #{errorMsg}, update_time = now()
+        where id = #{id}
+    </update>
+
+    <update id="incrementNumberUse">
+        update feishu_account
+        set number_use = ifnull(number_use, 0) + 1, update_time = now()
+        where id = #{id}
     </update>
-</mapper>
+
+    <delete id="deleteById">
+        delete from feishu_account where id = #{id}
+    </delete>
+</mapper>

+ 3 - 1
fs-service/src/main/resources/mapper/qw/QwUserMapper.xml

@@ -38,10 +38,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="delayStart"    column="delay_start"    />
         <result property="delayEnd"    column="delay_end"    />
         <result property="aiStatus"    column="ai_status"    />
+        <result property="feishuAccountId"    column="feishu_account_id"    />
     </resultMap>
 
     <sql id="selectQwUserVo">
-        select id,is_auto, video_get_status, qw_user_id,server_id,server_status,ipad_status,config_id,vid,uid,contact_way,app_key, qw_user_name, department, openid, company_id, company_user_id, corp_id, status, is_del, welcome_text, welcome_image, is_send_msg,app_key,qw_hook_id,fastGpt_role_id,login_status,tool_status,login_code_url,version,update_time,delay_start,delay_end,ai_status from qw_user
+        select id,is_auto, video_get_status, qw_user_id,server_id,server_status,ipad_status,config_id,vid,uid,contact_way,app_key, qw_user_name, department, openid, company_id, company_user_id, corp_id, status, is_del, welcome_text, welcome_image, is_send_msg,app_key,qw_hook_id,fastGpt_role_id,login_status,tool_status,login_code_url,version,update_time,delay_start,delay_end,ai_status,feishu_account_id from qw_user
         </sql>
 
     <select id="selectQwUserList" parameterType="QwUser" resultMap="QwUserResult">
@@ -188,6 +189,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="delayStart != null">delay_start = #{delayStart},</if>
             <if test="delayEnd != null">delay_end = #{delayEnd},</if>
             <if test="aiStatus != null">ai_status = #{aiStatus},</if>
+            <if test="feishuAccountId != null">feishu_account_id = #{feishuAccountId},</if>
         </trim>
         where id = #{id}
     </update>