Explorar el Código

cid 添加个微

wangxy hace 1 semana
padre
commit
c383a2813e
Se han modificado 18 ficheros con 681 adiciones y 55 borrados
  1. 16 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecLogMapper.java
  2. 8 0
      fs-service/src/main/java/com/fs/company/service/ICompanyWxAccountService.java
  3. 25 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  4. 77 28
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWxServiceImpl.java
  5. 7 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java
  6. 2 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNode.java
  7. 2 2
      fs-service/src/main/resources/application-config-druid-hdt.yml
  8. 35 0
      fs-service/src/main/resources/mapper/company/CompanyAiWorkflowExecLogMapper.xml
  9. 4 0
      fs-service/src/main/resources/mapper/company/CompanyWxAccountMapper.xml
  10. 46 10
      fs-wx-api/src/main/java/com/fs/app/controller/AppBaseController.java
  11. 98 0
      fs-wx-api/src/main/java/com/fs/app/controller/WxAccountController.java
  12. 79 0
      fs-wx-api/src/main/java/com/fs/app/controller/auth/CaptchaController.java
  13. 92 0
      fs-wx-api/src/main/java/com/fs/app/controller/auth/ToolLoginController.java
  14. 23 5
      fs-wx-api/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  15. 25 0
      fs-wx-api/src/main/java/com/fs/app/param/ToolLoginBody.java
  16. 18 0
      fs-wx-api/src/main/java/com/fs/app/vo/BoundWxAccountVO.java
  17. 46 0
      fs-wx-api/src/main/java/com/fs/app/websocket/service/WebSocketPathServer.java
  18. 78 10
      fs-wx-api/src/main/java/com/fs/app/websocket/service/WebSocketServer.java

+ 16 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecLogMapper.java

@@ -74,4 +74,20 @@ public interface CompanyAiWorkflowExecLogMapper extends BaseMapper<CompanyAiWork
     List<CompanyAiWorkflowExecLog> selectByInstanceIds(List<String> instanceIds);
 
     List<CallContentVO> selectCallContent(@Param("ids") List<Long> ids);
+
+    /**
+     * 根据加微客户ID查询仍为指定状态的加微节点执行日志
+     * (用于回调时工作流已提前流转、实例不再是 WAITING 的场景)
+     */
+    List<CompanyAiWorkflowExecLog> selectWaitingAddWxLogsByWxClientId(@Param("wxClientId") Long wxClientId,
+                                                                    @Param("status") Integer status,
+                                                                    @Param("nodeTypes") List<Integer> nodeTypes);
+
+    /**
+     * 纠偏:客户已加微成功(is_add=1),但加微节点日志仍为等待中
+     */
+    int reconcileWaitingAddWxLogsByRoboticId(@Param("roboticId") Long roboticId,
+                                            @Param("waitingStatus") Integer waitingStatus,
+                                            @Param("successStatus") Integer successStatus,
+                                            @Param("nodeTypes") List<Integer> nodeTypes);
 }

+ 8 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyWxAccountService.java

@@ -40,6 +40,14 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
      */
     public int insertCompanyWxAccount(CompanyWxAccount companyWxAccount);
 
+    /**
+     * 桌面工具新增个微账号:昵称/微信号/手机号允许暂空
+     *
+     * @param companyWxAccount 企微账号(需已带 companyId、companyUserId)
+     * @return 新账号主键 id
+     */
+    Long createFromTool(CompanyWxAccount companyWxAccount);
+
     /**
      * 修改企微账号
      *

+ 25 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -1733,6 +1733,9 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                                               String customerPhone,
                                               Boolean onlyCallNode,
                                               String encryptPhone) {
+        // 纠偏历史数据:加微客户已通过,但节点日志仍卡在「等待中」
+        reconcileAddWxWaitingLogs(roboticId);
+
         //分页查询主数据
         PageHelper.startPage(pageNum, pageSize);
         if(StringUtils.isNotBlank(encryptPhone)){
@@ -1881,6 +1884,28 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         }
     }
 
+    /**
+     * 纠偏:加微节点日志仍为「等待中」,但流程已结束(无条件边提前流转)或客户已加微成功
+     */
+    private void reconcileAddWxWaitingLogs(Long roboticId) {
+        try {
+            int updated = companyAiWorkflowExecLogMapper.reconcileWaitingAddWxLogsByRoboticId(
+                    roboticId,
+                    ExecutionStatusEnum.WAITING.getValue(),
+                    ExecutionStatusEnum.SUCCESS.getValue(),
+                    Arrays.asList(
+                            NodeTypeEnum.AI_ADD_WX_TASK.getValue(),
+                            NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue(),
+                            NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue()
+                    ));
+            if (updated > 0) {
+                log.info("纠偏加微等待中节点日志 {} 条, roboticId={}", updated, roboticId);
+            }
+        } catch (Exception e) {
+            log.warn("纠偏加微等待中节点日志失败, roboticId={}", roboticId, e);
+        }
+    }
+
     /**
      * 获取节点类型名称
      */

