Kaynağa Gözat

优化飞书业务账号池逻辑代码结构+推送开方数据到换绑医生的问题

cgp 6 gün önce
ebeveyn
işleme
2c475e0aad

+ 14 - 1
fs-service/src/main/java/com/fs/company/service/impl/CompanyUserServiceImpl.java

@@ -30,6 +30,7 @@ import com.fs.his.domain.FsDoctor;
 import com.fs.his.mapper.FsDoctorMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsCityService;
+import com.fs.his.service.IFsDoctorService;
 import com.fs.his.vo.CitysAreaVO;
 import com.fs.his.vo.OptionsVO;
 import com.fs.hisStore.domain.FsStoreProductAttrScrm;
@@ -660,7 +661,19 @@ public class CompanyUserServiceImpl implements ICompanyUserService
     @Override
     @DataScope(deptAlias = "u", userAlias = "u")
     public List<CompanyUserQwListVO> selectCompanyUserQwListVO(CompanyUserQwParam user) {
-        return companyUserMapper.selectCompanyUserQwListVO(user);
+        List<CompanyUserQwListVO> companyUserQwListVOS = companyUserMapper.selectCompanyUserQwListVO(user);
+        if (CollectionUtils.isNotEmpty(companyUserQwListVOS)){
+            List<Long> doctorIds = companyUserQwListVOS.stream().map(CompanyUserQwListVO::getDoctorId).collect(Collectors.toList());
+            List<FsDoctor> doctors = doctorMapper.selectDoctorByIds(doctorIds);
+            if (CollectionUtils.isNotEmpty(doctors)){
+                Map<Long, String> doctorNameMap = doctors.stream()
+                        .filter(d -> d.getDoctorId() != null && d.getDoctorName() != null)
+                        .collect(Collectors.toMap(FsDoctor::getDoctorId, FsDoctor::getDoctorName));
+                companyUserQwListVOS.forEach(companyUserQwListVO -> companyUserQwListVO.setDoctorName(doctorNameMap.getOrDefault(companyUserQwListVO.getDoctorId(), "")));
+            }
+            return companyUserQwListVOS;
+        }
+        return Collections.emptyList();
     }
 
     @Override

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

