Compare commits

..

2 Commits

Author SHA1 Message Date
dzq 8e6131c197 feat(qywx): 新增企业微信相关功能及定时任务
- 在WeixinConstants中添加agentid和corpid常量
- 扩展QywxApiUtil的sendNewsMessage方法,支持toparty和totag参数
- 在ShopController中新增企业微信授权重定向接口
- 在ReturnApprovalApplicationService中添加发送退货审核通知的功能
- 在QywxScheduleJob中新增定时获取企业授权信息的任务
2025-04-14 16:54:46 +08:00
dzq 2453b7bea7 feat(qywx): 新增企业微信API相关功能及响应类
- 新增NewsArticle、NewsMessageResponse、GetAuthInfoResult等响应类,用于处理企业微信API的返回数据
- 在QywxApiUtil中新增sendNewsMessage和getAuthInfo方法,支持发送图文消息和获取企业授权信息
- 在QywxController中新增getAuthInfo接口,用于获取企业授权信息
- 在ReturnApprovalApplicationService中更新商品库存逻辑,确保退货审批通过后更新商品库存
2025-04-14 08:10:38 +08:00
11 changed files with 1335 additions and 8 deletions

View File

@ -9,8 +9,12 @@ import com.agileboot.domain.qywx.api.QywxApiUtil;
import com.agileboot.domain.qywx.api.response.DepartmentInfoResponse;
import com.agileboot.domain.qywx.api.response.DepartmentInfoResponse.Department;
import com.agileboot.domain.qywx.api.response.DepartmentListResponse;
import com.agileboot.domain.qywx.api.response.GetAuthInfoResult;
import com.agileboot.domain.qywx.api.response.GetAuthInfoResult.Agent;
import com.agileboot.domain.qywx.api.response.GetAuthInfoResult.AuthCorpInfo;
import com.agileboot.domain.qywx.api.response.UserListResponse;
import com.agileboot.domain.qywx.authCorpInfo.AuthCorpInfoApplicationService;
import com.agileboot.domain.qywx.authCorpInfo.command.UpdateAuthCorpInfoCommand;
import com.agileboot.domain.qywx.authCorpInfo.db.QyAuthCorpInfoEntity;
import com.agileboot.domain.qywx.department.DepartmentApplicationService;
import com.agileboot.domain.qywx.department.command.AddDepartmentCommand;
@ -46,6 +50,15 @@ import org.springframework.beans.BeanUtils;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 企业微信定时任务调度器
* 包含以下核心功能
* 1. 定时获取企业微信应用凭证suite_access_token
* 2. 定时刷新企业微信访问令牌access_token
* 3. 定时同步企业微信组织架构到本地数据库
* 4. 定时同步企业微信用户信息到本地数据库
* 定时任务均采用每小时固定分钟数执行策略避免接口调用过于频繁
*/
@RequiredArgsConstructor
@Component
@Slf4j
@ -59,8 +72,25 @@ public class QywxScheduleJob {
private final UserModelFactory userModelFactory;
// private static final String appid = "QYTONG_YS_WXSHOP";
/**
* 企业微信应用ID常量
* 用于标识当前集成的第三方应用对应企业微信服务商后台配置的应用凭证
*/
private static final String appid2 = "QWTONG_YS_WXSHOP";
/**
* 定时获取第三方应用凭证suite_access_token
* 功能说明
* 1. 每小时第10分钟执行一次
* 2. 根据应用ID获取企业微信应用配置
* 3. 调用企业微信API获取最新的suite_access_token
* 4. 更新本地存储的凭证信息
* 执行流程
* - 通过appid查询应用模板配置
* - 使用suite_idsecret和suite_ticket获取新凭证
* - 将新凭证更新至数据库
* 异常处理捕获并记录异常日志保证任务继续执行
*/
@Scheduled(cron = "0 10 * * * *")
public void getSuiteAccessTokenTask() {
/*try {
@ -97,6 +127,18 @@ public class QywxScheduleJob {
}
}
/**
* 定时刷新访问令牌access_token
* 功能说明
* 1. 每小时第20分钟执行一次
* 2. 遍历所有已授权企业
* 3. 为每个企业获取新的access_token
* 4. 更新本地存储的访问令牌
* 关键参数
* - corpid企业微信ID
* - permanent_code企业永久授权码
* 安全机制不同企业间凭证隔离存储
*/
@Scheduled(cron = "0 20 * * * *")
public void getAccessTokenTask() {
/*try {
@ -133,6 +175,18 @@ public class QywxScheduleJob {
}
}
/**
* 定时同步组织架构信息
* 功能说明
* 1. 每小时第30分钟执行一次
* 2. 获取企业微信部门列表
* 3. 对比本地数据库进行增量同步
* 同步策略
* - 新增企业微信存在但本地不存在的部门
* - 更新名称或父部门发生变化的部门
* - 删除本地存在但企业微信不存在的部门
* 数据一致性通过corpid保证多企业数据隔离
*/
@Scheduled(cron = "0 30 * * * *")
public void syncDepartmentInfoTask() {
/*try {
@ -295,6 +349,18 @@ public class QywxScheduleJob {
}
}
/**
* 定时同步用户信息
* 功能说明
* 1. 每小时第40分钟执行一次
* 2. 按部门遍历企业微信用户
* 3. 三向对比本地/企业微信/部门进行同步
* 同步维度
* - 基础信息姓名手机邮箱等
* - 组织关系所属部门职位信息
* - 状态变更离职/禁用用户
* 数据关联通过userid保持用户唯一标识
*/
@Scheduled(cron = "0 40 * * * *")
public void syncUserInfoTask() {
/*try {
@ -503,6 +569,91 @@ public class QywxScheduleJob {
}
}
/**
* 定时获取企业授权信息
* 功能说明
* 1. 每小时第45分钟执行一次
* 2. 通过永久授权码获取企业基本信息应用权限及授权管理员列表
* 3. 更新本地存储的授权企业信息
* 关键参数
* - corpid企业微信ID
* - permanent_code企业永久授权码
* API对应关系
* - 企业微信获取企业授权信息接口
* 异常处理捕获并记录异常日志保证任务继续执行
*/
@Scheduled(cron = "0 45 * * * *")
public void getAuthInfoTask() {
try {
getAuthInfo(appid2);
} catch (Exception e) {
log.error("getAuthInfoTask error appid: " + appid2, e);
}
}
public void getAuthInfo(String appid) {
log.info("getAuthInfo Current Thread : {}, Fixed Rate Task : The time is now {}",
Thread.currentThread().getName(), DateUtil.formatTime(new Date()));
try {
List<QyAuthCorpInfoEntity> authCorpInfoList = authCorpInfoApplicationService.getByAppid(appid);
QyTemplateEntity template = templateApplicationService.getByAppid(appid);
if (template == null || authCorpInfoList == null) {
return;
}
String suiteAccessToken = template.getSuiteAccessToken();
if (StringUtils.isBlank(suiteAccessToken)) {
log.error("getAuthInfo suiteAccessToken is null");
return;
}
for (QyAuthCorpInfoEntity authCorp : authCorpInfoList) {
GetAuthInfoResult result = QywxApiUtil.getAuthInfo(suiteAccessToken,
authCorp.getCorpid(), authCorp.getPermanentCode());
if (result.getErrcode() == 0) {
Agent agent = result.getAuth_info().getAgent().get(0);
AuthCorpInfo authCorpInfo = result.getAuth_corp_info();
UpdateAuthCorpInfoCommand command = new UpdateAuthCorpInfoCommand();
command.setId(authCorp.getId());
command.setCorpType(authCorpInfo.getCorp_type());
command.setCorpSquareLogoUrl(authCorpInfo.getCorp_square_logo_url());
command.setCorpUserMax(String.valueOf(authCorpInfo.getCorp_user_max()));
command.setCorpFullName(authCorpInfo.getCorp_full_name());
command.setSubjectType(String.valueOf(authCorpInfo.getSubject_type()));
command.setVerifiedEndTime(String.valueOf(authCorpInfo.getVerified_end_time()));
command.setCorpScale(authCorpInfo.getCorp_scale());
command.setCorpIndustry(authCorpInfo.getCorp_industry());
command.setCorpSubIndustry(authCorpInfo.getCorp_sub_industry());
command.setAgentid(String.valueOf(agent.getAgentid()));
command.setAgentname(agent.getName());
authCorpInfoApplicationService.updateAuthCorpInfo(command);
}
}
} catch (Exception e) {
log.error("getAuthInfo error", e);
}
}
/**
* 定时同步用户绑定关系
* 功能说明
* 1. 每小时第50分钟执行一次
* 2. 根据企业微信通讯录与本地用户数据匹配
* 3. 维护系统用户与企业微信用户的映射关系
* 同步维度
* - 用户状态变更离职/禁用
* - 部门架构变动
* - 系统账户匹配规则手机号/邮箱
* 数据关联通过userid与系统用户表建立外键关联
* 异常处理单个企业同步失败不影响其他企业执行
*/
@Scheduled(cron = "0 50 * * * *")
public void syncUserBindingsTask() {
try {

View File

@ -5,6 +5,8 @@ import com.agileboot.api.customize.async.QyAsyncTaskFactory;
import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode;
import com.agileboot.common.utils.weixin.aes.WXBizMsgCrypt;
import com.agileboot.domain.qywx.api.QywxApiUtil;
import com.agileboot.domain.qywx.api.response.GetAuthInfoResult;
import com.agileboot.domain.qywx.auth.AuthApplicationService;
import com.agileboot.domain.qywx.auth.command.AddAuthCommand;
import com.agileboot.domain.qywx.authCorpInfo.AuthCorpInfoApplicationService;
@ -82,6 +84,16 @@ public class QywxController {
}
}
@GetMapping("/getAuthInfo")
public GetAuthInfoResult getAuthInfo(@RequestParam String suiteAccessToken, @RequestParam String corpid, @RequestParam String permanentCode) {
try {
return QywxApiUtil.getAuthInfo(suiteAccessToken, corpid, permanentCode);
} catch (Exception e) {
log.error("getAuthInfo error", e);
throw new ApiException(ErrorCode.FAILED, "获取企业授权信息失败", e);
}
}
@GetMapping("/callback/data")
public String validateDataCallback(@RequestParam String msg_signature,
@RequestParam String timestamp,

View File

@ -14,12 +14,15 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.view.RedirectView;
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.util.UriComponentsBuilder;
@RestController
@RequestMapping("/api/shop")
@CrossOrigin(origins = "*", allowedHeaders = "*")
@RequiredArgsConstructor
public class ShopController {
private final GoodsApplicationService goodsApplicationService;
private final CategoryApplicationService categoryApplicationService;
@ -50,4 +53,33 @@ public class ShopController {
+ "&state=STATE#wechat_redirect";
return new RedirectView(authUrl);
}
@GetMapping("/qy/wechatAuth")
public RedirectView qyWechatAuthRedirect() {
String authUrl = "https://open.weixin.qq.com/connect/oauth2/authorize"
+ "?appid=" + WeixinConstants.corpid
+ "&redirect_uri=http%3A%2F%2Fwxshop.ab98.cn%2Fshop-api%2Fapi%2Fshop%2FapprovalRedirect"
+ "&response_type=code"
+ "&scope=snsapi_base"
+ "&state=STATE"
+ "&agentid=" + WeixinConstants.agentid
+ "#wechat_redirect";
return new RedirectView(authUrl);
}
@GetMapping("/approvalRedirect")
public RedirectView approvalRedirect(HttpServletRequest request) {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl("http://wxshop.ab98.cn/shop#/approval/list")
.queryParam("corpid", WeixinConstants.corpid)
.queryParam("device", "APP");
request.getParameterMap().forEach((key, values) -> {
if (!"corpid".equals(key) && !"device".equals(key)) {
builder.queryParam(key, (Object[]) values);
}
});
return new RedirectView(builder.build().encode().toUriString());
}
}

View File

@ -1,21 +1,16 @@
package com.agileboot.api.customize.async;
import cn.hutool.core.date.DateUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.agileboot.domain.qywx.auth.db.QyAuthService;
import com.agileboot.domain.qywx.api.response.GetAuthInfoResult;
import com.agileboot.domain.qywx.authCorpInfo.AuthCorpInfoApplicationService;
import com.agileboot.domain.qywx.authCorpInfo.command.AddAuthCorpInfoCommand;
import com.agileboot.domain.qywx.authCorpInfo.command.UpdateAuthCorpInfoCommand;
import com.agileboot.domain.qywx.authCorpInfo.db.QyAuthCorpInfoService;
import com.agileboot.domain.qywx.template.TemplateApplicationService;
import com.agileboot.domain.qywx.template.db.QyTemplateEntity;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@Slf4j
public class QyAsyncTaskFactory {

View File

@ -5,4 +5,6 @@ public class WeixinConstants {
// public static String secret = "2a5a8b6ad3654a05f9fdd36524279a50";
public static String appid = "wx9922dfbb0d4cd7bb";
public static String secret = "7c7ef0dbb90b6be2abc8c269357f980a";
public static String agentid = "1000231";
public static String corpid = "wpZ1ZrEgAA2QTxIRcB4cMtY7hQbTcPAw";
}

View File

@ -1,5 +1,6 @@
package com.agileboot.domain.qywx.api;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import com.agileboot.common.exception.ApiException;
@ -11,12 +12,45 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.client.RestClientException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class QywxApiUtil {
/**
* 发送图文消息
* @param accessToken 接口调用凭证
* @param agentId 应用ID
* @param toUser 成员ID列表多个用|分隔
* @param articles 图文条目列表
* @return 消息发送结果
*/
public static NewsMessageResponse sendNewsMessage(String accessToken, Integer agentId, String toUser, String toparty, String totag, List<NewsArticle> articles) {
String url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + accessToken + "&debug=" + 1;
Map<String, Object> params = new HashMap<>();
params.put("touser", toUser);
params.put("toparty", toparty);
params.put("totag", totag);
params.put("msgtype", "news");
params.put("agentid", agentId);
params.put("news", Collections.singletonMap("articles", JSONUtil.parse(articles)));
String paramJson = JSONUtil.toJsonStr(params);
String response = HttpUtil.post(url, paramJson);
log.info("sendNewsMessage response: {}", response);
NewsMessageResponse result = JSONUtil.toBean(response, NewsMessageResponse.class);
if (result.getErrcode() != 0) {
throw new ApiException(ErrorCode.Internal.INTERNAL_ERROR,
String.format("发送图文消息失败: %s", result.getErrmsg()));
}
return result;
}
/**
* 获取企业微信部门列表简易信息
* @param access_token 企业微信接口调用凭证
@ -104,6 +138,14 @@ public class QywxApiUtil {
return JSONUtil.toBean(response, OpenidResponse.class);
}
/**
* 通过临时授权码获取企业微信用户ID
*
* @param accessToken 企业微信API访问凭证需从企业微信后台获取
* @param code 用户授权后获取的临时授权码通过OAuth2.0流程获取
* @return 企业微信用户的唯一标识userid
* @throws ApiException 当请求失败或返回错误码时抛出异常
*/
public static String getQyUserid(String accessToken, String code) {
try {
String url = String.format(
@ -125,5 +167,25 @@ public class QywxApiUtil {
throw new ApiException(ErrorCode.Client.COMMON_REQUEST_PARAMETERS_INVALID, "微信服务调用失败");
}
}
/**
* 获取企业微信授权信息
* @param suiteAccessToken 第三方应用凭证从企业微信后台获取
* @param corpid 授权方企业微信ID
* @param permanentCode 永久授权码通过授权流程获取
* @return 包含企业授权信息的响应对象包含agentid权限集等信息
*/
public static GetAuthInfoResult getAuthInfo(String suiteAccessToken, String corpid, String permanentCode) {
String url = "https://qyapi.weixin.qq.com/cgi-bin/service/v2/get_auth_info?suite_access_token="+suiteAccessToken;
Map<String, String> requestBody = new HashMap<String, String>();
requestBody.put("auth_corpid", corpid);
requestBody.put("permanent_code", permanentCode);
String response = HttpUtil.post(url, JSONUtil.toJsonStr(requestBody));
log.info("getAuthInfo response: {}", response);
return JSONUtil.toBean(response, GetAuthInfoResult.class);
}
}

View File

@ -1,4 +1,4 @@
package com.agileboot.api.customize.async;
package com.agileboot.domain.qywx.api.response;
import cn.hutool.json.JSONObject;
import lombok.Data;

View File

@ -0,0 +1,35 @@
package com.agileboot.domain.qywx.api.response;
import lombok.Data;
@Data
public class NewsArticle {
/**
* 图文消息标题
* 必填
* 长度限制不超过128个字符
*/
private String title;
/**
* 图文消息描述
* 必填
* 长度限制不超过512个字符
*/
private String description;
/**
* 点击后跳转的链接
* 必填
* 格式要求包含协议头(http/https)
* 长度限制不超过2048字节
*/
private String url;
/**
* 图文消息配图的url
* 必填
* 长度限制不超过2048字节
*/
private String picurl;
}

View File

@ -0,0 +1,47 @@
package com.agileboot.domain.qywx.api.response;
import lombok.Data;
@Data
public class NewsMessageResponse {
/**
* 返回码
* 0表示成功非0表示失败
*/
private Integer errcode;
/**
* 错误信息
* 成功返回"ok"
*/
private String errmsg;
/**
* 不合法的userid列表
* 多个接收者用'|'分隔
*/
private String invaliduser;
/**
* 不合法的部门id列表
* 多个接收者用'|'分隔
*/
private String invalidparty;
/**
* 不合法的标签id列表
* 多个接收者用'|'分隔
*/
private String invalidtag;
/**
* 没有基础接口许可的userid列表
* 包含已过期的许可
*/
private String unlicenseduser;
/**
* 消息id
* 用于撤回应用消息72小时内有效
*/
private String msgid;
/**
* 响应代码
* 仅模板卡片消息返回用于更新消息72小时内有效
*/
private String response_code;
}

View File

@ -3,6 +3,12 @@ package com.agileboot.domain.shop.approval;
import com.agileboot.common.constant.PayApiConstants;
import com.agileboot.common.core.page.PageDTO;
import com.agileboot.domain.common.command.BulkOperationCommand;
import com.agileboot.domain.qywx.accessToken.AccessTokenApplicationService;
import com.agileboot.domain.qywx.accessToken.db.QyAccessTokenEntity;
import com.agileboot.domain.qywx.api.QywxApiUtil;
import com.agileboot.domain.qywx.api.response.NewsArticle;
import com.agileboot.domain.qywx.authCorpInfo.AuthCorpInfoApplicationService;
import com.agileboot.domain.qywx.authCorpInfo.db.QyAuthCorpInfoEntity;
import com.agileboot.domain.shop.approval.command.AddReturnApprovalCommand;
import com.agileboot.domain.shop.approval.command.UpdateReturnApprovalCommand;
import com.agileboot.domain.shop.approval.db.ReturnApprovalEntity;
@ -12,6 +18,8 @@ import com.agileboot.domain.shop.approval.model.ReturnApprovalModel;
import com.agileboot.domain.shop.approval.model.ReturnApprovalModelFactory;
import com.agileboot.domain.shop.approval.query.SearchApiReturnApprovalQuery;
import com.agileboot.domain.shop.approval.query.SearchReturnApprovalQuery;
import com.agileboot.domain.shop.goods.model.GoodsModel;
import com.agileboot.domain.shop.goods.model.GoodsModelFactory;
import com.agileboot.domain.shop.order.db.ShopOrderGoodsEntity;
import com.agileboot.domain.shop.order.model.OrderGoodsModel;
import com.agileboot.domain.shop.order.model.OrderGoodsModelFactory;
@ -23,6 +31,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
@ -45,6 +54,9 @@ public class ReturnApprovalApplicationService {
private final OrderGoodsModelFactory orderGoodsModelFactory;
private final OrderModelFactory orderModelFactory;
private final PaymentApplicationService paymentApplicationService;
private final GoodsModelFactory goodsModelFactory;
private final AuthCorpInfoApplicationService authCorpInfoApplicationService;
private final AccessTokenApplicationService accessTokenApplicationService;
/**
* 获取退货审批列表
@ -137,6 +149,7 @@ public class ReturnApprovalApplicationService {
}
}
// 更新审批状态为通过
model.validateApprovalStatus();
model.setAuditImages(command.getAuditImages());
model.setAuditRemark(command.getAuditRemark());
@ -147,6 +160,11 @@ public class ReturnApprovalApplicationService {
// 更新关联订单商品状态
orderGoodsModel.setStatus(2); // 6表示已完成退货
orderGoodsModel.updateById();
// 更新商品库存
GoodsModel goodsModel = goodsModelFactory.loadById(orderGoodsModel.getGoodsId());
goodsModel.setStock(goodsModel.getStock() + orderGoodsModel.getQuantity());
goodsModel.updateById();
}
/**
@ -177,6 +195,32 @@ public class ReturnApprovalApplicationService {
orderGoodsModel.setStatus(5);
orderGoodsModel.updateById();
// 发送审核消息
try {
String appid = "QWTONG_YS_WXSHOP";
List<QyAuthCorpInfoEntity> authCorpInfoList = authCorpInfoApplicationService.getByAppid(appid);
QyAuthCorpInfoEntity authCorpInfo = authCorpInfoList.stream()
.filter(a -> "wpZ1ZrEgAA2QTxIRcB4cMtY7hQbTcPAw".equals(a.getCorpid()))
.findFirst().orElse(null);
QyAccessTokenEntity accessToken = accessTokenApplicationService.getByAppid(appid, authCorpInfo.getCorpid());
// TODO 获取用户ID
String toUser = "woZ1ZrEgAAV9AEdRt1MGQxSg-KDJrDlA|woZ1ZrEgAAoFQl9vWHMj4PkFoSc8FR8w";
String toparty = "";
String totag = "";
List<NewsArticle> articles = new ArrayList<>();
NewsArticle article = new NewsArticle();
article.setTitle("退货审核通知");
article.setDescription("退还商品:" + orderGoods.getGoodsName());
article.setPicurl(orderGoods.getCoverImg());
article.setUrl("http://wxshop.ab98.cn/shop-api/api/shop/qy/wechatAuth");
articles.add(article);
QywxApiUtil.sendNewsMessage(accessToken.getAccessToken(), Integer.valueOf(authCorpInfo.getAgentid()),
toUser, toparty, totag, articles);
} catch (Exception e) {
log.error("发送退货审核通知失败", e);
}
return returnApprovalModel.selectById();
}

File diff suppressed because one or more lines are too long