+ 77 - 28
fs-service/src/main/java/com/fs/company/service/impl/CompanyWxServiceImpl.java

@@ -145,15 +145,52 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
     @Override
     public int insertCompanyWxAccount(CompanyWxAccount companyWxAccount)
     {
-        //校验添加微信前缀唯一性
-        CompanyWxAccount wxAccount = companyWxAccountMapper.selectCompanyWxAccountByWxRemark(companyWxAccount.getWxRemark());
-        if(null != wxAccount){
-            throw new RuntimeException("微信前缀已存在,请更换后重试");
+        //校验添加微信前缀唯一性(有前缀时才校验)
+        if (companyWxAccount.getWxRemark() != null && !companyWxAccount.getWxRemark().isEmpty()) {
+            CompanyWxAccount wxAccount = companyWxAccountMapper.selectCompanyWxAccountByWxRemark(companyWxAccount.getWxRemark());
+            if (null != wxAccount) {
+                throw new RuntimeException("微信前缀已存在,请更换后重试");
+            }
         }
         companyWxAccount.setCreateTime(DateUtils.getNowDate());
         return companyWxAccountMapper.insertCompanyWxAccount(companyWxAccount);
     }
 
+    /**
+     * 桌面工具新增:昵称/微信号/手机号允许暂空;空串归一为 null,避免唯一索引冲突
+     */
+    @Override
+    public Long createFromTool(CompanyWxAccount companyWxAccount) {
+        if (companyWxAccount == null) {
+            throw new RuntimeException("参数不能为空");
+        }
+        if (companyWxAccount.getWxNickName() != null && companyWxAccount.getWxNickName().trim().isEmpty()) {
+            companyWxAccount.setWxNickName(null);
+        }
+        if (companyWxAccount.getWxNo() != null && companyWxAccount.getWxNo().trim().isEmpty()) {
+            companyWxAccount.setWxNo(null);
+        } else if (companyWxAccount.getWxNo() != null) {
+            companyWxAccount.setWxNo(companyWxAccount.getWxNo().trim());
+        }
+        if (companyWxAccount.getPhone() != null && companyWxAccount.getPhone().trim().isEmpty()) {
+            companyWxAccount.setPhone(null);
+        } else if (companyWxAccount.getPhone() != null) {
+            companyWxAccount.setPhone(companyWxAccount.getPhone().trim());
+        }
+        if (companyWxAccount.getWxNo() != null && !companyWxAccount.getWxNo().isEmpty()) {
+            CompanyWxAccount exists = companyWxAccountMapper.selectOne(new QueryWrapper<CompanyWxAccount>()
+                    .eq("wx_no", companyWxAccount.getWxNo())
+                    .last("limit 1"));
+            if (exists != null) {
+                throw new RuntimeException("微信号已存在,请更换后重试");
+            }
+        }
+        companyWxAccount.setPhone(com.fs.his.utils.PhoneUtil.encryptPhoneIfPlain(companyWxAccount.getPhone()));
+        companyWxAccount.setCreateTime(DateUtils.getNowDate());
+        companyWxAccountMapper.insertCompanyWxAccount(companyWxAccount);
+        return companyWxAccount.getId();
+    }
+
     /**
      * 修改企微账号
      *
@@ -434,6 +471,11 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
 
     private void triggerWorkflowOnAddWxResult(Long wxClientId, boolean success) {
         try {
+            // 先更新加微节点日志:无条件边会提前流转到结束节点,此时实例已不是 WAITING,
+            // 但仍需把节点日志从「等待中」改为「执行成功」,否则执行日志展示错误
+            updateWaitingAddWxExecLogs(wxClientId,
+                    Arrays.asList(NodeTypeEnum.AI_ADD_WX_TASK.getValue(), NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue()));
+
             // 先查老类型的等待中工作流实例
             CompanyAiWorkflowExec waitingExec = companyAiWorkflowExecMapper.selectWaitingAddWxWorkflowByWxClientId(
                     wxClientId,
@@ -450,22 +492,10 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
             }
 
             if (waitingExec == null) {
-                log.info("未找到等待中的加微工作流实例 - wxClientId: {}, success: {}", wxClientId, success);
+                log.info("未找到等待中的加微工作流实例(节点日志已尝试更新)- wxClientId: {}, success: {}", wxClientId, success);
                 return;
             }
 
-            //查询工作流加微执行日志是否未更新状态
-            CompanyAiWorkflowExecLog queryP = new CompanyAiWorkflowExecLog();
-            queryP.setWorkflowInstanceId(waitingExec.getWorkflowInstanceId());
-            queryP.setNodeType(isNewNodeType ? NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue() : NodeTypeEnum.AI_ADD_WX_TASK.getValue());
-            queryP.setStatus(ExecutionStatusEnum.WAITING.getValue());
-            List<CompanyAiWorkflowExecLog> companyAiWorkflowExecLogs = companyAiWorkflowExecLogMapper.selectCompanyAiWorkflowExecLogList(queryP);
-            companyAiWorkflowExecLogs.forEach(log -> {
-                        log.setStatus(ExecutionStatusEnum.SUCCESS.getValue());
-                        companyAiWorkflowExecLogMapper.updateById(log);
-                    }
-            );
-
             String workflowInstanceId = waitingExec.getWorkflowInstanceId();
             String currentNodeKey = waitingExec.getCurrentNodeKey();
 
@@ -501,6 +531,31 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
         }
     }
 
+    /**
+     * 将仍为「等待中」的加微节点执行日志更新为「执行成功」。
+     * 不依赖工作流实例当前是否仍停在加微节点。
+     */
+    private void updateWaitingAddWxExecLogs(Long wxClientId, List<Integer> nodeTypes) {
+        List<CompanyAiWorkflowExecLog> waitingLogs = companyAiWorkflowExecLogMapper.selectWaitingAddWxLogsByWxClientId(
+                wxClientId, ExecutionStatusEnum.WAITING.getValue(), nodeTypes);
+        if (waitingLogs == null || waitingLogs.isEmpty()) {
+            return;
+        }
+        Date now = new Date();
+        waitingLogs.forEach(execLog -> {
+            execLog.setStatus(ExecutionStatusEnum.SUCCESS.getValue());
+            if (execLog.getEndTime() == null) {
+                execLog.setEndTime(now);
+                if (execLog.getStartTime() != null) {
+                    long duration = now.getTime() - execLog.getStartTime().getTime();
+                    execLog.setDuration(duration > 0 ? duration : 1);
+                }
+            }
+            companyAiWorkflowExecLogMapper.updateById(execLog);
+        });
+        log.info("已更新加微节点等待中日志为执行成功 - wxClientId: {}, count: {}", wxClientId, waitingLogs.size());
+    }
+
     public void mockInterface(Long wxClientId){
         triggerWorkflowOnAddWxSuccess(wxClientId);
     }
@@ -510,25 +565,19 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
      */
     public void triggerQwAddWxWorkflowOnSuccess(Long wxClientId) {
         try {
+            // 先更新节点日志(流程可能已提前流转到后续节点)
+            updateWaitingAddWxExecLogs(wxClientId,
+                    Collections.singletonList(NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue()));
+
             CompanyAiWorkflowExec waitingExec = companyAiWorkflowExecMapper.selectWaitingAddWxWorkflowByWxClientId(
                     wxClientId,
                     ExecutionStatusEnum.WAITING.getValue(),
                     NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue());
             if (waitingExec == null) {
-                log.info("未找到等待中的企微加微工作流实例 - wxClientId: {}", wxClientId);
+                log.info("未找到等待中的企微加微工作流实例(节点日志已尝试更新)- wxClientId: {}", wxClientId);
                 return;
             }
 
-            CompanyAiWorkflowExecLog queryP = new CompanyAiWorkflowExecLog();
-            queryP.setWorkflowInstanceId(waitingExec.getWorkflowInstanceId());
-            queryP.setNodeType(NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue());
-            queryP.setStatus(ExecutionStatusEnum.WAITING.getValue());
-            List<CompanyAiWorkflowExecLog> execLogs = companyAiWorkflowExecLogMapper.selectCompanyAiWorkflowExecLogList(queryP);
-            execLogs.forEach(execLog -> {
-                execLog.setStatus(ExecutionStatusEnum.SUCCESS.getValue());
-                companyAiWorkflowExecLogMapper.updateById(execLog);
-            });
-
             String workflowInstanceId = waitingExec.getWorkflowInstanceId();
             String currentNodeKey = waitingExec.getCurrentNodeKey();
 

+ 7 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java

@@ -230,10 +230,14 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
         if (edges == null) {
             return;
         }
+        final boolean[] proceededImmediately = {false};
         edges.forEach(edge -> {
             String conditionExpr = edge.getConditionExpr();
             List<AiCallWorkflowConditionVo> conditions = StringUtils.isBlank(conditionExpr) ? null : JSONObject.parseArray(conditionExpr, AiCallWorkflowConditionVo.class);
             if (null == conditions || conditions.isEmpty()) {
+                // 无条件边:发完申请即继续流转,节点视为执行成功(不再一直停在等待中)
+                proceededImmediately[0] = true;
+                super.updateLogStatusIfExist(context, ExecutionStatusEnum.WAITING, ExecutionStatusEnum.SUCCESS);
                 super.runNextNode(context, edge);
             } else {
                 AiCallWorkflowConditionVo condition = conditions.get(0);
@@ -247,6 +251,9 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
                 }
             }
         });
+        if (proceededImmediately[0]) {
+            log.info("加微无条件边已立即流转,节点日志已标记成功 - workflowInstanceId: {}", workflowInstanceId);
+        }
     }
 
     /**

+ 2 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNode.java

@@ -281,6 +281,8 @@ public class AiAddWxTaskNode extends AbstractWorkflowNode {
             String conditionExpr = edge.getConditionExpr();
             List<AiCallWorkflowConditionVo> conditions = StringUtils.isBlank(conditionExpr) ? null : JSONObject.parseArray(conditionExpr, AiCallWorkflowConditionVo.class);
             if (null == conditions || conditions.isEmpty()) {
+                // 无条件边:发完申请即继续,节点标记为执行成功
+                super.updateLogStatusIfExist(context, ExecutionStatusEnum.WAITING, ExecutionStatusEnum.SUCCESS);
                 super.runNextNode(context, edge);
             } else {
                 AiCallWorkflowConditionVo condition = conditions.get(0);

+ 2 - 2
fs-service/src/main/resources/application-config-druid-hdt.yml

@@ -106,8 +106,8 @@ wx_miniapp_temp:
 feishu:
 #  appId: "cli_aaa27d4aed395bee"
 #  appSecret: "DYh8JVjwnoGT4p4ykHjK7AiA6AzH1x6e"
-  appId: "cli_aac988920f785bd0"
-  appSecret: "B5SqOpNzJ5jsMdcZkwwSPgkON1ZGK7R3"
+  appId: "cli_aafa8f356e799bc1"
+  appSecret: "nBpdLLciDGkqAF2FcM2n2cMVkdtJXd5R"
 # sip外呼配置
 sip:
   call:

+ 35 - 0
fs-service/src/main/resources/mapper/company/CompanyAiWorkflowExecLogMapper.xml

@@ -183,4 +183,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
 
     </select>
+
+    <!-- 根据 wxClientId 查找仍为等待中的加微节点日志(不依赖工作流实例当前状态) -->
+    <select id="selectWaitingAddWxLogsByWxClientId" resultType="CompanyAiWorkflowExecLog">
+        SELECT t1.*
+        FROM company_ai_workflow_exec_log t1
+                 INNER JOIN company_ai_workflow_exec t2 ON t1.workflow_instance_id = t2.workflow_instance_id
+                 INNER JOIN company_voice_robotic_business t3 ON t2.business_key = t3.id
+        WHERE t3.wx_client_id = #{wxClientId}
+          AND t1.status = #{status}
+          AND t1.node_type IN
+        <foreach collection="nodeTypes" item="nt" open="(" separator="," close=")">
+            #{nt}
+        </foreach>
+    </select>
+
+    <!-- 纠偏:加微节点日志仍为等待中,但流程已结束,或客户已加微成功 -->
+    <update id="reconcileWaitingAddWxLogsByRoboticId">
+        UPDATE company_ai_workflow_exec_log t1
+            INNER JOIN company_ai_workflow_exec t2 ON t1.workflow_instance_id = t2.workflow_instance_id
+            INNER JOIN company_voice_robotic_business t3 ON t2.business_key = t3.id
+            LEFT JOIN company_wx_client t4 ON t3.wx_client_id = t4.id
+        SET t1.status = #{successStatus},
+            t1.end_time = IFNULL(t1.end_time, NOW())
+        WHERE t3.robotic_id = #{roboticId}
+          AND t1.status = #{waitingStatus}
+          AND t1.node_type IN
+        <foreach collection="nodeTypes" item="nt" open="(" separator="," close=")">
+            #{nt}
+        </foreach>
+          AND (
+            t4.is_add = 1
+            OR t2.status = 1
+            OR t2.current_node_type = 2
+          )
+    </update>
 </mapper>

+ 4 - 0
fs-service/src/main/resources/mapper/company/CompanyWxAccountMapper.xml

@@ -54,7 +54,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="wxNickName != null">wx_nick_name,</if>
             <if test="phone != null">phone,</if>
             <if test="wxNo != null">wx_no,</if>
+            <if test="companyId != null">company_id,</if>
             <if test="companyUserId != null">company_user_id,</if>
+            <if test="wxRemark != null">wx_remark,</if>
             <if test="createTime != null">create_time,</if>
             <if test="createUser != null">create_user,</if>
          </trim>
@@ -62,7 +64,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="wxNickName != null">#{wxNickName},</if>
             <if test="phone != null">#{phone},</if>
             <if test="wxNo != null">#{wxNo},</if>
+            <if test="companyId != null">#{companyId},</if>
             <if test="companyUserId != null">#{companyUserId},</if>
+            <if test="wxRemark != null">#{wxRemark},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="createUser != null">#{createUser},</if>
          </trim>

+ 46 - 10
fs-wx-api/src/main/java/com/fs/app/controller/AppBaseController.java

@@ -2,31 +2,67 @@ package com.fs.app.controller;
 
 
 import com.fs.app.utils.JwtUtils;
-import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
 import io.jsonwebtoken.Claims;
 import org.springframework.beans.factory.annotation.Autowired;
 
+import javax.servlet.http.HttpServletRequest;
+
 
 public class AppBaseController {
 	@Autowired
 	JwtUtils jwtUtils;
 
+	/**
+	 * 解析当前登录用户 ID。
+	 * 兼容数字员工 Authorization: Bearer xxx,以及历史 APPToken / AppToken。
+	 */
 	public String getUserId()
 	{
-		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
-		Claims claims=jwtUtils.getClaimByToken(headValue);
-		String userId = claims.getSubject().toString();
-		return userId;
+		String token = resolveToken(ServletUtils.getRequest());
+		if (StringUtils.isBlank(token)) {
+			throw new RuntimeException("未登录或登录已失效");
+		}
+		Claims claims = jwtUtils.getClaimByToken(token);
+		if (claims == null || jwtUtils.isTokenExpired(claims.getExpiration())) {
+			throw new RuntimeException("未登录或登录已失效");
+		}
+		return claims.getSubject();
 	}
 
 	public String getAccountId()
 	{
-		String headValue =  ServletUtils.getRequest().getHeader("APPWxToken");
-		Claims claims=jwtUtils.getClaimByToken(headValue);
-		String userId = claims.getSubject().toString();
-		return userId;
+		HttpServletRequest request = ServletUtils.getRequest();
+		String headValue = request.getHeader("APPWxToken");
+		if (StringUtils.isBlank(headValue)) {
+			headValue = request.getHeader("AppWxToken");
+		}
+		Claims claims = jwtUtils.getClaimByToken(headValue);
+		return claims.getSubject();
 	}
 
-
+	private String resolveToken(HttpServletRequest request) {
+		if (request == null) {
+			return null;
+		}
+		// 数字员工:Authorization: Bearer <token>
+		String authorization = request.getHeader("Authorization");
+		if (StringUtils.isNotBlank(authorization)) {
+			String value = authorization.trim();
+			if (value.regionMatches(true, 0, "Bearer ", 0, 7)) {
+				return value.substring(7).trim();
+			}
+			return value;
+		}
+		// 兼容历史头
+		String token = request.getHeader("APPToken");
+		if (StringUtils.isBlank(token)) {
+			token = request.getHeader("AppToken");
+		}
+		if (StringUtils.isBlank(token) && StringUtils.isNotBlank(jwtUtils.getHeader())) {
+			token = request.getHeader(jwtUtils.getHeader());
+		}
+		return token;
+	}
 }

