WxOfficialAccountHttpClient.java
8.74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
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);
}
}