| | |
| | | package cn.iocoder.yudao.module.system.service.social; |
| | | |
| | | import cn.binarywang.wx.miniapp.api.WxMaService; |
| | | import cn.binarywang.wx.miniapp.api.WxMaSubscribeService; |
| | | import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl; |
| | | import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; |
| | | import cn.binarywang.wx.miniapp.bean.WxMaSubscribeMessage; |
| | | import cn.binarywang.wx.miniapp.bean.shop.request.shipping.*; |
| | | import cn.binarywang.wx.miniapp.bean.shop.response.WxMaOrderShippingInfoBaseResponse; |
| | | import cn.binarywang.wx.miniapp.config.impl.WxMaRedisBetterConfigImpl; |
| | | import cn.binarywang.wx.miniapp.constant.WxMaConstants; |
| | | import cn.hutool.core.bean.BeanUtil; |
| | | import cn.hutool.core.collection.CollUtil; |
| | | import cn.hutool.core.lang.Assert; |
| | | import cn.hutool.core.thread.ThreadUtil; |
| | | import cn.hutool.core.util.DesensitizedUtil; |
| | | import cn.hutool.core.util.ObjUtil; |
| | | import cn.hutool.core.util.ReflectUtil; |
| | | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| | | import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; |
| | | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| | | import cn.iocoder.yudao.framework.common.util.cache.CacheUtils; |
| | | import cn.iocoder.yudao.framework.common.util.http.HttpUtils; |
| | | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| | | import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; |
| | | import cn.iocoder.yudao.module.system.api.social.dto.SocialWxaOrderNotifyConfirmReceiveReqDTO; |
| | | import cn.iocoder.yudao.module.system.api.social.dto.SocialWxaOrderUploadShippingInfoReqDTO; |
| | | import cn.iocoder.yudao.module.system.api.social.dto.SocialWxaSubscribeMessageSendReqDTO; |
| | | import cn.iocoder.yudao.module.system.controller.admin.socail.vo.client.SocialClientPageReqVO; |
| | | import cn.iocoder.yudao.module.system.controller.admin.socail.vo.client.SocialClientSaveReqVO; |
| | | import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialClientDO; |
| | | import cn.iocoder.yudao.module.system.dal.mysql.social.SocialClientMapper; |
| | | import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants; |
| | | import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum; |
| | | import cn.iocoder.yudao.module.system.framework.justauth.core.AuthRequestFactory; |
| | | import com.binarywang.spring.starter.wxjava.miniapp.properties.WxMaProperties; |
| | | import com.binarywang.spring.starter.wxjava.mp.properties.WxMpProperties; |
| | | import com.google.common.annotations.VisibleForTesting; |
| | | import com.google.common.cache.CacheLoader; |
| | | import com.google.common.cache.LoadingCache; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.SneakyThrows; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import me.chanjar.weixin.common.bean.WxJsapiSignature; |
| | | import me.chanjar.weixin.common.bean.subscribemsg.TemplateInfo; |
| | | import me.chanjar.weixin.common.error.WxErrorException; |
| | | import me.chanjar.weixin.common.redis.RedisTemplateWxRedisOps; |
| | | import me.chanjar.weixin.mp.api.WxMpService; |
| | | import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl; |
| | | import me.chanjar.weixin.mp.config.impl.WxMpRedisConfigImpl; |
| | | import me.zhyd.oauth.config.AuthConfig; |
| | | import me.zhyd.oauth.model.AuthCallback; |
| | | import me.zhyd.oauth.model.AuthResponse; |
| | |
| | | import me.zhyd.oauth.request.AuthRequest; |
| | | import me.zhyd.oauth.utils.AuthStateUtils; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.cache.annotation.Cacheable; |
| | | import org.springframework.data.redis.core.StringRedisTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.time.Duration; |
| | | import java.time.ZonedDateTime; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | | import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; |
| | | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.UTC_MS_WITH_XXX_OFFSET_FORMATTER; |
| | | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.toEpochSecond; |
| | | import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; |
| | | import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; |
| | | import static java.util.Collections.singletonList; |
| | | |
| | | /** |
| | | * 社交应用 Service 实现类 |
| | |
| | | @Slf4j |
| | | public class SocialClientServiceImpl implements SocialClientService { |
| | | |
| | | /** |
| | | * 小程序码要打开的小程序版本 |
| | | * |
| | | * 1. release:正式版 |
| | | * 2. trial:体验版 |
| | | * 3. developer:开发版 |
| | | */ |
| | | @Value("${yudao.wxa-code.env-version:release}") |
| | | public String envVersion; |
| | | /** |
| | | * 订阅消息跳转小程序类型 |
| | | * |
| | | * 1. developer:开发版 |
| | | * 2. trial:体验版 |
| | | * 3. formal:正式版 |
| | | */ |
| | | @Value("${yudao.wxa-subscribe-message.miniprogram-state:formal}") |
| | | public String miniprogramState; |
| | | |
| | | /** |
| | | * 上传发货信息重试间隔(毫秒),数组长度即重试次数;总等待最坏 1 + 2 + 4 = 7 秒,覆盖微信支付回调的常见延迟 |
| | | */ |
| | | private static final long[] UPLOAD_SHIPPING_INFO_RETRY_BACKOFF_MILLIS = {1000, 2000, 4000}; |
| | | /** |
| | | * 微信错误码:支付单不存在 |
| | | */ |
| | | private static final int WX_ERR_CODE_PAY_ORDER_NOT_EXIST = 10060001; |
| | | |
| | | @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") |
| | | @Autowired(required = false) // 由于 justauth.enable 配置项,可以关闭 AuthRequestFactory 的功能,所以这里只能不强制注入 |
| | | private AuthRequestFactory authRequestFactory; |
| | | |
| | | @Resource |
| | | private WxMpService wxMpService; |
| | | @Resource |
| | | private WxMpProperties wxMpProperties; |
| | | @Resource |
| | | private StringRedisTemplate stringRedisTemplate; // WxMpService 需要使用到,所以在 Service 注入了它 |
| | | /** |
| | | * 缓存 WxMpService 对象 |
| | | * |
| | | * key:使用微信公众号的 appId + secret 拼接,即 {@link SocialClientDO} 的 clientId 和 clientSecret 属性。 |
| | | * 为什么 key 使用这种格式?因为 {@link SocialClientDO} 在管理后台可以变更,通过这个 key 存储它的单例。 |
| | | * |
| | | * 为什么要做 WxMpService 缓存?因为 WxMpService 构建成本比较大,所以尽量保证它是单例。 |
| | | */ |
| | | private final LoadingCache<String, WxMpService> wxMpServiceCache = CacheUtils.buildAsyncReloadingCache( |
| | | Duration.ofSeconds(10L), |
| | | new CacheLoader<String, WxMpService>() { |
| | | |
| | | @Override |
| | | public WxMpService load(String key) { |
| | | String[] keys = key.split(":"); |
| | | return buildWxMpService(keys[0], keys[1]); |
| | | } |
| | | |
| | | }); |
| | | |
| | | @Resource |
| | | private WxMaService wxMaService; |
| | | @Resource |
| | | private WxMaProperties wxMaProperties; |
| | | /** |
| | | * 缓存 WxMaService 对象 |
| | | * |
| | | * 说明同 {@link #wxMpServiceCache} 变量 |
| | | */ |
| | | private final LoadingCache<String, WxMaService> wxMaServiceCache = CacheUtils.buildAsyncReloadingCache( |
| | | Duration.ofSeconds(10L), |
| | | new CacheLoader<String, WxMaService>() { |
| | | |
| | | @Override |
| | | public WxMaService load(String key) { |
| | | String[] keys = key.split(":"); |
| | | return buildWxMaService(keys[0], keys[1]); |
| | | } |
| | | |
| | | }); |
| | | |
| | | @Resource |
| | | private SocialClientMapper socialClientMapper; |
| | |
| | | ReflectUtil.setFieldValue(request, "config", newAuthConfig); |
| | | } |
| | | return request; |
| | | } |
| | | |
| | | // =================== 微信公众号独有 =================== |
| | | |
| | | @Override |
| | | @SneakyThrows |
| | | public WxJsapiSignature createWxMpJsapiSignature(Integer userType, String url) { |
| | | WxMpService service = getWxMpService(userType); |
| | | return service.createJsapiSignature(url); |
| | | } |
| | | |
| | | /** |
| | | * 获得 clientId + clientSecret 对应的 WxMpService 对象 |
| | | * |
| | | * @param userType 用户类型 |
| | | * @return WxMpService 对象 |
| | | */ |
| | | @VisibleForTesting |
| | | WxMpService getWxMpService(Integer userType) { |
| | | // 第一步,查询 DB 的配置项,获得对应的 WxMpService 对象 |
| | | SocialClientDO client = socialClientMapper.selectBySocialTypeAndUserType( |
| | | SocialTypeEnum.WECHAT_MP.getType(), userType); |
| | | if (client != null && Objects.equals(client.getStatus(), CommonStatusEnum.ENABLE.getStatus())) { |
| | | return wxMpServiceCache.getUnchecked(client.getClientId() + ":" + client.getClientSecret()); |
| | | } |
| | | // 第二步,不存在 DB 配置项,则使用 application-*.yaml 对应的 WxMpService 对象 |
| | | return wxMpService; |
| | | } |
| | | |
| | | /** |
| | | * 创建 clientId + clientSecret 对应的 WxMpService 对象 |
| | | * |
| | | * @param clientId 微信公众号 appId |
| | | * @param clientSecret 微信公众号 secret |
| | | * @return WxMpService 对象 |
| | | */ |
| | | public WxMpService buildWxMpService(String clientId, String clientSecret) { |
| | | // 第一步,创建 WxMpRedisConfigImpl 对象 |
| | | WxMpRedisConfigImpl configStorage = new WxMpRedisConfigImpl( |
| | | new RedisTemplateWxRedisOps(stringRedisTemplate), |
| | | wxMpProperties.getConfigStorage().getKeyPrefix()); |
| | | configStorage.setAppId(clientId); |
| | | configStorage.setSecret(clientSecret); |
| | | |
| | | // 第二步,创建 WxMpService 对象 |
| | | WxMpService service = new WxMpServiceImpl(); |
| | | service.setWxMpConfigStorage(configStorage); |
| | | return service; |
| | | } |
| | | |
| | | // =================== 微信小程序独有 =================== |
| | | |
| | | @Override |
| | | public WxMaPhoneNumberInfo getWxMaPhoneNumberInfo(Integer userType, String phoneCode) { |
| | | WxMaService service = getWxMaService(userType); |
| | | try { |
| | | return service.getUserService().getPhoneNumber(phoneCode); |
| | | } catch (WxErrorException e) { |
| | | log.error("[getPhoneNumber][userType({}) phoneCode({}) 获得手机号失败]", userType, phoneCode, e); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_PHONE_CODE_ERROR); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public byte[] getWxaQrcode(SocialWxQrcodeReqDTO reqVO) { |
| | | WxMaService service = getWxMaService(UserTypeEnum.MEMBER.getValue()); |
| | | try { |
| | | return service.getQrcodeService().createWxaCodeUnlimitBytes( |
| | | ObjUtil.defaultIfEmpty(reqVO.getScene(), SocialWxQrcodeReqDTO.SCENE), |
| | | reqVO.getPath(), |
| | | ObjUtil.defaultIfNull(reqVO.getCheckPath(), SocialWxQrcodeReqDTO.CHECK_PATH), |
| | | envVersion, |
| | | ObjUtil.defaultIfNull(reqVO.getWidth(), SocialWxQrcodeReqDTO.WIDTH), |
| | | ObjUtil.defaultIfNull(reqVO.getAutoColor(), SocialWxQrcodeReqDTO.AUTO_COLOR), |
| | | null, |
| | | ObjUtil.defaultIfNull(reqVO.getHyaline(), SocialWxQrcodeReqDTO.HYALINE)); |
| | | } catch (WxErrorException e) { |
| | | log.error("[getWxQrcode][reqVO({}) 获得小程序码失败]", reqVO, e); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_QRCODE_ERROR); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | @Cacheable(cacheNames = RedisKeyConstants.WXA_SUBSCRIBE_TEMPLATE, key = "#userType", |
| | | unless = "#result == null") |
| | | public List<TemplateInfo> getSubscribeTemplateList(Integer userType) { |
| | | WxMaService service = getWxMaService(userType); |
| | | try { |
| | | WxMaSubscribeService subscribeService = service.getSubscribeService(); |
| | | return subscribeService.getTemplateList(); |
| | | } catch (WxErrorException e) { |
| | | log.error("[getSubscribeTemplate][userType({}) 获得小程序订阅消息模版]", userType, e); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_SUBSCRIBE_TEMPLATE_ERROR); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void sendSubscribeMessage(SocialWxaSubscribeMessageSendReqDTO reqDTO, String templateId, String openId) { |
| | | WxMaService service = getWxMaService(reqDTO.getUserType()); |
| | | try { |
| | | WxMaSubscribeService subscribeService = service.getSubscribeService(); |
| | | subscribeService.sendSubscribeMsg(buildMessageSendReqDTO(reqDTO, templateId, openId)); |
| | | } catch (WxErrorException e) { |
| | | log.error("[sendSubscribeMessage][reqVO({}) templateId({}) openId({}) 发送小程序订阅消息失败]", reqDTO, templateId, openId, e); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_SUBSCRIBE_MESSAGE_ERROR); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构建发送消息请求参数 |
| | | * |
| | | * @param reqDTO 请求 |
| | | * @param templateId 模版编号 |
| | | * @param openId 会员 openId |
| | | * @return 微信小程序订阅消息请求参数 |
| | | */ |
| | | private WxMaSubscribeMessage buildMessageSendReqDTO(SocialWxaSubscribeMessageSendReqDTO reqDTO, |
| | | String templateId, String openId) { |
| | | // 设置订阅消息基本参数 |
| | | WxMaSubscribeMessage subscribeMessage = new WxMaSubscribeMessage().setLang(WxMaConstants.MiniProgramLang.ZH_CN) |
| | | .setMiniprogramState(miniprogramState).setTemplateId(templateId).setToUser(openId).setPage(reqDTO.getPage()); |
| | | // 设置具体消息参数 |
| | | Map<String, String> messages = reqDTO.getMessages(); |
| | | if (CollUtil.isNotEmpty(messages)) { |
| | | reqDTO.getMessages().keySet().forEach(key -> findAndThen(messages, key, value -> |
| | | subscribeMessage.addData(new WxMaSubscribeMessage.MsgData(key, value)))); |
| | | } |
| | | return subscribeMessage; |
| | | } |
| | | |
| | | @Override |
| | | public void uploadWxaOrderShippingInfo(Integer userType, SocialWxaOrderUploadShippingInfoReqDTO reqDTO) { |
| | | WxMaService service = getWxMaService(userType); |
| | | List<ShippingListBean> shippingList; |
| | | if (Objects.equals(reqDTO.getLogisticsType(), SocialWxaOrderUploadShippingInfoReqDTO.LOGISTICS_TYPE_EXPRESS)) { |
| | | shippingList = singletonList(ShippingListBean.builder() |
| | | .trackingNo(reqDTO.getLogisticsNo()) |
| | | .expressCompany(reqDTO.getExpressCompany()) |
| | | .itemDesc(reqDTO.getItemDesc()) |
| | | .contact(ContactBean.builder().receiverContact(DesensitizedUtil.mobilePhone(reqDTO.getReceiverContact())).build()) |
| | | .build()); |
| | | } else { |
| | | shippingList = singletonList(ShippingListBean.builder().itemDesc(reqDTO.getItemDesc()).build()); |
| | | } |
| | | WxMaOrderShippingInfoUploadRequest request = WxMaOrderShippingInfoUploadRequest.builder() |
| | | .orderKey(OrderKeyBean.builder() |
| | | .orderNumberType(2) // 使用原支付交易对应的微信订单号,即渠道单号 |
| | | .transactionId(reqDTO.getTransactionId()) |
| | | .build()) |
| | | .logisticsType(reqDTO.getLogisticsType()) // 配送方式 |
| | | .deliveryMode(1) // 统一发货 |
| | | .shippingList(shippingList) |
| | | .payer(PayerBean.builder().openid(reqDTO.getOpenid()).build()) |
| | | .uploadTime(ZonedDateTime.now().format(UTC_MS_WITH_XXX_OFFSET_FORMATTER)) |
| | | .build(); |
| | | // 重试机制:解决支付回调与订单信息上传之间的时间差导致的 10060001 错误 |
| | | // 对应 ISSUE:https://gitee.com/zhijiantianya/yudao-cloud/pulls/230 |
| | | // 注意:wx-java 的 upload 内部对 errCode != 0 直接抛 WxErrorException,所以重试判断必须基于异常的 errorCode |
| | | int maxAttempts = UPLOAD_SHIPPING_INFO_RETRY_BACKOFF_MILLIS.length + 1; |
| | | for (int attempt = 1; attempt <= maxAttempts; attempt++) { |
| | | try { |
| | | WxMaOrderShippingInfoBaseResponse response = service.getWxMaOrderShippingService().upload(request); |
| | | log.info("[uploadWxaOrderShippingInfo][上传微信小程序发货信息成功:request({}) response({})]", request, response); |
| | | return; |
| | | } catch (WxErrorException ex) { |
| | | if (ex.getError().getErrorCode() == WX_ERR_CODE_PAY_ORDER_NOT_EXIST && attempt < maxAttempts) { |
| | | long delayMillis = UPLOAD_SHIPPING_INFO_RETRY_BACKOFF_MILLIS[attempt - 1]; |
| | | log.warn("[uploadWxaOrderShippingInfo][第 {} 次尝试失败,支付单不存在,{} ms 后重试:request({})]", |
| | | attempt, delayMillis, request, ex); |
| | | ThreadUtil.sleep(delayMillis); |
| | | continue; |
| | | } |
| | | log.error("[uploadWxaOrderShippingInfo][上传微信小程序发货信息失败:request({})]", request, ex); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR, ex.getError().getErrorMsg()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void notifyWxaOrderConfirmReceive(Integer userType, SocialWxaOrderNotifyConfirmReceiveReqDTO reqDTO) { |
| | | WxMaService service = getWxMaService(userType); |
| | | WxMaOrderShippingInfoNotifyConfirmRequest request = WxMaOrderShippingInfoNotifyConfirmRequest.builder() |
| | | .transactionId(reqDTO.getTransactionId()) |
| | | .receivedTime(toEpochSecond(reqDTO.getReceivedTime())) |
| | | .build(); |
| | | try { |
| | | WxMaOrderShippingInfoBaseResponse response = service.getWxMaOrderShippingService().notifyConfirmReceive(request); |
| | | if (response.getErrCode() != 0) { |
| | | log.error("[notifyWxaOrderConfirmReceive][确认收货提醒到微信小程序失败:request({}) response({})]", request, response); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_NOTIFY_CONFIRM_RECEIVE_ERROR, response.getErrMsg()); |
| | | } |
| | | log.info("[notifyWxaOrderConfirmReceive][确认收货提醒到微信小程序成功:request({}) response({})]", request, response); |
| | | } catch (WxErrorException ex) { |
| | | log.error("[notifyWxaOrderConfirmReceive][确认收货提醒到微信小程序失败:request({})]", request, ex); |
| | | throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_NOTIFY_CONFIRM_RECEIVE_ERROR, ex.getError().getErrorMsg()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 获得 clientId + clientSecret 对应的 WxMpService 对象 |
| | | * |
| | | * @param userType 用户类型 |
| | | * @return WxMpService 对象 |
| | | */ |
| | | @VisibleForTesting |
| | | WxMaService getWxMaService(Integer userType) { |
| | | // 第一步,查询 DB 的配置项,获得对应的 WxMaService 对象 |
| | | SocialClientDO client = socialClientMapper.selectBySocialTypeAndUserType( |
| | | SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), userType); |
| | | if (client != null && Objects.equals(client.getStatus(), CommonStatusEnum.ENABLE.getStatus())) { |
| | | return wxMaServiceCache.getUnchecked(client.getClientId() + ":" + client.getClientSecret()); |
| | | } |
| | | // 第二步,不存在 DB 配置项,则使用 application-*.yaml 对应的 WxMaService 对象 |
| | | return wxMaService; |
| | | } |
| | | |
| | | /** |
| | | * 创建 clientId + clientSecret 对应的 WxMaService 对象 |
| | | * |
| | | * @param clientId 微信小程序 appId |
| | | * @param clientSecret 微信小程序 secret |
| | | * @return WxMaService 对象 |
| | | */ |
| | | private WxMaService buildWxMaService(String clientId, String clientSecret) { |
| | | // 第一步,创建 WxMaRedisBetterConfigImpl 对象 |
| | | WxMaRedisBetterConfigImpl configStorage = new WxMaRedisBetterConfigImpl( |
| | | new RedisTemplateWxRedisOps(stringRedisTemplate), |
| | | wxMaProperties.getConfigStorage().getKeyPrefix()); |
| | | configStorage.setAppid(clientId); |
| | | configStorage.setSecret(clientSecret); |
| | | |
| | | // 第二步,创建 WxMpService 对象 |
| | | WxMaService service = new WxMaServiceImpl(); |
| | | service.setWxMaConfig(configStorage); |
| | | return service; |
| | | } |
| | | |
| | | // =================== 客户端管理 =================== |