+ 98 - 0
fs-wx-api/src/main/java/com/fs/app/controller/WxAccountController.java

@@ -0,0 +1,98 @@
+package com.fs.app.controller;
+
+import com.fs.app.vo.BoundWxAccountVO;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.domain.CompanyWxAccount;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.company.service.ICompanyWxAccountService;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 数字员工桌面工具:当前销售个微账号查询 / 新增
+ * 从 saas fs-wx-api 迁入,使用本项目 company 账号体系
+ */
+@Slf4j
+@RestController
+@RequestMapping("/app/wxAccount")
+public class WxAccountController extends AppBaseController {
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @ApiOperation("当前销售绑定个微列表")
+    @GetMapping("/myBoundList")
+    public R myBoundList() {
+        Long userId;
+        try {
+            userId = Long.parseLong(getUserId());
+        } catch (Exception e) {
+            return R.error(401, "未登录或登录已失效");
+        }
+        CompanyWxAccount query = new CompanyWxAccount();
+        query.setCompanyUserId(userId);
+        List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountList(query);
+        if (list == null || list.isEmpty()) {
+            return R.ok().put("data", Collections.emptyList());
+        }
+        List<BoundWxAccountVO> result = list.stream().map(item -> {
+            BoundWxAccountVO vo = new BoundWxAccountVO();
+            vo.setAccountId(item.getId());
+            vo.setWxNo(item.getWxNo());
+            vo.setWxNickName(item.getWxNickName());
+            vo.setPhone(item.getPhone());
+            return vo;
+        }).collect(Collectors.toList());
+        return R.ok().put("data", result);
+    }
+
+    @ApiOperation("工具新增个微账号")
+    @PostMapping("/create")
+    public R create(@RequestBody(required = false) CompanyWxAccount body) {
+        try {
+            Long userId = Long.parseLong(getUserId());
+            CompanyUser user = companyUserService.selectCompanyUserById(userId);
+            if (user == null) {
+                return R.error("当前登录用户不存在");
+            }
+            if (user.getCompanyId() == null) {
+                return R.error("当前登录用户未绑定销售公司");
+            }
+            CompanyWxAccount account = new CompanyWxAccount();
+            if (body != null) {
+                account.setWxNickName(body.getWxNickName());
+                account.setWxNo(body.getWxNo());
+                account.setPhone(body.getPhone());
+                account.setWxRemark(body.getWxRemark());
+                account.setHeadImgUrl(body.getHeadImgUrl());
+            }
+            account.setCompanyId(user.getCompanyId());
+            account.setCompanyUserId(user.getUserId());
+            account.setCreateUser(user.getUserId());
+            Long accountId = companyWxAccountService.createFromTool(account);
+            return R.ok()
+                    .put("data", accountId)
+                    .put("accountId", accountId)
+                    .put("wxNo", account.getWxNo())
+                    .put("wxNickName", account.getWxNickName());
+        } catch (Exception e) {
+            log.error("工具新增个微账号失败", e);
+            return R.error(StringUtils.isNotEmpty(e.getMessage()) ? e.getMessage() : "工具新增个微账号失败");
+        }
+    }
+}

