Jiaqi Xia

feat: 微信点餐推送

......@@ -83,4 +83,9 @@ public interface ConfigConstants {
String MINI_PROGRAM_APP_SECRET = "${miniprogram.appSecret}";
String MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID = "${miniprogram.orderReminderTemplateId}";
// 公众号配置(用于订阅消息推送)
String OFFICIAL_ACCOUNT_APP_ID = "${officialAccount.appId}";
String OFFICIAL_ACCOUNT_APP_SECRET = "${officialAccount.appSecret}";
String OFFICIAL_ACCOUNT_ORDER_REMINDER_TEMPLATE_ID = "${officialAccount.orderReminderTemplateId}";
}
......
package com.infoloop.tianting.logic.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* 订餐提醒任务命令行执行器
* 通过命令行参数 --trigger-order-reminder 触发执行
*
* 使用方式:
* java -jar app.jar --trigger-order-reminder
* 或者
* java -jar app.jar --spring.main.web-application-type=none --trigger-order-reminder
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderReminderTaskRunner implements CommandLineRunner {
private final OrderReminderTask orderReminderTask;
@Override
public void run(String... args) {
boolean shouldTrigger = Arrays.asList(args).contains("--trigger-order-reminder");
if (shouldTrigger) {
log.info("OrderReminderTaskRunner: Triggering order reminder task via command line");
orderReminderTask.executeTask();
log.info("OrderReminderTaskRunner: Task execution completed");
}
}
}
......@@ -4,19 +4,24 @@ import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse
/**
* 订餐提醒服务接口
* 用于发送订餐周期开启前的提醒消息
* 用于发送订餐周期开启前的提醒消息(小程序订阅消息)
*/
public interface OrderReminderService {
/**
* 发送订餐提醒消息
*
* @param openId 用户 OpenID
* @param page 点击跳转页面路径,例如:pages/order/index?cycleId=202501
* @param reminderContent 提醒内容,例如:"下次订餐即将开启"
* @param orderTime 订餐时间,例如:"周一 09:00 至 周三 12:00"
* @param tips 温馨提示,例如:"请点击卡片,准时进入小程序完成预订"
* 小程序模板字段(模板ID: LtCsW7ciF0edUwNwt3h5C_ZCIRzOeO7MnvozRl2CxlE):
* - time4: 开始时间
* - time5: 结束时间
* - thing1: 温馨提醒
*
* @param openId 用户 OpenID(小程序的 openId)
* @param page 点击跳转小程序页面路径
* @param startTime 开始时间,例如:"周一 09:00"
* @param endTime 结束时间,例如:"周四 18:00"
* @param tips 温馨提醒,例如:"请点击卡片,准时进入小程序完成预订"
* @return 发送结果
*/
SendSubscriptionMessageResponse sendOrderReminder(String openId, String page,
String reminderContent, String orderTime, String tips);
String startTime, String endTime, String tips);
}
......
......@@ -12,8 +12,13 @@ import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcRequest;
import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcResponse;
import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcRequest;
import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcResponse;
import com.infoloop.tianting.mealorderservice.BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.CreateOrderSubscriptionMessageRpcRequest;
import com.infoloop.tianting.mealorderservice.DeleteDinersByIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.GetUserOrderSubscriptionMessageHistoryRpcRequest;
import com.infoloop.tianting.mealorderservice.OrderSubscriptionMessageRpcResponse;
import com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse;
import com.infoloop.tianting.mealorderservice.QueryPendingOrderSubscriptionMessagesRpcRequest;
import com.infoloop.tianting.mealorderservice.DeleteMpAccountDinerRefsByIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.DinerCreation;
import com.infoloop.tianting.mealorderservice.DinerModification;
......@@ -37,6 +42,7 @@ import com.infoloop.tianting.mealorderservice.QueryGradeClassesByConditionRpcReq
import com.infoloop.tianting.mealorderservice.QueryLatestMealMenuRecordsByClientIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryMealOrdersByConditionRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryMpAccountDinerRefsByConditionRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryMpAccountsByConditionRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryReserveMealOrdersByDinerIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.SingleDinerRpcResponse;
import com.infoloop.tianting.mealorderservice.SingleGradeClassRpcResponse;
......@@ -105,6 +111,19 @@ public class MealOrderServiceRpcClient {
.build());
}
/**
* 查询所有活跃的小程序用户
*/
public List<SingleMpAccountRpcResponse> queryAllActiveMpAccounts(int enterpriseId) {
return mealOrderServiceRpcBlockingStub.queryMpAccountsByCondition(
QueryMpAccountsByConditionRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.setShouldFilterStatus(false)
.setIncludeDeleted(false)
.build()
).getResponsesList();
}
public List<SingleMpAccountDinerRefRpcResponse> queryMpAccountDinerRefsByCondition(int enterpriseId, List<Integer> mpAccountIds, List<Integer> dinerIds) {
return mealOrderServiceRpcBlockingStub.queryMpAccountDinerRefsByCondition(QueryMpAccountDinerRefsByConditionRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
......@@ -328,4 +347,48 @@ public class MealOrderServiceRpcClient {
}
return mealOrderServiceRpcBlockingStub.createOrderSubscriptionMessage(req.build()).getId();
}
/**
* 查询用户订阅消息历史(未删除的记录)
*/
public List<OrderSubscriptionMessageRpcResponse> getUserOrderSubscriptionMessageHistory(
int enterpriseId,
String openId,
@Nullable Long orderPeriodStartDate
) {
final var reqBuilder = GetUserOrderSubscriptionMessageHistoryRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.setOpenId(openId);
if (orderPeriodStartDate != null) {
reqBuilder.setOrderPeriodStartDate(orderPeriodStartDate);
}
return mealOrderServiceRpcBlockingStub.getUserOrderSubscriptionMessageHistory(reqBuilder.build())
.getResponseList();
}
/**
* 批量删除订阅消息(逻辑删除,发送成功后调用)
*/
public int batchDeleteOrderSubscriptionMessagesByIds(int enterpriseId, List<Long> ids) {
if (CollectionUtils.isEmpty(ids)) {
return 0;
}
return mealOrderServiceRpcBlockingStub.batchDeleteOrderSubscriptionMessagesByIds(
BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.addAllIds(ids)
.build()
).getAffectedRows();
}
/**
* 查询待发送的订阅消息(未删除的记录)
*/
public List<PendingOrderSubscriptionMessageRpcResponse> queryPendingOrderSubscriptionMessages(int enterpriseId) {
return mealOrderServiceRpcBlockingStub.queryPendingOrderSubscriptionMessages(
QueryPendingOrderSubscriptionMessagesRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.build()
).getResponsesList();
}
}
......
package com.infoloop.tianting.service.client;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto;
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.beans.factory.annotation.Value;
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 java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.infoloop.tianting.constant.ConfigConstants.OFFICIAL_ACCOUNT_APP_ID;
import static com.infoloop.tianting.constant.ConfigConstants.OFFICIAL_ACCOUNT_APP_SECRET;
/**
* 微信公众号 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;
@Value(OFFICIAL_ACCOUNT_APP_ID)
private String appId;
@Value(OFFICIAL_ACCOUNT_APP_SECRET)
private String appSecret;
private final Object lock = new Object();
/**
* 获取公众号 access_token(带缓存)
*/
public String fetchStableAccessToken() {
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) {
try {
String accessToken = fetchStableAccessToken();
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);
}
}
......@@ -16,7 +16,16 @@ import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_
/**
* 订餐提醒服务实现
* 将业务参数转换为微信订阅消息模板格式并发送
* 使用小程序订阅消息接口发送提醒
*
* 小程序模板字段(模板ID: LtCsW7ciF0edUwNwt3h5C_ZCIRzOeO7MnvozRl2CxlE):
* - time4: 开始时间
* - time5: 结束时间
* - thing1: 温馨提醒
*
* 注意:
* 1. 使用的是小程序的 access_token 和 template_id
* 2. openId 是用户在小程序下的 openId
*/
@Slf4j
@Service
......@@ -31,36 +40,41 @@ public class OrderReminderServiceImpl implements OrderReminderService {
@Override
public SendSubscriptionMessageResponse sendOrderReminder(String openId,
String page,
String reminderContent,
String orderTime,
String startTime,
String endTime,
String tips) {
log.info("Sending order reminder to openId: {}, page: {}", openId, page);
log.info("Sending order reminder via MiniProgram to openId: {}, page: {}, startTime: {}, endTime: {}, tips: {}",
openId, page, startTime, endTime, tips);
// 将业务参数转换为微信模板数据格式
// 根据文档,模板字段为:thing1 (提醒内容), time2 (订餐时间), thing3 (温馨提示)
// 构建小程序订阅消息模板数据
Map<String, Object> data = new HashMap<>();
// thing1: 提醒内容
Map<String, String> thing1Value = new HashMap<>();
thing1Value.put("value", reminderContent);
data.put("thing1", thing1Value);
// time4: 开始时间
Map<String, String> time4Value = new HashMap<>();
time4Value.put("value", startTime);
data.put("time4", time4Value);
// time2: 订餐时间
Map<String, String> time2Value = new HashMap<>();
time2Value.put("value", orderTime);
data.put("time2", time2Value);
// time5: 结束时间
Map<String, String> time5Value = new HashMap<>();
time5Value.put("value", endTime);
data.put("time5", time5Value);
// thing3: 温馨提示
Map<String, String> thing3Value = new HashMap<>();
thing3Value.put("value", tips);
data.put("thing3", thing3Value);
// thing1: 温馨提醒
Map<String, String> thing1Value = new HashMap<>();
thing1Value.put("value", tips);
data.put("thing1", thing1Value);
try {
// 使用小程序订阅消息接口
SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage(
openId, templateId, page, data);
openId,
templateId,
page,
data
);
if (response.getErrcode() != null && response.getErrcode() == 0) {
log.info("Order reminder sent successfully to openId: {}", openId);
log.info("Order reminder sent successfully via MiniProgram to openId: {}", openId);
} else {
log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}",
openId, response.getErrcode(), response.getErrmsg());
......@@ -68,7 +82,7 @@ public class OrderReminderServiceImpl implements OrderReminderService {
return response;
} catch (Exception e) {
log.error("Failed to send order reminder to openId: {}", openId, e);
log.error("Failed to send order reminder via MiniProgram to openId: {}", openId, e);
throw e;
}
}
......
......@@ -69,6 +69,7 @@ service MealOrderServiceRpc {
rpc GetUserOrderSubscriptionMessageHistory (GetUserOrderSubscriptionMessageHistoryRpcRequest) returns (GetUserOrderSubscriptionMessageHistoryRpcResponse) {}
rpc BatchDeleteOrderSubscriptionMessagesByIds (BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
rpc BatchPhysicalDeleteOrderSubscriptionMessagesByIds (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
rpc QueryPendingOrderSubscriptionMessages (QueryPendingOrderSubscriptionMessagesRpcRequest) returns (QueryPendingOrderSubscriptionMessagesRpcResponse) {}
}
enum MealOrderOrderMethodEnum {
......@@ -1013,3 +1014,24 @@ message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest {
message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse {
int32 affectedRows = 1; // 受影响的行数
}
// 查询待发送的订阅消息(未删除的记录)
message QueryPendingOrderSubscriptionMessagesRpcRequest {
int32 enterpriseId = 1;
}
message PendingOrderSubscriptionMessageRpcResponse {
int64 id = 1;
int32 enterpriseId = 2;
int32 dinerId = 3;
string openId = 4;
string templateId = 5;
int64 orderPeriodStartDate = 6;
string jumpPath = 7;
int64 orderPeriodEndDate = 8;
int64 createdAt = 9;
}
message QueryPendingOrderSubscriptionMessagesRpcResponse {
repeated PendingOrderSubscriptionMessageRpcResponse responses = 1;
}
......