package com.qqflow.engine.domain.ewechat.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.qqflow.engine.common.service.RedissonService; import com.qqflow.engine.domain.ewechat.api.EnterpriseWechatApi; import com.qqflow.engine.domain.ewechat.config.EnterpriseWechatProperties; import com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatCorpMapper; import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendTextCardMsgReq; import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendTextMsgReq; import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.EnterpriseWechatSendTextMsgRsp; import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp; import com.qqflow.engine.domain.system.entity.SysUser; import com.qqflow.engine.domain.system.entity.SysUserRole; import com.qqflow.engine.domain.system.mapper.SysUserMapper; import com.qqflow.engine.domain.system.mapper.SysUserRoleMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 企业微信消息发送服务 */ @Slf4j @Service @RequiredArgsConstructor public class EnterpriseWechatMsgService { private final EnterpriseWechatApi enterpriseWechatApi; private final EnterpriseWechatProperties wechatProperties; private final EnterpriseWechatCorpMapper wechatCorpMapper; private final SysUserMapper sysUserMapper; private final SysUserRoleMapper sysUserRoleMapper; private final RedissonService redissonService; private static final String ACCESS_TOKEN_CACHE_KEY = "wecom:access_token:"; /** access_token 有效期 7200 秒,缓存 7000 秒留余量 */ private static final Duration ACCESS_TOKEN_TTL = Duration.ofSeconds(7000); /** 给指定企微用户ID列表发送文本消息;返回是否被企微接口成功接收 */ public boolean sendTextToUsers(List wecomUserIds, String content) { if (wecomUserIds == null || wecomUserIds.isEmpty()) return false; boolean allOk = true; // 多企业部署:接收人按所属企业分组,各自用本企业的 token + agentid 发送 for (Map.Entry> entry : groupUserIdsByCorp(wecomUserIds).entrySet()) { EnterpriseWechatCorp corp = this.getCorpById(entry.getKey()); List corpUserIds = entry.getValue(); String accessToken = this.getCorpAccessToken(corp); if (accessToken == null) { log.warn("[企微消息] 无法获取企业 {} 的 access_token,跳过 {} 人", entry.getKey(), corpUserIds.size()); allOk = false; continue; } try { EnterpriseWechatSendTextMsgReq req = EnterpriseWechatSendTextMsgReq.toUsers( corpUserIds, this.resolveAgentId(corp), content); EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextMsg(accessToken, req); allOk &= logSendResult(rsp, "文本消息", corpUserIds.size()); } catch (Exception e) { log.error("[企微消息] 发送失败 corpId={}", entry.getKey(), e); allOk = false; } } return allOk; } /** 给指定企微用户ID列表发送文本卡片消息(可点击跳转,用于审批待办/催办);返回是否发送成功 */ public boolean sendTextCardToUsers(List wecomUserIds, String title, String description, String url, String btnText) { if (wecomUserIds == null || wecomUserIds.isEmpty()) return false; boolean allOk = true; // 多企业部署:接收人按所属企业分组发送,卡片链接携带 corpId 供 H5 免登按企业路由 for (Map.Entry> entry : groupUserIdsByCorp(wecomUserIds).entrySet()) { EnterpriseWechatCorp corp = this.getCorpById(entry.getKey()); List corpUserIds = entry.getValue(); String accessToken = this.getCorpAccessToken(corp); if (accessToken == null) { log.warn("[企微消息] 无法获取企业 {} 的 access_token,跳过 {} 人", entry.getKey(), corpUserIds.size()); allOk = false; continue; } try { EnterpriseWechatSendTextCardMsgReq req = EnterpriseWechatSendTextCardMsgReq.toUsers( corpUserIds, this.resolveAgentId(corp), title, description, appendCorpIdToUrl(url, entry.getKey()), btnText); EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextCardMsg(accessToken, req); allOk &= logSendResult(rsp, "文本卡片", corpUserIds.size()); } catch (Exception e) { log.error("[企微消息] 文本卡片发送失败 corpId={}", entry.getKey(), e); allOk = false; } } return allOk; } /** 接收人按所属企业分组:wecom_user_id → sys_user.corp_id;无记录/为空 → 默认授权企业 */ private Map> groupUserIdsByCorp(List wecomUserIds) { List distinct = wecomUserIds.stream().distinct().collect(Collectors.toList()); Map userCorpMap = new HashMap<>(); sysUserMapper.selectBatchByWecomUserIds(distinct).forEach(u -> userCorpMap.putIfAbsent(u.getWecomUserId(), u.getCorpId())); EnterpriseWechatCorp defaultCorp = this.getAuthorizedCorp(); String defaultCorpId = defaultCorp == null ? null : defaultCorp.getCorpId(); Map> grouped = new LinkedHashMap<>(); for (String userid : wecomUserIds) { String corpId = userCorpMap.getOrDefault(userid, defaultCorpId); grouped.computeIfAbsent(corpId, k -> new ArrayList<>()).add(userid); } return grouped; } /** 按 corp_id 查企业;为空回退默认授权企业 */ public EnterpriseWechatCorp getCorpById(String corpId) { if (corpId == null || corpId.isBlank()) { return this.getAuthorizedCorp(); } return wechatCorpMapper.selectByCorpId(corpId); } /** 发送用的应用 agentid:优先企业自身(代开发各企业 agentid 不同),为空回退全局配置 */ public Integer resolveAgentId(EnterpriseWechatCorp corp) { return corp != null && corp.getAgentId() != null ? corp.getAgentId() : wechatProperties.getAgentId(); } /** 卡片链接追加 corpId 参数(H5 免登按企业取 appid/token 用) */ private String appendCorpIdToUrl(String url, String corpId) { if (url == null || corpId == null || corpId.isBlank()) { return url; } return url + (url.contains("?") ? "&" : "?") + "corpId=" + corpId; } /** * 记录企微接口真实返回码:errcode=0 才算成功。 * 之前只看 HTTP 异常,errcode 非 0(如 60020 IP 不在白名单)会被当成发送成功。 */ private boolean logSendResult(EnterpriseWechatSendTextMsgRsp rsp, String scene, int userCount) { if (rsp == null) { log.error("[企微消息] {}无响应 | 接收人数:{}", scene, userCount); return false; } if (rsp.getErrcode() == null || rsp.getErrcode() == 0) { log.info("[企微消息] {}已发送 | 接收人数:{} | msgid:{}", scene, userCount, rsp.getMsgid()); return true; } log.error("[企微消息] {}发送失败 | 接收人数:{} | errcode={} errmsg={} invaliduser={}", scene, userCount, rsp.getErrcode(), rsp.getErrmsg(), rsp.getInvaliduser()); return false; } /** 给指定角色下的所有企微用户发送文本消息 */ public void sendTextToRole(Long roleId, String content) { List wecomUserIds = this.getRoleWecomUserIds(roleId); this.sendTextToUsers(wecomUserIds, content); } /** 给指定用户发送文本消息(如果该用户有企微账号) */ public void sendTextToUser(Long userId, String content) { SysUser user = sysUserMapper.selectById(userId); if (user != null && user.getWecomUserId() != null && !user.getWecomUserId().isBlank()) { this.sendTextToUsers(Collections.singletonList(user.getWecomUserId()), content); } } /** 获取角色下所有带企微账号的用户ID列表 */ public List getRoleWecomUserIds(Long roleId) { List userRoles = sysUserRoleMapper.selectList( new LambdaQueryWrapper().eq(SysUserRole::getRoleId, roleId)); return userRoles.stream() .map(ur -> sysUserMapper.selectById(ur.getUserId())) .filter(u -> u != null && u.getWecomUserId() != null && !u.getWecomUserId().isBlank()) .map(SysUser::getWecomUserId) .distinct() .collect(Collectors.toList()); } /** 当前已授权的代开发企业(取第一条有永久授权码的,单企业部署场景) */ public EnterpriseWechatCorp getAuthorizedCorp() { List corps = wechatCorpMapper.selectList( new LambdaQueryWrapper() .eq(EnterpriseWechatCorp::getSuiteId, wechatProperties.getSuiteId()) .isNotNull(EnterpriseWechatCorp::getPermanentCode) .last("LIMIT 1")); return corps.isEmpty() ? null : corps.get(0); } /** 获取默认授权企业的 access_token(未绑定企业的用户/内部调用使用) */ public String getCorpAccessToken() { return this.getCorpAccessToken(this.getAuthorizedCorp()); } /** 获取指定企业的 access_token(Redis 按 corp_id 缓存,TTL 7000s) */ public String getCorpAccessToken(EnterpriseWechatCorp corp) { if (corp == null) return null; String cacheKey = ACCESS_TOKEN_CACHE_KEY + corp.getCorpId(); String cached = redissonService.get(cacheKey); if (cached != null) return cached; try { String token = enterpriseWechatApi.getAccessToken(corp.getCorpId(), corp.getPermanentCode()).getAccessToken(); redissonService.set(cacheKey, token, ACCESS_TOKEN_TTL); return token; } catch (Exception e) { log.error("[企微消息] 获取 access_token 失败, corpId={}", corp.getCorpId(), e); return null; } } }