+ 79 - 0
fs-wx-api/src/main/java/com/fs/app/controller/auth/CaptchaController.java

@@ -0,0 +1,79 @@
+package com.fs.app.controller.auth;
+
+import com.fs.common.constant.Constants;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.sign.Base64;
+import com.fs.common.utils.uuid.IdUtils;
+import com.google.code.kaptcha.Producer;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.util.FastByteArrayOutputStream;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.imageio.ImageIO;
+import javax.servlet.http.HttpServletResponse;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 验证码(数字员工 / 工具端登录用)
+ * 从 saas fs-wx-api 迁入,本项目始终开启验证码
+ */
+@RestController
+public class CaptchaController {
+
+    @Resource(name = "captchaProducer")
+    private Producer captchaProducer;
+
+    @Resource(name = "captchaProducerMath")
+    private Producer captchaProducerMath;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Value("${fs.captchaType}")
+    private String captchaType;
+
+    @GetMapping("/captchaImage")
+    public AjaxResult getCode(HttpServletResponse response) throws IOException {
+        AjaxResult ajax = AjaxResult.success();
+        ajax.put("captchaOnOff", true);
+
+        String uuid = IdUtils.simpleUUID();
+        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
+
+        String capStr;
+        String code;
+        BufferedImage image;
+
+        if ("math".equals(captchaType)) {
+            String capText = captchaProducerMath.createText();
+            capStr = capText.substring(0, capText.lastIndexOf("@"));
+            code = capText.substring(capText.lastIndexOf("@") + 1);
+            image = captchaProducerMath.createImage(capStr);
+        } else {
+            capStr = code = captchaProducer.createText();
+            image = captchaProducer.createImage(capStr);
+        }
+        if (StringUtils.isEmpty(code) || image == null) {
+            return AjaxResult.error("验证码生成失败");
+        }
+
+        redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
+        FastByteArrayOutputStream os = new FastByteArrayOutputStream();
+        try {
+            ImageIO.write(image, "jpg", os);
+        } catch (IOException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+
+        ajax.put("uuid", uuid);
+        ajax.put("img", Base64.encode(os.toByteArray()));
+        return ajax;
+    }
+}

+ 92 - 0
fs-wx-api/src/main/java/com/fs/app/controller/auth/ToolLoginController.java

@@ -0,0 +1,92 @@
+package com.fs.app.controller.auth;
+
+import com.fs.app.param.ToolLoginBody;
+import com.fs.app.utils.JwtUtils;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyUserService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 数字员工工具端登录:company_user + 验证码,不校验租户编号
+ * 接口路径与 SaaS 对齐:POST /login,供 数字员工 remote_api_base 调用
+ */
+@Slf4j
+@RestController
+public class ToolLoginController {
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private JwtUtils jwtUtils;
+
+    @PostMapping("/login")
+    public AjaxResult login(@RequestBody ToolLoginBody body) {
+        if (StringUtils.isEmpty(body.getUsername()) || StringUtils.isEmpty(body.getPassword())) {
+            return AjaxResult.error("账号或密码不能为空");
+        }
+        try {
+            validateCaptcha(body.getUsername(), body.getCode(), body.getUuid());
+            CompanyUser user = companyUserService.selectUserByUserName(body.getUsername());
+            if (user == null) {
+                return AjaxResult.error("用户不存在/密码错误");
+            }
+            if (!"0".equals(user.getStatus())) {
+                return AjaxResult.error("账号已停用");
+            }
+            if (user.getDelFlag() != null && !"0".equals(user.getDelFlag())) {
+                return AjaxResult.error("账号已被删除");
+            }
+            if (!SecurityUtils.matchesPassword(body.getPassword(), user.getPassword())) {
+                return AjaxResult.error("用户不存在/密码错误");
+            }
+
+            String token = jwtUtils.generateToken(user.getUserId());
+            redisCache.setCacheObject("AiChatToken:" + user.getUserId(), token, 604800, TimeUnit.SECONDS);
+            if (user.getCompanyId() != null) {
+                redisCache.setCacheObject("companyId:" + user.getUserId(), user.getCompanyId(), 604800, TimeUnit.SECONDS);
+            }
+            log.info("数字员工工具端登录成功 userId={}, userName={}, companyId={}",
+                    user.getUserId(), user.getUserName(), user.getCompanyId());
+
+            AjaxResult ajax = AjaxResult.success();
+            ajax.put(Constants.TOKEN, token);
+            return ajax;
+        } catch (ServiceException e) {
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("数字员工工具端登录失败", e);
+            return AjaxResult.error("登录失败,请稍后重试");
+        }
+    }
+
+    private void validateCaptcha(String username, String code, String uuid) {
+        if (StringUtils.isEmpty(uuid) || StringUtils.isEmpty(code)) {
+            throw new ServiceException("验证码不能为空");
+        }
+        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid.trim();
+        String captcha = redisCache.getCacheObject(verifyKey);
+        redisCache.deleteObject(verifyKey);
+        if (captcha == null) {
+            throw new ServiceException("验证码已失效");
+        }
+        if (!code.trim().equalsIgnoreCase(captcha.trim())) {
+            throw new ServiceException("验证码错误");
+        }
+    }
+}

+ 23 - 5
fs-wx-api/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -40,11 +40,7 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
-        //获取用户凭证
-        String token = request.getHeader(jwtUtils.getHeader());
-        if(StringUtils.isBlank(token)){
-            token = request.getParameter(jwtUtils.getHeader());
-        }
+        String token = resolveToken(request);
 
         //凭证为空
         if(StringUtils.isBlank(token)){
@@ -60,4 +56,26 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
 
         return true;
     }
+
+    private String resolveToken(HttpServletRequest request) {
+        String authorization = request.getHeader("Authorization");
+        if (StringUtils.isNotBlank(authorization)) {
+            String value = authorization.trim();
+            if (value.regionMatches(true, 0, "Bearer ", 0, 7)) {
+                return value.substring(7).trim();
+            }
+            return value;
+        }
+        String token = request.getHeader(jwtUtils.getHeader());
+        if (StringUtils.isBlank(token)) {
+            token = request.getHeader("APPToken");
+        }
+        if (StringUtils.isBlank(token)) {
+            token = request.getHeader("AppToken");
+        }
+        if (StringUtils.isBlank(token)) {
+            token = request.getParameter(jwtUtils.getHeader());
+        }
+        return token;
+    }
 }