@@ -136,6 +136,9 @@ public class CompanyUserQwListVO extends BaseEntity {
     /** 医生id */
     private Long doctorId;
 
+    /** 医生姓名 */
+    private String doctorName;
+
     private Integer createOpen;
 
     private Long cidServerId;

+ 161 - 80
fs-service/src/main/java/com/fs/feishu/service/FeishuClientPool.java

@@ -1,26 +1,32 @@
 package com.fs.feishu.service;
 
-import com.fs.common.exception.CustomException;
+import cn.hutool.json.JSONUtil;
 import com.fs.feishu.domain.FeishuAccount;
 import com.fs.feishu.mapper.FeishuAccountMapper;
 import com.lark.oapi.Client;
 import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 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.Collections;
 import java.util.List;
-import java.util.stream.Collectors;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
 
 /**
- * 飞书账号池管理:
- * 1. 启动时从数据库加载所有启用公共账号,构建 Client 列表
+ * 飞书账号池管理(分布式版本)
+ * 1. 启动时从数据库加载所有启用公共账号,写入 Redis 共享存储
  * 2. 使用 Redis 循环队列实现跨实例的轮询负载均衡
  * 3. 提供 Client + FeishuAccount 的包装对象,便于获取账号信息
- * 4. 支持禁用异常账号,同步更新数据库、内存和 Redis 索引
- * 5. 支持手动刷新账号池
+ * 4. 支持禁用异常账号,同步更新数据库和 Redis(所有实例即时感知)
+ * 5. 本地缓存 Client 对象(无状态 HTTP 配置),通过 appId 去重复用
+ * 6. 写操作(refresh / disableAccount)使用 Redisson 分布式锁保证多实例安全
  */
 @Component
 @Slf4j
@@ -32,20 +38,29 @@ public class FeishuClientPool {
     @Autowired
     private StringRedisTemplate stringRedisTemplate;
 
+    @Autowired
+    private RedissonClient redissonClient;
+
     /**
-     * 内存中的 Client 列表(volatile 保证可见性)
+     * Redis 中存储账号 JSON 列表的 key
      */
-    private volatile List<Client> clients = new ArrayList<>();
+    private static final String ACCOUNTS_KEY = "feishu:public_accounts";
 
     /**
-     * 内存中的 FeishuAccount 列表(与 clients 顺序一致,同索引对应)
+     * Redis 中存储轮询索引队列的 key
      */
-    private volatile List<FeishuAccount> accounts = new ArrayList<>();
+    private static final String POOL_KEY = "feishu:account_pool";
 
     /**
-     * Redis 中存储索引列表的 key
+     * 分布式锁 key
      */
-    private static final String POOL_KEY = "feishu:account_pool";
+    private static final String LOCK_KEY = "feishu:pool:lock";
+
+    /**
+     * 本地 Client 缓存,key = appId
+     * Client 是无状态的 HTTP 配置对象,本地缓存不影响多实例一致性
+     */
+    private final Map<String, Client> clientCache = new ConcurrentHashMap<>();
 
     /**
      * 初始化时加载账号池
@@ -55,36 +70,91 @@ public class FeishuClientPool {
         refresh();
     }
 
+    // ==================== 分布式锁工具方法 ====================
+
     /**
-     * 刷新账号池:从数据库加载所有启用状态的公共账号,重建内存列表和 Redis 索引
+     * 执行需要分布式锁保护的操作
      */
-    public synchronized void refresh() {
-        // 从数据库加载 status=1 的公共账号(假设公共账号有标志 isPublic=1)
-        List<FeishuAccount> accountList = feishuAccountMapper.selectAllPublicAccounts();
-        if (accountList.isEmpty()) {
-            log.error("没有可用的公共飞书应用账号,账号池为空");
-            // 清空内存和 Redis
-            this.clients = new ArrayList<>();
-            this.accounts = new ArrayList<>();
-            resetPoolIndexes(0);
-            return;
+    private void withLock(Runnable action) {
+        RLock lock = redissonClient.getLock(LOCK_KEY);
+        try {
+            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
+                try {
+                    action.run();
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.error("获取飞书账号池分布式锁失败(超时)");
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("获取飞书账号池分布式锁被中断", e);
         }
+    }
+
+    /**
+     * 从 Redis 读取公共账号 JSON 并解析为列表
+     */
+    private List<FeishuAccount> loadAccountsFromRedis() {
+        String json = stringRedisTemplate.opsForValue().get(ACCOUNTS_KEY);
+        if (json == null || json.isEmpty()) {
+            return Collections.emptyList();
+        }
+        try {
+            return JSONUtil.parseArray(json).toList(FeishuAccount.class);
+        } catch (Exception e) {
+            log.error("解析飞书公共账号 JSON 失败", e);
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 将账号列表写入 Redis
+     */
+    private void saveAccountsToRedis(List<FeishuAccount> accounts) {
+        String json = JSONUtil.toJsonStr(accounts);
+        stringRedisTemplate.opsForValue().set(ACCOUNTS_KEY, json);
+    }
 
-        // 构建 Client 列表
-        List<Client> newClients = accountList.stream()
-                .map(acc -> Client.newBuilder(acc.getAppId(), acc.getAppSecret())
+    /**
+     * 根据 appId 获取或构建 Client
+     */
+    private Client getOrCreateClient(FeishuAccount account) {
+        return clientCache.computeIfAbsent(account.getAppId(),
+                k -> Client.newBuilder(account.getAppId(), account.getAppSecret())
                         .logReqAtDebug(true)
-                        .build())
-                .collect(Collectors.toList());
+                        .build());
+    }
+
+    // ==================== 账号池管理 ====================
+
+    /**
+     * 刷新账号池:从数据库加载所有启用状态的公共账号,写入 Redis,重建索引
+     */
+    public void refresh() {
+        withLock(() -> {
+            // 1. 从数据库加载公共账号(phone/companyId/companyUserId 均为 NULL)
+            List<FeishuAccount> accountList = feishuAccountMapper.selectAllPublicAccounts();
+            if (accountList.isEmpty()) {
+                log.error("没有可用的公共飞书应用账号,账号池为空");
+                stringRedisTemplate.delete(ACCOUNTS_KEY);
+                resetPoolIndexes(0);
+                clientCache.clear();
+                return;
+            }
+
+            // 2. 写入 Redis
+            saveAccountsToRedis(accountList);
 
-        // 更新内存
-        this.clients = newClients;
-        this.accounts = accountList;
+            // 3. 重建轮询索引队列
+            resetPoolIndexes(accountList.size());
 
-        // 重置 Redis 索引
-        resetPoolIndexes(newClients.size());
+            // 4. 清空本地 Client 缓存(账号可能有变更,下次按需重建)
+            clientCache.clear();
 
-        log.info("飞书账号池刷新完成,当前可用账号数: {}", newClients.size());
+            log.info("飞书账号池刷新完成,当前可用账号数: {}", accountList.size());
+        });
     }
 
     /**
@@ -106,6 +176,8 @@ public class FeishuClientPool {
     /**
      * 获取一个可用账号的包装对象(包含 Client 和 FeishuAccount)
      * 使用 Redis 循环队列实现轮询,保证多实例负载均衡
+     * <p>
+     * 每次从 Redis 读取最新的账号列表,确保任意实例禁用账号后其他实例能即时感知
      */
     public ClientWrapper getClientWrapper() {
         while (true) {
@@ -116,10 +188,7 @@ public class FeishuClientPool {
                 // 队列为空,可能是账号池被清空或尚未初始化,尝试刷新
                 log.warn("账号池索引为空,尝试重新初始化");
                 refresh();
-                // 如果刷新后仍为空,抛出异常
-                if (clients.isEmpty()) {
-                    throw new CustomException("当前没有可用的飞书公共账号");
-                }
+                // 刷新后重新开始循环
                 continue;
             }
 
@@ -131,80 +200,92 @@ public class FeishuClientPool {
                 continue;
             }
 
-            // 2. 获取当前内存快照
-            List<Client> currentClients = this.clients;
-            List<FeishuAccount> currentAccounts = this.accounts;
+            // 2. 从 Redis 读取最新的账号列表(保证多实例一致性)
+            List<FeishuAccount> accountList = loadAccountsFromRedis();
+
+            // 3. 如果 Redis 中也没有数据,尝试刷新
+            if (accountList.isEmpty()) {
+                log.warn("Redis 中无公共账号数据,尝试重新初始化");
+                refresh();
+                continue;
+            }
 
-            // 3. 检查索引有效性
-            if (index >= currentClients.size()) {
-                log.warn("索引 {} 越界(当前账号数 {}),丢弃并重试", index, currentClients.size());
+            // 4. 检查索引有效性
+            if (index >= accountList.size()) {
+                log.warn("索引 {} 越界(当前账号数 {}),丢弃并重试", index, accountList.size());
                 continue;
             }
 
-            // 4. 返回包装对象
-            return new ClientWrapper(currentClients.get(index), currentAccounts.get(index));
+            // 5. 获取账号,构建或从缓存获取 Client
+            FeishuAccount account = accountList.get(index);
+            Client client = getOrCreateClient(account);
+
+            return new ClientWrapper(client, account);
         }
     }
 
     /**
      * 禁用指定账号(根据 FeishuAccount 对象),将其从池中移除
-     * 同步方法保证内存和 Redis 的一致性
+     * 使用分布式锁保证数据库和 Redis 的跨实例一致性
      *
-     * @param account  要禁用的账号(必须包含 ID)
+     * @param account  要禁用的账号(必须包含 ID 和 appId
      * @param errorMsg 禁用原因,会写入数据库
      */
-    public synchronized void disableAccount(FeishuAccount account, String errorMsg) {
+    public void disableAccount(FeishuAccount account, String errorMsg) {
         if (account == null || account.getId() == null) {
             log.warn("禁用账号失败:账号或ID为空");
             return;
         }
 
-        Long accountId = account.getId();
-        // 在 accounts 列表中查找匹配的索引(使用 ID 比较,避免对象引用问题)
-        int indexToRemove = -1;
-        for (int i = 0; i < accounts.size(); i++) {
-            if (accounts.get(i).getId().equals(accountId)) {
-                indexToRemove = i;
-                break;
-            }
-        }
-
-        if (indexToRemove == -1) {
-            log.warn("账号 ID {} 不在当前池中,可能已被移除", accountId);
-            return;
-        }
+        withLock(() -> {
+            Long accountId = account.getId();
+            String appId = account.getAppId();
 
-        // 更新数据库状态为禁用(status=0),并记录错误信息
-        feishuAccountMapper.updateStatusAndErrorMsg(accountId, 0, errorMsg);
+            // 1. 更新数据库状态为禁用(status=0),并记录错误信息
+            feishuAccountMapper.updateStatusAndErrorMsg(accountId, 0, errorMsg);
 
-        // 从内存列表中移除(注意:不能直接修改原列表,要创建新列表避免并发问题)
-        List<Client> newClients = new ArrayList<>(clients);
-        List<FeishuAccount> newAccounts = new ArrayList<>(accounts);
-        newClients.remove(indexToRemove);
-        newAccounts.remove(indexToRemove);
+            // 2. 从 Redis 账号列表中移除
+            List<FeishuAccount> accountList = loadAccountsFromRedis();
+            boolean removed = accountList.removeIf(a -> a.getId().equals(accountId));
+            if (!removed) {
+                log.warn("账号 ID {} 不在 Redis 账号池中,可能已被移除", accountId);
+            } else {
+                saveAccountsToRedis(accountList);
+            }
 
-        // 更新 volatile 引用
-        this.clients = newClients;
-        this.accounts = newAccounts;
+            // 3. 重建索引队列
+            resetPoolIndexes(accountList.size());
 
-        // 重置 Redis 索引
-        resetPoolIndexes(newClients.size());
+            // 4. 从本地 Client 缓存中移除
+            if (appId != null) {
+                clientCache.remove(appId);
+            }
 
-        log.warn("飞书账号[{}]已被自动禁用,原因: {}", account.getAppId(), errorMsg);
+            log.warn("飞书公共账号[{}]已被禁用,原因: {}", appId, errorMsg);
+        });
     }
 
     /**
-     * 获取当前可用账号数量的快照
+     * 获取当前可用账号数量
      */
     public int getAvailableCount() {
-        return clients.size();
+        List<FeishuAccount> accountList = loadAccountsFromRedis();
+        return accountList.size();
     }
 
     /**
      * 获取所有可用 Client 的快照(只读,用于遍历)
      */
     public List<Client> getAllClients() {
-        return new ArrayList<>(clients);
+        List<FeishuAccount> accountList = loadAccountsFromRedis();
+        if (accountList.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Client> clients = new ArrayList<>(accountList.size());
+        for (FeishuAccount account : accountList) {
+            clients.add(getOrCreateClient(account));
+        }
+        return clients;
     }
 
     // ==================== 内部包装类 ====================
@@ -229,4 +310,4 @@ public class FeishuClientPool {
             return account;
         }
     }
-}
+}

+ 12 - 2
fs-service/src/main/java/com/fs/his/service/impl/FsPrescribeDataScrmServiceImpl.java

@@ -5,6 +5,8 @@ import com.alibaba.excel.EasyExcel;
 import com.fs.common.BeanCopyUtils;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.DateUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.his.domain.*;
 import com.fs.his.dto.*;
 import com.fs.his.enums.DoctorTypeEnum;
@@ -58,6 +60,9 @@ public class FsPrescribeDataScrmServiceImpl implements IFsPrescribeDataScrmServi
     @Autowired
     private FsDoctorMapper fsDoctorMapper;
 
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+
     @Autowired
     private IFsCompanyCustomerService companyCustomerService;
 
@@ -492,12 +497,17 @@ public class FsPrescribeDataScrmServiceImpl implements IFsPrescribeDataScrmServi
         // 处方单号
         String prescribeCode= IdUtil.getSnowflake(0, 0).nextIdStr();
         data.setPrescribeCode(prescribeCode);
+        //获取当前销售最新绑定的医生id
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserByUserId(customer.getCompanyUserId());
+        if (companyUser==null||companyUser.getDoctorId()==null){
+            throw new CustomException("没有找到绑定的医生信息,请绑定后再推送!");
+        }
         // 医生id
-        FsDoctor doctor = fsDoctorMapper.selectFsDoctorByDoctorId(customer.getDoctorId());
+        FsDoctor doctor = fsDoctorMapper.selectFsDoctorByDoctorId(companyUser.getDoctorId());
         if (doctor!=null&&doctor.getStatus()==0){
             throw new CustomException("医生已停用,请更换医生!");
         }
-        data.setDoctorId(customer.getDoctorId());
+        data.setDoctorId(companyUser.getDoctorId());
         data.setStatus(0);
         data.setCreateTime(DateUtils.getNowDate());
         data.setDoctorConfirm(0);