WxOfficialAccountHttpClient.java 8.74 KB
package com.infoloop.tianting.service.client;

import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto;
import org.springframework.lang.Nullable;
import com.infoloop.tianting.utils.JsonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import javax.annotation.PostConstruct;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 微信公众号 HTTP 客户端
 * 用于发送公众号一次性订阅消息
 */
@Slf4j
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class WxOfficialAccountHttpClient {

    /**
     * 公众号一次性订阅消息接口
     * 文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/One-time_subscription_info.html
     */
    private static final String SUBSCRIBE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/template/subscribe?access_token={accessToken}";

    private static final String ACCESS_TOKEN_CACHE_KEY = "wx:official_account:access_token";

    private final RestTemplate restTemplate;
    private final StringRedisTemplate stringRedisTemplate;
    private final Environment environment;

    /** 由 {@link #bindOfficialAccountConfig()} 从 Environment 读取;缺失配置项时为空白,不触发占位符解析 */
    private String appId = "";

    private String appSecret = "";

    private final Object lock = new Object();

    @PostConstruct
    void bindOfficialAccountConfig() {
        this.appId = StringUtils.trimToEmpty(environment.getProperty("officialAccount.appId"));
        this.appSecret = StringUtils.trimToEmpty(environment.getProperty("officialAccount.appSecret"));
    }

    private boolean isOfficialAccountConfigured() {
        return StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appSecret);
    }

    /**
     * 获取公众号 access_token(带缓存)
     *
     * @return token;未配置 appId/appSecret 时返回 null(不请求微信接口)
     */
    @Nullable
    public String fetchStableAccessToken() {
        if (!isOfficialAccountConfigured()) {
            log.warn("未配置 officialAccount.appId / officialAccount.appSecret,跳过获取公众号 access_token");
            return null;
        }
        log.info("Fetching Official Account access token...");
        String cachedToken = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
        if (StringUtils.isNotEmpty(cachedToken)) {
            log.info("Using cached Official Account access token");
            return cachedToken;
        }

        synchronized (lock) {
            cachedToken = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
            if (StringUtils.isNotEmpty(cachedToken)) {
                log.info("Using cached Official Account access token after lock");
                return cachedToken;
            }

            String url = "https://api.weixin.qq.com/cgi-bin/stable_token";
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            Map<String, String> requestBody = new HashMap<>();
            requestBody.put("grant_type", "client_credential");
            requestBody.put("appid", appId);
            requestBody.put("secret", appSecret);
            HttpEntity<Map<String, String>> request = new HttpEntity<>(requestBody, headers);

            ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
            if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
                log.error("Failed to fetch Official Account access token: {}", response);
                throw new IllegalArgumentException("获取公众号 access_token 失败");
            }

            WxUserTokenDto tokenDto = JsonUtil.readJsonAs(response.getBody(), WxUserTokenDto.class);
            log.info("Official Account access token received, expires_in: {}", tokenDto.getExpires_in());

            String accessToken = tokenDto.getAccess_token();
            // 缓存 token,提前 5 分钟过期
            long expireSeconds = tokenDto.getExpires_in() - 300;
            if (expireSeconds > 0) {
                stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, accessToken, expireSeconds, TimeUnit.SECONDS);
            }
            return accessToken;
        }
    }

    /**
     * 发送公众号一次性订阅消息
     * 
     * @param openId     用户在公众号下的 OpenID
     * @param templateId 公众号订阅消息模板 ID
     * @param scene      订阅场景值(用户授权时传入的 scene)
     * @param title      消息标题(15字以内)
     * @param data       模板数据,格式为 Map<String, Map<String, String>>
     * @param url        点击跳转的 URL(可选)
     * @param miniprogram 跳转小程序配置(可选)
     * @return 发送结果
     */
    public SendSubscriptionMessageResponse sendSubscriptionMessage(
            String openId,
            String templateId,
            String scene,
            String title,
            Map<String, Object> data,
            String url,
            Map<String, String> miniprogram) {
        if (!isOfficialAccountConfigured()) {
            log.warn("未配置公众号,跳过发送订阅消息 openId={}", openId);
            return new SendSubscriptionMessageResponse(-1, "微信公众号未配置");
        }
        try {
            String accessToken = fetchStableAccessToken();
            if (accessToken == null) {
                return new SendSubscriptionMessageResponse(-1, "未获取到公众号 access_token");
            }
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);

            Map<String, Object> requestBody = new HashMap<>();
            requestBody.put("touser", openId);
            requestBody.put("template_id", templateId);
            requestBody.put("scene", scene);
            requestBody.put("title", title);
            requestBody.put("data", data);

            if (StringUtils.isNotEmpty(url)) {
                requestBody.put("url", url);
            }
            if (miniprogram != null && !miniprogram.isEmpty()) {
                requestBody.put("miniprogram", miniprogram);
            }

            HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);
            ResponseEntity<SendSubscriptionMessageResponse> response = restTemplate.postForEntity(
                    SUBSCRIBE_MESSAGE_URL, requestEntity, SendSubscriptionMessageResponse.class, accessToken);

            if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
                log.error("Failed to send Official Account subscription message: {}", response);
                throw new IllegalArgumentException("发送公众号订阅消息失败");
            }

            SendSubscriptionMessageResponse result = response.getBody();
            if (result.getErrcode() != null && result.getErrcode() != 0) {
                log.error("Official Account subscription message error: errcode={}, errmsg={}",
                        result.getErrcode(), result.getErrmsg());
            } else {
                log.info("Official Account subscription message sent successfully to openId: {}", openId);
            }

            return result;
        } catch (Exception e) {
            log.error("Error sending Official Account subscription message to openId: {}", openId, e);
            throw new IllegalArgumentException("发送公众号订阅消息失败: " + e.getMessage());
        }
    }

    /**
     * 发送公众号一次性订阅消息(简化版,跳转到小程序)
     */
    public SendSubscriptionMessageResponse sendSubscriptionMessageToMiniProgram(
            String openId,
            String templateId,
            String scene,
            String title,
            Map<String, Object> data,
            String miniProgramAppId,
            String miniProgramPagePath) {
        Map<String, String> miniprogram = new HashMap<>();
        miniprogram.put("appid", miniProgramAppId);
        miniprogram.put("pagepath", miniProgramPagePath);
        return sendSubscriptionMessage(openId, templateId, scene, title, data, null, miniprogram);
    }
}