+ 25 - 0
fs-wx-api/src/main/java/com/fs/app/param/ToolLoginBody.java

@@ -0,0 +1,25 @@
+package com.fs.app.param;
+
+import lombok.Data;
+
+/**
+ * 数字员工工具端登录入参(兼容前端字段,忽略租户)
+ */
+@Data
+public class ToolLoginBody {
+
+    private String username;
+
+    private String password;
+
+    /** 验证码 */
+    private String code;
+
+    /** 验证码唯一标识 */
+    private String uuid;
+
+    /**
+     * 兼容 SaaS 前端传参,本项目不校验、不使用
+     */
+    private String tenantCode;
+}

+ 18 - 0
fs-wx-api/src/main/java/com/fs/app/vo/BoundWxAccountVO.java

@@ -0,0 +1,18 @@
+package com.fs.app.vo;
+
+import lombok.Data;
+
+/**
+ * 当前销售绑定个微简要信息
+ */
+@Data
+public class BoundWxAccountVO {
+    /** 个微账号主键 */
+    private Long accountId;
+    /** 微信号 */
+    private String wxNo;
+    /** 微信昵称 */
+    private String wxNickName;
+    /** 手机号 */
+    private String phone;
+}

+ 46 - 0
fs-wx-api/src/main/java/com/fs/app/websocket/service/WebSocketPathServer.java

