diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java index e45822b7e..cec28f868 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/util/TenantUtils.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.framework.tenant.core.util; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import java.util.Map; +import java.util.concurrent.Callable; import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; @@ -36,6 +37,31 @@ public class TenantUtils { } } + /** + * 使用指定租户,执行对应的逻辑 + * + * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户 + * 当然,执行完成后,还是会恢复回去 + * + * @param tenantId 租户编号 + * @param callable 逻辑 + */ + public static V execute(Long tenantId, Callable callable) { + Long oldTenantId = TenantContextHolder.getTenantId(); + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setTenantId(tenantId); + TenantContextHolder.setIgnore(false); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + TenantContextHolder.setTenantId(oldTenantId); + TenantContextHolder.setIgnore(oldIgnore); + } + } + /** * 忽略租户,执行对应的逻辑 * diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/MpOpenController.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/MpOpenController.java index 709d2b89d..1cb2b5d26 100644 --- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/MpOpenController.java +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/MpOpenController.java @@ -1,16 +1,27 @@ package cn.iocoder.yudao.module.mp.controller.admin.open; +import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.module.mp.controller.admin.open.vo.MpOpenCheckSignatureReqVO; +import cn.iocoder.yudao.module.mp.controller.admin.open.vo.MpOpenHandleMessageReqVO; +import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO; import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory; +import cn.iocoder.yudao.module.mp.framework.mp.core.context.MpContextHolder; +import cn.iocoder.yudao.module.mp.service.account.MpAccountService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.mp.api.WxMpMessageRouter; import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; +import java.util.Objects; @Api(tags = "管理后台 - 公众号回调") @RestController @@ -22,6 +33,9 @@ public class MpOpenController { @Resource private MpServiceFactory mpServiceFactory; + @Resource + private MpAccountService mpAccountService; + /** * 接收微信公众号的校验签名 * @@ -49,8 +63,51 @@ public class MpOpenController { */ @ApiOperation("处理消息") @PostMapping(value = "/{appId}", produces = "application/xml; charset=UTF-8") - public String handleMessage() { - return "123"; + @OperateLog(enable = false) // 回调地址,无需记录操作日志 + public String handleMessage(@PathVariable("appId") String appId, + @RequestBody String content, + MpOpenHandleMessageReqVO reqVO) { + log.info("[handleMessage][appId({}) 推送消息,参数({}) 内容({})]", appId, reqVO, content); + + // 处理 appId + 多租户的上下文 + MpAccountDO account = mpAccountService.getAccountFromCache(appId); + Assert.notNull(account, "公众号 appId({}) 不存在", appId); + try { + MpContextHolder.setAppId(appId); + return TenantUtils.execute(account.getTenantId(), + () -> handleMessage0(appId, content, reqVO)); + } finally { + MpContextHolder.clear(); + } + } + + private String handleMessage0(String appId, String content, MpOpenHandleMessageReqVO reqVO) { + // 校验请求签名 + WxMpService mppService = mpServiceFactory.getRequiredMpService(appId); + Assert.isTrue(mppService.checkSignature(reqVO.getTimestamp(), reqVO.getNonce(), reqVO.getSignature()), + "非法请求"); + + // 第一步,解析消息 + WxMpXmlMessage inMessage = null; + if (StrUtil.isBlank(reqVO.getEncrypt_type())) { // 明文模式 + inMessage = WxMpXmlMessage.fromXml(content); + } else if (Objects.equals(reqVO.getEncrypt_type(), MpOpenHandleMessageReqVO.ENCRYPT_TYPE_AES)) { // AES 加密模式 + inMessage = WxMpXmlMessage.fromEncryptedXml(content, mppService.getWxMpConfigStorage(), + reqVO.getTimestamp(), reqVO.getNonce(), reqVO.getMsg_signature()); + } + Assert.notNull(inMessage, "消息解析失败,原因:消息为空"); + + // 第二步,处理消息 + WxMpMessageRouter mpMessageRouter = mpServiceFactory.getRequiredMpMessageRouter(appId); + WxMpXmlOutMessage outMessage = mpMessageRouter.route(inMessage); + + // 第三步,返回消息 + if (StrUtil.isBlank(reqVO.getEncrypt_type())) { // 明文模式 + return outMessage.toXml(); + } else if (Objects.equals(reqVO.getEncrypt_type(), MpOpenHandleMessageReqVO.ENCRYPT_TYPE_AES)) { // AES 加密模式 + return outMessage.toEncryptedXml(mppService.getWxMpConfigStorage()); + } + return null; } } diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/vo/MpOpenHandleMessageReqVO.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/vo/MpOpenHandleMessageReqVO.java new file mode 100644 index 000000000..c8a9f3123 --- /dev/null +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/open/vo/MpOpenHandleMessageReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.mp.controller.admin.open.vo; + + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.validation.constraints.NotEmpty; + +@ApiModel("管理后台 - 公众号处理消息 Request VO") +@Data +public class MpOpenHandleMessageReqVO { + + public static final String ENCRYPT_TYPE_AES = "aes"; + + @ApiModelProperty(value = "微信加密签名", required = true, example = "490eb57f448b87bd5f20ccef58aa4de46aa1908e") + @NotEmpty(message = "微信加密签名不能为空") + private String signature; + + @ApiModelProperty(value = "时间戳", required = true, example = "1672587863") + @NotEmpty(message = "时间戳不能为空") + private String timestamp; + + @ApiModelProperty(value = "随机数", required = true, example = "1827365808") + @NotEmpty(message = "随机数不能为空") + private String nonce; + + @ApiModelProperty(value = "用户 openid", required = true, example = "oz-Jdtyn-WGm4C4I5Z-nvBMO_ZfY") + @NotEmpty(message = "用户 openid 不能为空") + private String openid; + + @ApiModelProperty(value = "消息加密类型", example = "aes") + private String encrypt_type; + + @ApiModelProperty(value = "微信签名", example = "QW5kcm9pZCBUaGUgQmFzZTY0IGlzIGEgZ2VuZXJhdGVkIHN0cmluZw==") + private String msg_signature; + +} diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/account/MpAccountDO.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/account/MpAccountDO.java index 877027556..593befdd4 100644 --- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/account/MpAccountDO.java +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/account/MpAccountDO.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.mp.dal.dataobject.account; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; @@ -23,7 +24,7 @@ import me.chanjar.weixin.mp.config.impl.WxMpRedisConfigImpl; @Builder @NoArgsConstructor @AllArgsConstructor -public class MpAccountDO extends BaseDO { +public class MpAccountDO extends TenantBaseDO { /** * 编号 diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/MpServiceFactory.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/MpServiceFactory.java index 12474be3c..7da6f07ae 100644 --- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/MpServiceFactory.java +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/MpServiceFactory.java @@ -42,4 +42,11 @@ public interface MpServiceFactory { * @return WxMpMessageRouter 实例 */ WxMpMessageRouter getMpMessageRouter(String appId); + + default WxMpMessageRouter getRequiredMpMessageRouter(String appId) { + WxMpMessageRouter wxMpMessageRouter = getMpMessageRouter(appId); + Assert.notNull(wxMpMessageRouter, "找到对应 appId({}) 的 WxMpMessageRouter,请核实!", appId); + return wxMpMessageRouter; + } + } diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/context/MpContextHolder.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/context/MpContextHolder.java new file mode 100644 index 000000000..e5c45c531 --- /dev/null +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/context/MpContextHolder.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025, lengleng All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * Neither the name of the pig4cloud.com developer nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * Author: lengleng (wangiegie@gmail.com) + */ + +package cn.iocoder.yudao.module.mp.framework.mp.core.context; + +import cn.iocoder.yudao.module.mp.controller.admin.open.vo.MpOpenHandleMessageReqVO; +import com.alibaba.ttl.TransmittableThreadLocal; +import lombok.experimental.UtilityClass; +import me.chanjar.weixin.mp.api.WxMpMessageHandler; + +/** + * 微信上下文 Context + * + * 目的:解决微信多公众号的问题,在 {@link WxMpMessageHandler} 实现类中,可以通过 {@link #getAppId()} 获取到当前的 appId + * + * @see cn.iocoder.yudao.module.mp.controller.admin.open.MpOpenController#handleMessage(String, String, MpOpenHandleMessageReqVO) + * + * @author 芋道源码 + */ +public class MpContextHolder { + + /** + * 微信公众号的 appId 上下文 + */ + private static final ThreadLocal APPID = new TransmittableThreadLocal<>(); + + public static void setAppId(String appId) { + APPID.set(appId); + } + + public static String getAppId() { + return APPID.get(); + } + + public static void clear() { + APPID.remove(); + } + +} diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountService.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountService.java index b242c30a3..657fc7cbb 100644 --- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountService.java +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountService.java @@ -51,6 +51,14 @@ public interface MpAccountService { */ MpAccountDO getAccount(Long id); + /** + * 从缓存中,获得公众号账户 + * + * @param appId 微信公众号 appId + * @return 公众号账户 + */ + MpAccountDO getAccountFromCache(String appId); + /** * 获得公众号账户分页 * diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java index e6b6b77e2..adbe28ac2 100644 --- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java +++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java @@ -25,6 +25,7 @@ import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; /** @@ -43,6 +44,14 @@ public class MpAccountServiceImpl implements MpAccountService { */ private static final long SCHEDULER_PERIOD = 5 * 60 * 1000L; + /** + * 账号缓存 + * key:账号编号 {@link MpAccountDO#getAppId()} + * + * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向 + */ + @Getter + private volatile Map accountCache; /** * 缓存菜单的最大更新时间,用于后续的增量轮询,判断是否有更新 */ @@ -92,6 +101,7 @@ public class MpAccountServiceImpl implements MpAccountService { // 第二步:构建缓存。创建或更新支付 Client mpServiceFactory.init(accounts); + accountCache = CollectionUtils.convertMap(accounts, MpAccountDO::getAppId); // 第三步:设置最新的 maxUpdateTime,用于下次的增量判断。 this.maxUpdateTime = CollectionUtils.getMaxValue(accounts, MpAccountDO::getUpdateTime); @@ -146,6 +156,11 @@ public class MpAccountServiceImpl implements MpAccountService { return mpAccountMapper.selectById(id); } + @Override + public MpAccountDO getAccountFromCache(String appId) { + return accountCache.get(appId); + } + @Override public PageResult getAccountPage(MpAccountPageReqVO pageReqVO) { return mpAccountMapper.selectPage(pageReqVO);