Jiaqi Xia

feat: 微信点餐推送

...@@ -83,4 +83,9 @@ public interface ConfigConstants { ...@@ -83,4 +83,9 @@ public interface ConfigConstants {
83 String MINI_PROGRAM_APP_SECRET = "${miniprogram.appSecret}"; 83 String MINI_PROGRAM_APP_SECRET = "${miniprogram.appSecret}";
84 String MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID = "${miniprogram.orderReminderTemplateId}"; 84 String MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID = "${miniprogram.orderReminderTemplateId}";
85 85
86 + // 公众号配置(用于订阅消息推送)
87 + String OFFICIAL_ACCOUNT_APP_ID = "${officialAccount.appId}";
88 + String OFFICIAL_ACCOUNT_APP_SECRET = "${officialAccount.appSecret}";
89 + String OFFICIAL_ACCOUNT_ORDER_REMINDER_TEMPLATE_ID = "${officialAccount.orderReminderTemplateId}";
90 +
86 } 91 }
......
1 +package com.infoloop.tianting.logic.task;
2 +
3 +import com.infoloop.tianting.deliveryruleservice.Rule;
4 +import com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse;
5 +import com.infoloop.tianting.mealorderservice.SingleMpAccountRpcResponse;
6 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
7 +import com.infoloop.tianting.service.OrderReminderService;
8 +import com.infoloop.tianting.service.client.DeliveryRuleServiceRpcClient;
9 +import com.infoloop.tianting.service.client.MealOrderServiceRpcClient;
10 +import lombok.extern.slf4j.Slf4j;
11 +import org.apache.commons.collections4.CollectionUtils;
12 +import org.springframework.beans.factory.annotation.Qualifier;
13 +import org.springframework.scheduling.annotation.Scheduled;
14 +import org.springframework.stereotype.Component;
15 +
16 +import java.time.DayOfWeek;
17 +import java.time.LocalDate;
18 +import java.util.ArrayList;
19 +import java.util.Collections;
20 +import java.util.List;
21 +import java.util.Map;
22 +import java.util.Set;
23 +import java.util.concurrent.CompletableFuture;
24 +import java.util.concurrent.ConcurrentHashMap;
25 +import java.util.concurrent.Executor;
26 +import java.util.concurrent.atomic.AtomicInteger;
27 +import java.util.stream.Collectors;
28 +
29 +import static com.infoloop.tianting.constant.ConfigConstants.ASYNC_EXECUTOR;
30 +
31 +/**
32 + * 订餐提醒定时任务
33 + * 在订餐周期开启前(周一 9:00)向已授权的家长发送微信服务通知
34 + *
35 + * 推送逻辑:
36 + * 1. 查询所有待发送的订阅消息(未删除的记录)
37 + * 2. 按 openId 去重,每个用户只推送一次
38 + * 3. 通过 openId 查询活跃的小程序用户
39 + * 4. 根据配送规则配置生成订餐时间提示
40 + * 5. 发送消息,发送成功后标记为已删除
41 + */
42 +@Slf4j
43 +@Component
44 +public class OrderReminderTask {
45 +
46 + private static final int ENTERPRISE_ID = 449;
47 +
48 + /**
49 + * 温馨提醒内容
50 + */
51 + private static final String TIPS = "请点击卡片,准时进入小程序完成预订";
52 +
53 + /**
54 + * 订餐时间配置
55 + */
56 + private record OrderTimeConfig(String startTime, String endTime) {}
57 +
58 + private final MealOrderServiceRpcClient mealOrderServiceRpcClient;
59 + private final DeliveryRuleServiceRpcClient deliveryRuleServiceRpcClient;
60 + private final OrderReminderService orderReminderService;
61 + private final Executor asyncExecutor;
62 +
63 + public OrderReminderTask(
64 + MealOrderServiceRpcClient mealOrderServiceRpcClient,
65 + DeliveryRuleServiceRpcClient deliveryRuleServiceRpcClient,
66 + OrderReminderService orderReminderService,
67 + @Qualifier(ASYNC_EXECUTOR) Executor asyncExecutor) {
68 + this.mealOrderServiceRpcClient = mealOrderServiceRpcClient;
69 + this.deliveryRuleServiceRpcClient = deliveryRuleServiceRpcClient;
70 + this.orderReminderService = orderReminderService;
71 + this.asyncExecutor = asyncExecutor;
72 + }
73 +
74 + /**
75 + * 每周一 9:00 执行订餐提醒推送
76 + */
77 + @Scheduled(cron = "0 0 9 * * MON")
78 + public void executeTask() {
79 + log.info("OrderReminderTask started");
80 +
81 + try {
82 + // 1. 获取配送规则配置,用于生成订餐时间提示
83 + OrderTimeConfig orderTimeConfig = getOrderTimeConfigFromRule();
84 + if (orderTimeConfig == null) {
85 + log.warn("Failed to get order time config from rule, using default");
86 + orderTimeConfig = new OrderTimeConfig("请查看小程序", "请查看小程序");
87 + }
88 + log.info("Order time config: startTime={}, endTime={}", orderTimeConfig.startTime(), orderTimeConfig.endTime());
89 +
90 + // 2. 查询所有待发送的订阅消息(未删除的记录)
91 + List<PendingOrderSubscriptionMessageRpcResponse> pendingMessages =
92 + mealOrderServiceRpcClient.queryPendingOrderSubscriptionMessages(ENTERPRISE_ID);
93 +
94 + if (pendingMessages.isEmpty()) {
95 + log.info("No pending order subscription messages to send");
96 + return;
97 + }
98 + log.info("Found {} pending order subscription messages", pendingMessages.size());
99 +
100 + // 3. 提取所有 openId,查询活跃的小程序用户
101 + Set<String> openIds = pendingMessages.stream()
102 + .map(PendingOrderSubscriptionMessageRpcResponse::getOpenId)
103 + .collect(Collectors.toSet());
104 +
105 + Map<String, SingleMpAccountRpcResponse> activeMpAccountMap = getActiveMpAccountMap(openIds);
106 + log.info("Found {} active mp accounts from {} openIds", activeMpAccountMap.size(), openIds.size());
107 +
108 + int totalMessages = pendingMessages.size();
109 + AtomicInteger skippedInactive = new AtomicInteger(0);
110 + AtomicInteger skippedDuplicate = new AtomicInteger(0);
111 + AtomicInteger sentCount = new AtomicInteger(0);
112 + List<Long> sentMessageIds = Collections.synchronizedList(new ArrayList<>());
113 + Set<String> sentOpenIds = ConcurrentHashMap.newKeySet(); // 线程安全的 Set,用于 openId 去重
114 +
115 + // 4. 按 openId 去重,筛选出需要发送的消息
116 + List<PendingOrderSubscriptionMessageRpcResponse> messagesToSend = new ArrayList<>();
117 + for (PendingOrderSubscriptionMessageRpcResponse message : pendingMessages) {
118 + String openId = message.getOpenId();
119 +
120 + // 检查用户是否活跃
121 + if (!activeMpAccountMap.containsKey(openId)) {
122 + log.debug("Skipping message {} - user {} is not active", message.getId(), openId);
123 + skippedInactive.incrementAndGet();
124 + sentMessageIds.add(message.getId());
125 + continue;
126 + }
127 +
128 + // openId 去重:每个用户只推送一次
129 + if (!sentOpenIds.add(openId)) {
130 + log.debug("Skipping message {} - already queued for openId: {}", message.getId(), openId);
131 + skippedDuplicate.incrementAndGet();
132 + sentMessageIds.add(message.getId());
133 + continue;
134 + }
135 +
136 + messagesToSend.add(message);
137 + }
138 +
139 + log.info("Messages to send: {}, Skipped inactive: {}, Skipped duplicate: {}",
140 + messagesToSend.size(), skippedInactive.get(), skippedDuplicate.get());
141 +
142 + // 5. 多线程并发发送消息
143 + final OrderTimeConfig finalOrderTimeConfig = orderTimeConfig;
144 + List<CompletableFuture<Void>> futures = messagesToSend.stream()
145 + .map(message -> CompletableFuture.runAsync(() -> {
146 + try {
147 + boolean success = sendReminderMessage(message.getOpenId(), message, finalOrderTimeConfig);
148 + if (success) {
149 + sentMessageIds.add(message.getId());
150 + sentCount.incrementAndGet();
151 + }
152 + } catch (Exception e) {
153 + log.error("Failed to send reminder to openId: {}, messageId: {}",
154 + message.getOpenId(), message.getId(), e);
155 + }
156 + }, asyncExecutor))
157 + .toList();
158 +
159 + // 等待所有任务完成
160 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
161 +
162 + // 6. 批量标记已发送的消息(逻辑删除)
163 + if (!sentMessageIds.isEmpty()) {
164 + int affectedRows = mealOrderServiceRpcClient.batchDeleteOrderSubscriptionMessagesByIds(
165 + ENTERPRISE_ID, sentMessageIds);
166 + log.info("Marked {} messages as sent", affectedRows);
167 + }
168 +
169 + log.info("OrderReminderTask completed. Total: {}, Sent: {}, SkippedInactive: {}, SkippedDuplicate: {}",
170 + totalMessages, sentCount.get(), skippedInactive.get(), skippedDuplicate.get());
171 +
172 + } catch (Exception e) {
173 + log.error("OrderReminderTask failed", e);
174 + }
175 + }
176 +
177 + /**
178 + * 从配送规则配置中获取订餐时间配置
179 + * 返回开始时间和结束时间,格式:周x xx:xx
180 + */
181 + private OrderTimeConfig getOrderTimeConfigFromRule() {
182 + try {
183 + final var response = deliveryRuleServiceRpcClient.getDefaultDeliveryTemplateAndRule(ENTERPRISE_ID);
184 + final var rule = response.getRule();
185 + if (rule == null || rule.getConfigJson().getRulesList().isEmpty()) {
186 + log.warn("Delivery rule not configured");
187 + return null;
188 + }
189 +
190 + final var cfg = rule.getConfigJson().getRules(0);
191 + final String startTimeStr = cfg.getStartTime();
192 + final String endTimeStr = cfg.getEndTime();
193 + if (startTimeStr == null || endTimeStr == null) {
194 + log.warn("Delivery rule time config incomplete");
195 + return null;
196 + }
197 +
198 + // 获取启用的日期
199 + List<DayOfWeek> enabledDays = getEnabledDays(cfg);
200 + if (enabledDays.isEmpty()) {
201 + log.warn("No enabled days in delivery rule");
202 + return null;
203 + }
204 +
205 + // 格式化时间(去掉秒)
206 + String startTime = formatTime(startTimeStr);
207 + String endTime = formatTime(endTimeStr);
208 +
209 + // 获取第一个和最后一个启用日
210 + DayOfWeek firstDay = enabledDays.get(0);
211 + DayOfWeek lastDay = enabledDays.get(enabledDays.size() - 1);
212 +
213 + // 计算本周的实际日期(微信time类型需要具体日期格式)
214 + LocalDate today = LocalDate.now();
215 + LocalDate startDate = today.with(java.time.temporal.TemporalAdjusters.nextOrSame(firstDay));
216 + LocalDate endDate = today.with(java.time.temporal.TemporalAdjusters.nextOrSame(lastDay));
217 +
218 + // 如果结束日期在开始日期之前,说明跨周了,结束日期加一周
219 + if (endDate.isBefore(startDate)) {
220 + endDate = endDate.plusWeeks(1);
221 + }
222 +
223 + // 格式化为微信接受的格式:yyyy年MM月dd日 HH:mm
224 + java.time.format.DateTimeFormatter dateFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日");
225 + String formattedStartTime = startDate.format(dateFormatter) + " " + startTime;
226 + String formattedEndTime = endDate.format(dateFormatter) + " " + endTime;
227 +
228 + return new OrderTimeConfig(formattedStartTime, formattedEndTime);
229 +
230 + } catch (Exception e) {
231 + log.error("Failed to get order time config from rule", e);
232 + return null;
233 + }
234 + }
235 +
236 + /**
237 + * 获取启用的日期列表(按周一到周日排序)
238 + */
239 + private List<DayOfWeek> getEnabledDays(Rule cfg) {
240 + List<DayOfWeek> enabledDays = new ArrayList<>();
241 + if (Boolean.TRUE.equals(cfg.getMonday())) enabledDays.add(DayOfWeek.MONDAY);
242 + if (Boolean.TRUE.equals(cfg.getTuesday())) enabledDays.add(DayOfWeek.TUESDAY);
243 + if (Boolean.TRUE.equals(cfg.getWednesday())) enabledDays.add(DayOfWeek.WEDNESDAY);
244 + if (Boolean.TRUE.equals(cfg.getThursday())) enabledDays.add(DayOfWeek.THURSDAY);
245 + if (Boolean.TRUE.equals(cfg.getFriday())) enabledDays.add(DayOfWeek.FRIDAY);
246 + if (Boolean.TRUE.equals(cfg.getSaturday())) enabledDays.add(DayOfWeek.SATURDAY);
247 + if (Boolean.TRUE.equals(cfg.getSunday())) enabledDays.add(DayOfWeek.SUNDAY);
248 + return enabledDays;
249 + }
250 +
251 + /**
252 + * 格式化时间字符串(HH:mm:ss -> HH:mm)
253 + */
254 + private String formatTime(String timeStr) {
255 + if (timeStr == null || timeStr.length() < 5) {
256 + return timeStr;
257 + }
258 + // 取前5位 HH:mm
259 + return timeStr.substring(0, 5);
260 + }
261 +
262 + /**
263 + * 获取活跃的小程序用户 Map(openId -> MpAccount)
264 + */
265 + private Map<String, SingleMpAccountRpcResponse> getActiveMpAccountMap(Set<String> openIds) {
266 + if (CollectionUtils.isEmpty(openIds)) {
267 + return Collections.emptyMap();
268 + }
269 + List<SingleMpAccountRpcResponse> allAccounts = mealOrderServiceRpcClient.queryAllActiveMpAccounts(ENTERPRISE_ID);
270 + return allAccounts.stream()
271 + .filter(account -> openIds.contains(account.getOpenId()))
272 + .collect(Collectors.toMap(SingleMpAccountRpcResponse::getOpenId, account -> account, (a, b) -> a));
273 + }
274 +
275 + /**
276 + * 发送单条提醒消息
277 + *
278 + * 小程序模板字段:
279 + * - time4: 开始时间(周x xx:xx)
280 + * - time5: 结束时间(周x xx:xx)
281 + * - thing1: 温馨提醒
282 + */
283 + private boolean sendReminderMessage(String openId, PendingOrderSubscriptionMessageRpcResponse message, OrderTimeConfig orderTimeConfig) {
284 + String jumpPath = message.getJumpPath();
285 +
286 + // 发送消息
287 + SendSubscriptionMessageResponse response = orderReminderService.sendOrderReminder(
288 + openId,
289 + jumpPath,
290 + orderTimeConfig.startTime(), // time4: 开始时间
291 + orderTimeConfig.endTime(), // time5: 结束时间
292 + TIPS // thing1: 温馨提醒
293 + );
294 +
295 + // 判断发送结果
296 + if (response.getErrcode() != null && response.getErrcode() == 0) {
297 + return true;
298 + }
299 +
300 + // 记录失败原因
301 + log.warn("Failed to send reminder. openId: {}, errcode: {}, errmsg: {}",
302 + openId, response.getErrcode(), response.getErrmsg());
303 + return false;
304 + }
305 +
306 +}
1 +package com.infoloop.tianting.logic.task;
2 +
3 +import lombok.RequiredArgsConstructor;
4 +import lombok.extern.slf4j.Slf4j;
5 +import org.springframework.boot.CommandLineRunner;
6 +import org.springframework.stereotype.Component;
7 +
8 +import java.util.Arrays;
9 +
10 +/**
11 + * 订餐提醒任务命令行执行器
12 + * 通过命令行参数 --trigger-order-reminder 触发执行
13 + *
14 + * 使用方式:
15 + * java -jar app.jar --trigger-order-reminder
16 + * 或者
17 + * java -jar app.jar --spring.main.web-application-type=none --trigger-order-reminder
18 + */
19 +@Slf4j
20 +@Component
21 +@RequiredArgsConstructor
22 +public class OrderReminderTaskRunner implements CommandLineRunner {
23 +
24 + private final OrderReminderTask orderReminderTask;
25 +
26 + @Override
27 + public void run(String... args) {
28 + boolean shouldTrigger = Arrays.asList(args).contains("--trigger-order-reminder");
29 + if (shouldTrigger) {
30 + log.info("OrderReminderTaskRunner: Triggering order reminder task via command line");
31 + orderReminderTask.executeTask();
32 + log.info("OrderReminderTaskRunner: Task execution completed");
33 + }
34 + }
35 +}
...@@ -4,19 +4,24 @@ import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse ...@@ -4,19 +4,24 @@ import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse
4 4
5 /** 5 /**
6 * 订餐提醒服务接口 6 * 订餐提醒服务接口
7 - * 用于发送订餐周期开启前的提醒消息 7 + * 用于发送订餐周期开启前的提醒消息(小程序订阅消息)
8 */ 8 */
9 public interface OrderReminderService { 9 public interface OrderReminderService {
10 /** 10 /**
11 * 发送订餐提醒消息 11 * 发送订餐提醒消息
12 * 12 *
13 - * @param openId 用户 OpenID 13 + * 小程序模板字段(模板ID: LtCsW7ciF0edUwNwt3h5C_ZCIRzOeO7MnvozRl2CxlE):
14 - * @param page 点击跳转页面路径,例如:pages/order/index?cycleId=202501 14 + * - time4: 开始时间
15 - * @param reminderContent 提醒内容,例如:"下次订餐即将开启" 15 + * - time5: 结束时间
16 - * @param orderTime 订餐时间,例如:"周一 09:00 至 周三 12:00" 16 + * - thing1: 温馨提醒
17 - * @param tips 温馨提示,例如:"请点击卡片,准时进入小程序完成预订" 17 + *
18 + * @param openId 用户 OpenID(小程序的 openId)
19 + * @param page 点击跳转小程序页面路径
20 + * @param startTime 开始时间,例如:"周一 09:00"
21 + * @param endTime 结束时间,例如:"周四 18:00"
22 + * @param tips 温馨提醒,例如:"请点击卡片,准时进入小程序完成预订"
18 * @return 发送结果 23 * @return 发送结果
19 */ 24 */
20 SendSubscriptionMessageResponse sendOrderReminder(String openId, String page, 25 SendSubscriptionMessageResponse sendOrderReminder(String openId, String page,
21 - String reminderContent, String orderTime, String tips); 26 + String startTime, String endTime, String tips);
22 } 27 }
......
...@@ -12,8 +12,13 @@ import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcRequest; ...@@ -12,8 +12,13 @@ import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcRequest;
12 import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcResponse; 12 import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcResponse;
13 import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcRequest; 13 import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcRequest;
14 import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcResponse; 14 import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcResponse;
15 +import com.infoloop.tianting.mealorderservice.BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest;
15 import com.infoloop.tianting.mealorderservice.CreateOrderSubscriptionMessageRpcRequest; 16 import com.infoloop.tianting.mealorderservice.CreateOrderSubscriptionMessageRpcRequest;
16 import com.infoloop.tianting.mealorderservice.DeleteDinersByIdsRpcRequest; 17 import com.infoloop.tianting.mealorderservice.DeleteDinersByIdsRpcRequest;
18 +import com.infoloop.tianting.mealorderservice.GetUserOrderSubscriptionMessageHistoryRpcRequest;
19 +import com.infoloop.tianting.mealorderservice.OrderSubscriptionMessageRpcResponse;
20 +import com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse;
21 +import com.infoloop.tianting.mealorderservice.QueryPendingOrderSubscriptionMessagesRpcRequest;
17 import com.infoloop.tianting.mealorderservice.DeleteMpAccountDinerRefsByIdsRpcRequest; 22 import com.infoloop.tianting.mealorderservice.DeleteMpAccountDinerRefsByIdsRpcRequest;
18 import com.infoloop.tianting.mealorderservice.DinerCreation; 23 import com.infoloop.tianting.mealorderservice.DinerCreation;
19 import com.infoloop.tianting.mealorderservice.DinerModification; 24 import com.infoloop.tianting.mealorderservice.DinerModification;
...@@ -37,6 +42,7 @@ import com.infoloop.tianting.mealorderservice.QueryGradeClassesByConditionRpcReq ...@@ -37,6 +42,7 @@ import com.infoloop.tianting.mealorderservice.QueryGradeClassesByConditionRpcReq
37 import com.infoloop.tianting.mealorderservice.QueryLatestMealMenuRecordsByClientIdsRpcRequest; 42 import com.infoloop.tianting.mealorderservice.QueryLatestMealMenuRecordsByClientIdsRpcRequest;
38 import com.infoloop.tianting.mealorderservice.QueryMealOrdersByConditionRpcRequest; 43 import com.infoloop.tianting.mealorderservice.QueryMealOrdersByConditionRpcRequest;
39 import com.infoloop.tianting.mealorderservice.QueryMpAccountDinerRefsByConditionRpcRequest; 44 import com.infoloop.tianting.mealorderservice.QueryMpAccountDinerRefsByConditionRpcRequest;
45 +import com.infoloop.tianting.mealorderservice.QueryMpAccountsByConditionRpcRequest;
40 import com.infoloop.tianting.mealorderservice.QueryReserveMealOrdersByDinerIdsRpcRequest; 46 import com.infoloop.tianting.mealorderservice.QueryReserveMealOrdersByDinerIdsRpcRequest;
41 import com.infoloop.tianting.mealorderservice.SingleDinerRpcResponse; 47 import com.infoloop.tianting.mealorderservice.SingleDinerRpcResponse;
42 import com.infoloop.tianting.mealorderservice.SingleGradeClassRpcResponse; 48 import com.infoloop.tianting.mealorderservice.SingleGradeClassRpcResponse;
...@@ -105,6 +111,19 @@ public class MealOrderServiceRpcClient { ...@@ -105,6 +111,19 @@ public class MealOrderServiceRpcClient {
105 .build()); 111 .build());
106 } 112 }
107 113
114 + /**
115 + * 查询所有活跃的小程序用户
116 + */
117 + public List<SingleMpAccountRpcResponse> queryAllActiveMpAccounts(int enterpriseId) {
118 + return mealOrderServiceRpcBlockingStub.queryMpAccountsByCondition(
119 + QueryMpAccountsByConditionRpcRequest.newBuilder()
120 + .setEnterpriseId(enterpriseId)
121 + .setShouldFilterStatus(false)
122 + .setIncludeDeleted(false)
123 + .build()
124 + ).getResponsesList();
125 + }
126 +
108 public List<SingleMpAccountDinerRefRpcResponse> queryMpAccountDinerRefsByCondition(int enterpriseId, List<Integer> mpAccountIds, List<Integer> dinerIds) { 127 public List<SingleMpAccountDinerRefRpcResponse> queryMpAccountDinerRefsByCondition(int enterpriseId, List<Integer> mpAccountIds, List<Integer> dinerIds) {
109 return mealOrderServiceRpcBlockingStub.queryMpAccountDinerRefsByCondition(QueryMpAccountDinerRefsByConditionRpcRequest.newBuilder() 128 return mealOrderServiceRpcBlockingStub.queryMpAccountDinerRefsByCondition(QueryMpAccountDinerRefsByConditionRpcRequest.newBuilder()
110 .setEnterpriseId(enterpriseId) 129 .setEnterpriseId(enterpriseId)
...@@ -328,4 +347,48 @@ public class MealOrderServiceRpcClient { ...@@ -328,4 +347,48 @@ public class MealOrderServiceRpcClient {
328 } 347 }
329 return mealOrderServiceRpcBlockingStub.createOrderSubscriptionMessage(req.build()).getId(); 348 return mealOrderServiceRpcBlockingStub.createOrderSubscriptionMessage(req.build()).getId();
330 } 349 }
350 +
351 + /**
352 + * 查询用户订阅消息历史(未删除的记录)
353 + */
354 + public List<OrderSubscriptionMessageRpcResponse> getUserOrderSubscriptionMessageHistory(
355 + int enterpriseId,
356 + String openId,
357 + @Nullable Long orderPeriodStartDate
358 + ) {
359 + final var reqBuilder = GetUserOrderSubscriptionMessageHistoryRpcRequest.newBuilder()
360 + .setEnterpriseId(enterpriseId)
361 + .setOpenId(openId);
362 + if (orderPeriodStartDate != null) {
363 + reqBuilder.setOrderPeriodStartDate(orderPeriodStartDate);
364 + }
365 + return mealOrderServiceRpcBlockingStub.getUserOrderSubscriptionMessageHistory(reqBuilder.build())
366 + .getResponseList();
367 + }
368 +
369 + /**
370 + * 批量删除订阅消息(逻辑删除,发送成功后调用)
371 + */
372 + public int batchDeleteOrderSubscriptionMessagesByIds(int enterpriseId, List<Long> ids) {
373 + if (CollectionUtils.isEmpty(ids)) {
374 + return 0;
375 + }
376 + return mealOrderServiceRpcBlockingStub.batchDeleteOrderSubscriptionMessagesByIds(
377 + BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest.newBuilder()
378 + .setEnterpriseId(enterpriseId)
379 + .addAllIds(ids)
380 + .build()
381 + ).getAffectedRows();
382 + }
383 +
384 + /**
385 + * 查询待发送的订阅消息(未删除的记录)
386 + */
387 + public List<PendingOrderSubscriptionMessageRpcResponse> queryPendingOrderSubscriptionMessages(int enterpriseId) {
388 + return mealOrderServiceRpcBlockingStub.queryPendingOrderSubscriptionMessages(
389 + QueryPendingOrderSubscriptionMessagesRpcRequest.newBuilder()
390 + .setEnterpriseId(enterpriseId)
391 + .build()
392 + ).getResponsesList();
393 + }
331 } 394 }
......
1 +package com.infoloop.tianting.service.client;
2 +
3 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
4 +import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto;
5 +import com.infoloop.tianting.utils.JsonUtil;
6 +import lombok.RequiredArgsConstructor;
7 +import lombok.extern.slf4j.Slf4j;
8 +import org.apache.commons.lang3.StringUtils;
9 +import org.springframework.beans.factory.annotation.Autowired;
10 +import org.springframework.beans.factory.annotation.Value;
11 +import org.springframework.data.redis.core.StringRedisTemplate;
12 +import org.springframework.http.HttpEntity;
13 +import org.springframework.http.HttpHeaders;
14 +import org.springframework.http.MediaType;
15 +import org.springframework.http.ResponseEntity;
16 +import org.springframework.stereotype.Service;
17 +import org.springframework.web.client.RestTemplate;
18 +
19 +import java.util.HashMap;
20 +import java.util.Map;
21 +import java.util.concurrent.TimeUnit;
22 +
23 +import static com.infoloop.tianting.constant.ConfigConstants.OFFICIAL_ACCOUNT_APP_ID;
24 +import static com.infoloop.tianting.constant.ConfigConstants.OFFICIAL_ACCOUNT_APP_SECRET;
25 +
26 +/**
27 + * 微信公众号 HTTP 客户端
28 + * 用于发送公众号一次性订阅消息
29 + */
30 +@Slf4j
31 +@Service
32 +@RequiredArgsConstructor(onConstructor = @__(@Autowired))
33 +public class WxOfficialAccountHttpClient {
34 +
35 + /**
36 + * 公众号一次性订阅消息接口
37 + * 文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/One-time_subscription_info.html
38 + */
39 + private static final String SUBSCRIBE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/template/subscribe?access_token={accessToken}";
40 +
41 + private static final String ACCESS_TOKEN_CACHE_KEY = "wx:official_account:access_token";
42 +
43 + private final RestTemplate restTemplate;
44 + private final StringRedisTemplate stringRedisTemplate;
45 +
46 + @Value(OFFICIAL_ACCOUNT_APP_ID)
47 + private String appId;
48 +
49 + @Value(OFFICIAL_ACCOUNT_APP_SECRET)
50 + private String appSecret;
51 +
52 + private final Object lock = new Object();
53 +
54 + /**
55 + * 获取公众号 access_token(带缓存)
56 + */
57 + public String fetchStableAccessToken() {
58 + log.info("Fetching Official Account access token...");
59 + String cachedToken = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
60 + if (StringUtils.isNotEmpty(cachedToken)) {
61 + log.info("Using cached Official Account access token");
62 + return cachedToken;
63 + }
64 +
65 + synchronized (lock) {
66 + cachedToken = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
67 + if (StringUtils.isNotEmpty(cachedToken)) {
68 + log.info("Using cached Official Account access token after lock");
69 + return cachedToken;
70 + }
71 +
72 + String url = "https://api.weixin.qq.com/cgi-bin/stable_token";
73 + HttpHeaders headers = new HttpHeaders();
74 + headers.setContentType(MediaType.APPLICATION_JSON);
75 + Map<String, String> requestBody = new HashMap<>();
76 + requestBody.put("grant_type", "client_credential");
77 + requestBody.put("appid", appId);
78 + requestBody.put("secret", appSecret);
79 + HttpEntity<Map<String, String>> request = new HttpEntity<>(requestBody, headers);
80 +
81 + ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
82 + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
83 + log.error("Failed to fetch Official Account access token: {}", response);
84 + throw new IllegalArgumentException("获取公众号 access_token 失败");
85 + }
86 +
87 + WxUserTokenDto tokenDto = JsonUtil.readJsonAs(response.getBody(), WxUserTokenDto.class);
88 + log.info("Official Account access token received, expires_in: {}", tokenDto.getExpires_in());
89 +
90 + String accessToken = tokenDto.getAccess_token();
91 + // 缓存 token,提前 5 分钟过期
92 + long expireSeconds = tokenDto.getExpires_in() - 300;
93 + if (expireSeconds > 0) {
94 + stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, accessToken, expireSeconds, TimeUnit.SECONDS);
95 + }
96 + return accessToken;
97 + }
98 + }
99 +
100 + /**
101 + * 发送公众号一次性订阅消息
102 + *
103 + * @param openId 用户在公众号下的 OpenID
104 + * @param templateId 公众号订阅消息模板 ID
105 + * @param scene 订阅场景值(用户授权时传入的 scene)
106 + * @param title 消息标题(15字以内)
107 + * @param data 模板数据,格式为 Map<String, Map<String, String>>
108 + * @param url 点击跳转的 URL(可选)
109 + * @param miniprogram 跳转小程序配置(可选)
110 + * @return 发送结果
111 + */
112 + public SendSubscriptionMessageResponse sendSubscriptionMessage(
113 + String openId,
114 + String templateId,
115 + String scene,
116 + String title,
117 + Map<String, Object> data,
118 + String url,
119 + Map<String, String> miniprogram) {
120 + try {
121 + String accessToken = fetchStableAccessToken();
122 + HttpHeaders headers = new HttpHeaders();
123 + headers.setContentType(MediaType.APPLICATION_JSON);
124 +
125 + Map<String, Object> requestBody = new HashMap<>();
126 + requestBody.put("touser", openId);
127 + requestBody.put("template_id", templateId);
128 + requestBody.put("scene", scene);
129 + requestBody.put("title", title);
130 + requestBody.put("data", data);
131 +
132 + if (StringUtils.isNotEmpty(url)) {
133 + requestBody.put("url", url);
134 + }
135 + if (miniprogram != null && !miniprogram.isEmpty()) {
136 + requestBody.put("miniprogram", miniprogram);
137 + }
138 +
139 + HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);
140 + ResponseEntity<SendSubscriptionMessageResponse> response = restTemplate.postForEntity(
141 + SUBSCRIBE_MESSAGE_URL, requestEntity, SendSubscriptionMessageResponse.class, accessToken);
142 +
143 + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
144 + log.error("Failed to send Official Account subscription message: {}", response);
145 + throw new IllegalArgumentException("发送公众号订阅消息失败");
146 + }
147 +
148 + SendSubscriptionMessageResponse result = response.getBody();
149 + if (result.getErrcode() != null && result.getErrcode() != 0) {
150 + log.error("Official Account subscription message error: errcode={}, errmsg={}",
151 + result.getErrcode(), result.getErrmsg());
152 + } else {
153 + log.info("Official Account subscription message sent successfully to openId: {}", openId);
154 + }
155 +
156 + return result;
157 + } catch (Exception e) {
158 + log.error("Error sending Official Account subscription message to openId: {}", openId, e);
159 + throw new IllegalArgumentException("发送公众号订阅消息失败: " + e.getMessage());
160 + }
161 + }
162 +
163 + /**
164 + * 发送公众号一次性订阅消息(简化版,跳转到小程序)
165 + */
166 + public SendSubscriptionMessageResponse sendSubscriptionMessageToMiniProgram(
167 + String openId,
168 + String templateId,
169 + String scene,
170 + String title,
171 + Map<String, Object> data,
172 + String miniProgramAppId,
173 + String miniProgramPagePath) {
174 + Map<String, String> miniprogram = new HashMap<>();
175 + miniprogram.put("appid", miniProgramAppId);
176 + miniprogram.put("pagepath", miniProgramPagePath);
177 + return sendSubscriptionMessage(openId, templateId, scene, title, data, null, miniprogram);
178 + }
179 +}
...@@ -16,7 +16,16 @@ import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_ ...@@ -16,7 +16,16 @@ import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_
16 16
17 /** 17 /**
18 * 订餐提醒服务实现 18 * 订餐提醒服务实现
19 - * 将业务参数转换为微信订阅消息模板格式并发送 19 + * 使用小程序订阅消息接口发送提醒
20 + *
21 + * 小程序模板字段(模板ID: LtCsW7ciF0edUwNwt3h5C_ZCIRzOeO7MnvozRl2CxlE):
22 + * - time4: 开始时间
23 + * - time5: 结束时间
24 + * - thing1: 温馨提醒
25 + *
26 + * 注意:
27 + * 1. 使用的是小程序的 access_token 和 template_id
28 + * 2. openId 是用户在小程序下的 openId
20 */ 29 */
21 @Slf4j 30 @Slf4j
22 @Service 31 @Service
...@@ -31,36 +40,41 @@ public class OrderReminderServiceImpl implements OrderReminderService { ...@@ -31,36 +40,41 @@ public class OrderReminderServiceImpl implements OrderReminderService {
31 @Override 40 @Override
32 public SendSubscriptionMessageResponse sendOrderReminder(String openId, 41 public SendSubscriptionMessageResponse sendOrderReminder(String openId,
33 String page, 42 String page,
34 - String reminderContent, 43 + String startTime,
35 - String orderTime, 44 + String endTime,
36 String tips) { 45 String tips) {
37 - log.info("Sending order reminder to openId: {}, page: {}", openId, page); 46 + log.info("Sending order reminder via MiniProgram to openId: {}, page: {}, startTime: {}, endTime: {}, tips: {}",
47 + openId, page, startTime, endTime, tips);
38 48
39 - // 将业务参数转换为微信模板数据格式 49 + // 构建小程序订阅消息模板数据
40 - // 根据文档,模板字段为:thing1 (提醒内容), time2 (订餐时间), thing3 (温馨提示)
41 Map<String, Object> data = new HashMap<>(); 50 Map<String, Object> data = new HashMap<>();
42 51
43 - // thing1: 提醒内容 52 + // time4: 开始时间
44 - Map<String, String> thing1Value = new HashMap<>(); 53 + Map<String, String> time4Value = new HashMap<>();
45 - thing1Value.put("value", reminderContent); 54 + time4Value.put("value", startTime);
46 - data.put("thing1", thing1Value); 55 + data.put("time4", time4Value);
47 56
48 - // time2: 订餐时间 57 + // time5: 结束时间
49 - Map<String, String> time2Value = new HashMap<>(); 58 + Map<String, String> time5Value = new HashMap<>();
50 - time2Value.put("value", orderTime); 59 + time5Value.put("value", endTime);
51 - data.put("time2", time2Value); 60 + data.put("time5", time5Value);
52 61
53 - // thing3: 温馨提示 62 + // thing1: 温馨提醒
54 - Map<String, String> thing3Value = new HashMap<>(); 63 + Map<String, String> thing1Value = new HashMap<>();
55 - thing3Value.put("value", tips); 64 + thing1Value.put("value", tips);
56 - data.put("thing3", thing3Value); 65 + data.put("thing1", thing1Value);
57 66
58 try { 67 try {
68 + // 使用小程序订阅消息接口
59 SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage( 69 SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage(
60 - openId, templateId, page, data); 70 + openId,
71 + templateId,
72 + page,
73 + data
74 + );
61 75
62 if (response.getErrcode() != null && response.getErrcode() == 0) { 76 if (response.getErrcode() != null && response.getErrcode() == 0) {
63 - log.info("Order reminder sent successfully to openId: {}", openId); 77 + log.info("Order reminder sent successfully via MiniProgram to openId: {}", openId);
64 } else { 78 } else {
65 log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}", 79 log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}",
66 openId, response.getErrcode(), response.getErrmsg()); 80 openId, response.getErrcode(), response.getErrmsg());
...@@ -68,7 +82,7 @@ public class OrderReminderServiceImpl implements OrderReminderService { ...@@ -68,7 +82,7 @@ public class OrderReminderServiceImpl implements OrderReminderService {
68 82
69 return response; 83 return response;
70 } catch (Exception e) { 84 } catch (Exception e) {
71 - log.error("Failed to send order reminder to openId: {}", openId, e); 85 + log.error("Failed to send order reminder via MiniProgram to openId: {}", openId, e);
72 throw e; 86 throw e;
73 } 87 }
74 } 88 }
......
...@@ -69,6 +69,7 @@ service MealOrderServiceRpc { ...@@ -69,6 +69,7 @@ service MealOrderServiceRpc {
69 rpc GetUserOrderSubscriptionMessageHistory (GetUserOrderSubscriptionMessageHistoryRpcRequest) returns (GetUserOrderSubscriptionMessageHistoryRpcResponse) {} 69 rpc GetUserOrderSubscriptionMessageHistory (GetUserOrderSubscriptionMessageHistoryRpcRequest) returns (GetUserOrderSubscriptionMessageHistoryRpcResponse) {}
70 rpc BatchDeleteOrderSubscriptionMessagesByIds (BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse) {} 70 rpc BatchDeleteOrderSubscriptionMessagesByIds (BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
71 rpc BatchPhysicalDeleteOrderSubscriptionMessagesByIds (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse) {} 71 rpc BatchPhysicalDeleteOrderSubscriptionMessagesByIds (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
72 + rpc QueryPendingOrderSubscriptionMessages (QueryPendingOrderSubscriptionMessagesRpcRequest) returns (QueryPendingOrderSubscriptionMessagesRpcResponse) {}
72 } 73 }
73 74
74 enum MealOrderOrderMethodEnum { 75 enum MealOrderOrderMethodEnum {
...@@ -1013,3 +1014,24 @@ message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest { ...@@ -1013,3 +1014,24 @@ message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest {
1013 message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse { 1014 message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse {
1014 int32 affectedRows = 1; // 受影响的行数 1015 int32 affectedRows = 1; // 受影响的行数
1015 } 1016 }
1017 +
1018 +// 查询待发送的订阅消息(未删除的记录)
1019 +message QueryPendingOrderSubscriptionMessagesRpcRequest {
1020 + int32 enterpriseId = 1;
1021 +}
1022 +
1023 +message PendingOrderSubscriptionMessageRpcResponse {
1024 + int64 id = 1;
1025 + int32 enterpriseId = 2;
1026 + int32 dinerId = 3;
1027 + string openId = 4;
1028 + string templateId = 5;
1029 + int64 orderPeriodStartDate = 6;
1030 + string jumpPath = 7;
1031 + int64 orderPeriodEndDate = 8;
1032 + int64 createdAt = 9;
1033 +}
1034 +
1035 +message QueryPendingOrderSubscriptionMessagesRpcResponse {
1036 + repeated PendingOrderSubscriptionMessageRpcResponse responses = 1;
1037 +}
......