@@ -0,0 +1,46 @@
+package com.fs.app.websocket.service;
+
+import com.fs.common.utils.spring.SpringUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.PathParam;
+import javax.websocket.server.ServerEndpoint;
+
+/**
+ * 兼容旧连接格式:ws://host:7113/app/webSocket/{wxId}
+ */
+@Slf4j
+@Component
+@ServerEndpoint("/app/webSocket/{wxId}")
+public class WebSocketPathServer {
+
+    private WebSocketServer delegate() {
+        return SpringUtils.getBean(WebSocketServer.class);
+    }
+
+    @OnOpen
+    public void onOpen(Session session, @PathParam("wxId") String wxId) {
+        delegate().bindAndOpen(session, wxId);
+    }
+
+    @OnClose
+    public void onClose(@PathParam("wxId") String wxId) {
+        delegate().closeByWxId(wxId);
+    }
+
+    @OnMessage
+    public void onMessage(String message, Session session, @PathParam("wxId") String wxId) {
+        delegate().handleMessage(message, wxId, session);
+    }
+
+    @OnError
+    public void onError(Session session, Throwable throwable) {
+        log.error("发生错误;{}", throwable.getMessage());
+    }
+}

+ 78 - 10
fs-wx-api/src/main/java/com/fs/app/websocket/service/WebSocketServer.java

@@ -31,23 +31,24 @@ import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Component;
 
 import javax.websocket.*;
-import javax.websocket.server.PathParam;
 import javax.websocket.server.ServerEndpoint;
 import java.io.IOException;
-import java.lang.reflect.Method;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executor;
 
 @Slf4j
 @Component
-@ServerEndpoint("/app/webSocket/{wxId}")
+@ServerEndpoint("/app/webSocket")
 public class WebSocketServer {
 
+    private static final String ATTR_WX_ID = "wxId";
+
     //concurrent包的线程安全Set,用来存放每个客户端对应的WebSocketServer对象。
     public static ConcurrentHashMap<String, Session> sessionPools = new ConcurrentHashMap<>();
     RedisCache redisCache = SpringUtils.getBean(RedisCache.class);
@@ -75,9 +76,27 @@ public class WebSocketServer {
             }
         }
     }
-    //建立连接成功调用
+
+    /**
+     * 数字员工连接格式:ws://host:7113/app/webSocket?token=xxx&amp;wxId=yyy
+     * 兼容旧路径:/app/webSocket/{wxId}(由 WebSocketPathServer 转发)
+     */
     @OnOpen
-    public void onOpen(Session session, @PathParam(value = "wxId") String wxId) {
+    public void onOpen(Session session) {
+        String wxId = resolveWxId(session, null);
+        bindAndOpen(session, wxId);
+    }
+
+    public void bindAndOpen(Session session, String wxId) {
+        if (StringUtils.isEmpty(wxId)) {
+            sendMessage(session, ResultMsgVo.error("缺少wxId"));
+            try {
+                session.close();
+            } catch (IOException ignored) {
+            }
+            return;
+        }
+        session.getUserProperties().put(ATTR_WX_ID, wxId);
         CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
         if(companyWxAccount == null){
             sendMessage(session, ResultMsgVo.error("未找到对应微信数据"));
@@ -95,7 +114,15 @@ public class WebSocketServer {
 
     //关闭连接时调用
     @OnClose
-    public void onClose(@PathParam(value = "wxId") String wxId) {
+    public void onClose(Session session) {
+        String wxId = resolveWxId(session, null);
+        closeByWxId(wxId);
+    }
+
+    public void closeByWxId(String wxId) {
+        if (StringUtils.isEmpty(wxId)) {
+            return;
+        }
         sessionPools.remove(wxId);
         CompanyWxAccount companyWxAccount = accountMapper.selectOne(new QueryWrapper<CompanyWxAccount>().eq("wx_no", wxId));
         if(companyWxAccount != null){
@@ -109,14 +136,25 @@ public class WebSocketServer {
 
     //收到客户端信息
     @OnMessage
-    public void onMessage(String message, @PathParam(value = "wxId") String wxId) {
+    public void onMessage(String message, Session session) {
+        String wxId = resolveWxId(session, null);
+        handleMessage(message, wxId, session);
+    }
+
+    public void handleMessage(String message, String wxId) {
+        handleMessage(message, wxId, null);
+    }
+
+    public void handleMessage(String message, String wxId, Session incomingSession) {
         SendMsgVo msg = JSONObject.parseObject(message, SendMsgVo.class);
-        if(msg.getType() == 0){
+        if(msg == null || msg.getType() == 0){
             return;
         }
-        Session session = sessionPools.get(wxId);
+        Session session = StringUtils.isEmpty(wxId) ? null : sessionPools.get(wxId);
         if(session == null){
-            log.error("参数异常:{}", wxId);
+            // 常见于切换账号后旧 WS 未断开,仍在上报心跳/同步,忽略并关闭孤儿连接
+            log.warn("忽略离线会话消息 wxId={} cmd={},尝试关闭残留连接", wxId, msg.getCmd());
+            closeQuietly(incomingSession);
             return;
         }
         log.info("收到数据:{}", msg.getCmd());
@@ -253,6 +291,36 @@ public class WebSocketServer {
         throwable.printStackTrace();
     }
 
+    private String resolveWxId(Session session, String pathWxId) {
+        if (StringUtils.isNotEmpty(pathWxId)) {
+            return pathWxId.trim();
+        }
+        if (session == null) {
+            return null;
+        }
+        Object cached = session.getUserProperties().get(ATTR_WX_ID);
+        if (cached != null && StringUtils.isNotEmpty(String.valueOf(cached))) {
+            return String.valueOf(cached).trim();
+        }
+        Map<String, List<String>> params = session.getRequestParameterMap();
+        if (params != null && params.get("wxId") != null && !params.get("wxId").isEmpty()) {
+            return params.get("wxId").get(0);
+        }
+        if (params != null && params.get("wx_id") != null && !params.get("wx_id").isEmpty()) {
+            return params.get("wx_id").get(0);
+        }
+        return null;
+    }
+
+    private void closeQuietly(Session session) {
+        if (session == null || !session.isOpen()) {
+            return;
+        }
+        try {
+            session.close();
+        } catch (IOException ignored) {
+        }
+    }
 
     /**
      * 标记加微回调已到达(暂停期间),便于恢复时补触发