Jiaqi Xia

feat: 微信点餐推送

---
alwaysApply: false
---
# 项目代码规范
## 项目结构
```
src/main/java/com/infoloop/tianting/
├── controller/ # REST API 控制器
├── service/ # 业务逻辑接口
│ ├── impl/ # 业务逻辑实现
│ └── client/ # gRPC/HTTP 客户端封装
├── model/ # 数据模型
│ ├── dto/ # 数据传输对象
│ ├── vo/ # 视图对象
│ ├── bo/ # 业务对象
│ └── common/ # 通用模型
├── config/ # 配置类
├── constant/ # 常量定义
├── enums/ # 枚举类
├── exception/ # 异常定义
├── logic/ # 业务逻辑(定时任务、延迟任务等)
├── utils/ # 工具类
└── store/ # Redis 存储封装
```
## 代码风格
### 类注解顺序
```java
@Slf4j
@Service // 或 @Component, @RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class XxxServiceImpl implements XxxService {
```
### Controller 规范
- 使用 `@Api(tags = "模块名")` 标注 Swagger 分组
- 使用 `@ApiOperation(value = "接口描述")` 标注接口
- 使用 `@ResponseStatus` 指定 HTTP 状态码
- 使用 `@Valid` 进行参数校验
### Service 规范
- 接口定义在 `service/` 目录
- 实现类在 `service/impl/` 目录,命名为 `XxxServiceImpl`
- gRPC 客户端封装在 `service/client/` 目录,命名为 `XxxServiceRpcClient`
### 配置常量
- 配置项定义在 `ConfigConstants` 接口中
- 使用 `@Value(ConfigConstants.XXX)` 注入配置
### 依赖注入
- 优先使用构造器注入:`@RequiredArgsConstructor(onConstructor = @__(@Autowired))`
- 配置值使用 `@Value` 注入
### 日志规范
- 使用 `@Slf4j` 注解
- 使用 `log.info/warn/error` 记录日志
- 异常日志使用 `log.error("message", e)`
### gRPC 调用
- 通过 `XxxServiceRpcClient` 封装 gRPC 调用
- 在 `GrpcConfig` 中配置 Channel 和 Stub
### 定时任务
- 放在 `logic/task/` 目录
- 使用 `@Scheduled(cron = "...")` 注解
- 使用 `@Component` 注册为 Bean
## 命名规范
- DTO 类:`XxxDTO` 或内部类 `XxxDTO.CreateXxxDTO`
- VO 类:`XxxVO`
- 枚举类:`XxxEnum`
- 常量类:`XxxConstants`
- 工具类:`XxxUtil`
## 禁止事项
### 禁止循环依赖
- **Service 之间禁止循环调用**:ServiceA 调用 ServiceB,ServiceB 不能再调用 ServiceA
- **避免循环依赖注入**:如果出现循环依赖,需要重构代码,提取公共逻辑到新的 Service
- **分层调用原则**:Controller → Service → RpcClient/Store,禁止反向调用
### 禁止循环调用 gRPC 方法
- **禁止在循环中调用 gRPC 方法**:会导致大量网络请求,严重影响性能
- **必须使用批量接口**:如果需要处理多条数据,必须使用批量查询/批量创建接口
- **先收集 ID 再批量查询**:先收集所有需要查询的 ID,一次性批量查询
```java
// ❌ 错误示例:循环调用 gRPC
for (Long id : ids) {
var result = rpcClient.getById(id); // 禁止!
results.add(result);
}
// ✅ 正确示例:批量调用
var results = rpcClient.getByIds(ids); // 一次批量查询
```
### 批量接口设计规范
- **Proto 定义批量方法**:`GetXxxsByIds`、`BatchCreateXxx`、`BatchUpdateXxx`
- **RpcClient 封装批量方法**:提供 `getByIds(List<Long> ids)` 等批量方法
- **空集合检查**:批量方法调用前检查集合是否为空,避免无效请求
### 参数类型规范
- **禁止使用 Object 作为参数类型**:必须明确定义具体类型
- **禁止使用 Map<String, Object>**:应定义具体的 DTO 类
- **集合类型必须指定泛型**:使用 `List<XxxDTO>` 而非 `List`
- **方法参数类型要明确**:避免使用 `var` 定义方法参数
- **返回值类型要明确**:禁止返回 `Object`,必须定义具体类型
### 示例
```java
// ❌ 错误示例
public Object process(Map<String, Object> params) { ... }
// ✅ 正确示例
public OrderVO process(CreateOrderDTO params) { ... }
```
---
description: Implementing Spring Boot controllers, services, gRPC clients, business logic, and backend APIs
alwaysApply: false
---
# 项目技术栈
# Backend Implementation Skill (Spring Boot + gRPC)
## 核心框架
- **Spring Boot 2.5.12**:Web 应用框架
- **Java 17**:编程语言
- **Gradle**:构建工具
## When writing backend code
- Use Spring Boot 2.5.12 conventions
- Write Java 17 compatible code
- Follow layered architecture
## 通信协议
- **gRPC**:微服务间通信,使用 Protobuf 定义接口
- **REST API**:对外 HTTP 接口,使用 Knife4j/Swagger 文档
## Service Implementation
- Service interfaces in service/
- Implementations in service/impl/, named XxxServiceImpl
- Use constructor injection
- Use @Transactional for write operations
- Log key business steps with @Slf4j
## 数据存储
- **Redis (Lettuce)**:缓存、分布式锁、Session 存储
- **Redisson**:分布式锁实现
- **阿里云 OSS**:文件存储
## gRPC Integration
- Use XxxServiceRpcClient to wrap gRPC calls
- Prefer batch RPC methods
- Validate empty collections before calling RPC
## 认证授权
- **Sa-Token + JWT**:用户认证和权限管理
## 工具库
- **Lombok**:简化 Java 代码
- **Hutool**:通用工具类
- **Jackson**:JSON/XML 序列化
- **Apache POI**:Excel 处理
## 可观测性
- **Zipkin Brave**:分布式链路追踪
- **Spring Actuator**:健康检查和监控
## 异步处理
- **Spring Scheduling**:定时任务
- **Spring Async**:异步执行
- **WebSocket**:实时通信
## Common Libraries
- Lombok for boilerplate reduction
- Hutool for date, collection, string utilities
\ No newline at end of file
......
......@@ -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 com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse;
import com.infoloop.tianting.mealorderservice.SingleMpAccountRpcResponse;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.service.OrderReminderService;
import com.infoloop.tianting.service.client.DeliveryRuleServiceRpcClient;
import com.infoloop.tianting.service.client.MealOrderServiceRpcClient;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import static com.infoloop.tianting.constant.ConfigConstants.ASYNC_EXECUTOR;
/**
* 订餐提醒定时任务
* 在订餐周期开启前(周一 9:00)向已授权的家长发送微信服务通知
*
* 推送逻辑:
* 1. 查询所有待发送的订阅消息(未删除的记录)
* 2. 按 openId 去重,每个用户只推送一次
* 3. 通过 openId 查询活跃的小程序用户
* 4. 根据配送规则配置生成订餐时间提示
* 5. 发送消息,发送成功后标记为已删除
*/
@Slf4j
@Component
public class OrderReminderTask {
private static final int ENTERPRISE_ID = 449;
/**
* 用餐类型
*/
private static final String MEAL_TYPE = "下次订餐即将开启";
/**
* 温馨提醒内容
*/
private static final String TIPS = "请点击卡片,准时进入小程序完成预订";
/**
* 订餐时间配置(格式:HH:mm~HH:mm)
*/
private record OrderTimeConfig(String orderTime) {}
private final MealOrderServiceRpcClient mealOrderServiceRpcClient;
private final DeliveryRuleServiceRpcClient deliveryRuleServiceRpcClient;
private final OrderReminderService orderReminderService;
private final Executor asyncExecutor;
public OrderReminderTask(
MealOrderServiceRpcClient mealOrderServiceRpcClient,
DeliveryRuleServiceRpcClient deliveryRuleServiceRpcClient,
OrderReminderService orderReminderService,
@Qualifier(ASYNC_EXECUTOR) Executor asyncExecutor) {
this.mealOrderServiceRpcClient = mealOrderServiceRpcClient;
this.deliveryRuleServiceRpcClient = deliveryRuleServiceRpcClient;
this.orderReminderService = orderReminderService;
this.asyncExecutor = asyncExecutor;
}
/**
* 每周一 9:00 执行订餐提醒推送
*/
@Scheduled(cron = "${task.orderReminder.cron:0 0 9 * * MON}")
public void executeTask() {
log.info("OrderReminderTask started");
try {
// 1. 获取配送规则配置,用于生成订餐时间提示
OrderTimeConfig orderTimeConfig = getOrderTimeConfigFromRule();
if (orderTimeConfig == null) {
log.warn("Failed to get order time config from rule, using default");
orderTimeConfig = new OrderTimeConfig("请查看小程序");
}
log.info("Order time config: orderTime={}", orderTimeConfig.orderTime());
// 2. 查询所有待发送的订阅消息(未删除的记录)
List<PendingOrderSubscriptionMessageRpcResponse> pendingMessages =
mealOrderServiceRpcClient.queryPendingOrderSubscriptionMessages(ENTERPRISE_ID);
if (pendingMessages.isEmpty()) {
log.info("No pending order subscription messages to send");
return;
}
log.info("Found {} pending order subscription messages", pendingMessages.size());
// 3. 提取所有 openId,查询活跃的小程序用户
Set<String> openIds = pendingMessages.stream()
.map(PendingOrderSubscriptionMessageRpcResponse::getOpenId)
.collect(Collectors.toSet());
Map<String, SingleMpAccountRpcResponse> activeMpAccountMap = getActiveMpAccountMap(openIds);
log.info("Found {} active mp accounts from {} openIds", activeMpAccountMap.size(), openIds.size());
int totalMessages = pendingMessages.size();
AtomicInteger skippedInactive = new AtomicInteger(0);
AtomicInteger skippedDuplicate = new AtomicInteger(0);
AtomicInteger sentCount = new AtomicInteger(0);
List<Long> sentMessageIds = Collections.synchronizedList(new ArrayList<>());
Set<String> sentOpenIds = ConcurrentHashMap.newKeySet(); // 线程安全的 Set,用于 openId 去重
// 4. 按 openId 去重,筛选出需要发送的消息
List<PendingOrderSubscriptionMessageRpcResponse> messagesToSend = new ArrayList<>();
for (PendingOrderSubscriptionMessageRpcResponse message : pendingMessages) {
String openId = message.getOpenId();
// 检查用户是否活跃
if (!activeMpAccountMap.containsKey(openId)) {
log.debug("Skipping message {} - user {} is not active", message.getId(), openId);
skippedInactive.incrementAndGet();
sentMessageIds.add(message.getId());
continue;
}
// openId 去重:每个用户只推送一次
if (!sentOpenIds.add(openId)) {
log.debug("Skipping message {} - already queued for openId: {}", message.getId(), openId);
skippedDuplicate.incrementAndGet();
sentMessageIds.add(message.getId());
continue;
}
messagesToSend.add(message);
}
log.info("Messages to send: {}, Skipped inactive: {}, Skipped duplicate: {}",
messagesToSend.size(), skippedInactive.get(), skippedDuplicate.get());
// 5. 多线程并发发送消息
final OrderTimeConfig finalOrderTimeConfig = orderTimeConfig;
List<CompletableFuture<Void>> futures = messagesToSend.stream()
.map(message -> CompletableFuture.runAsync(() -> {
try {
boolean success = sendReminderMessage(message.getOpenId(), message, finalOrderTimeConfig);
if (success) {
sentMessageIds.add(message.getId());
sentCount.incrementAndGet();
}
} catch (Exception e) {
log.error("Failed to send reminder to openId: {}, messageId: {}",
message.getOpenId(), message.getId(), e);
}
}, asyncExecutor))
.toList();
// 等待所有任务完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 6. 批量标记已发送的消息(逻辑删除)
if (!sentMessageIds.isEmpty()) {
int affectedRows = mealOrderServiceRpcClient.batchDeleteOrderSubscriptionMessagesByIds(
ENTERPRISE_ID, sentMessageIds);
log.info("Marked {} messages as sent", affectedRows);
}
log.info("OrderReminderTask completed. Total: {}, Sent: {}, SkippedInactive: {}, SkippedDuplicate: {}",
totalMessages, sentCount.get(), skippedInactive.get(), skippedDuplicate.get());
} catch (Exception e) {
log.error("OrderReminderTask failed", e);
}
}
/**
* 从配送规则配置中获取订餐时间配置
* 返回订餐时间,格式:yyyy年MM月dd日 HH:mm~HH:mm
*/
private OrderTimeConfig getOrderTimeConfigFromRule() {
try {
final var response = deliveryRuleServiceRpcClient.getDefaultDeliveryTemplateAndRule(ENTERPRISE_ID);
final var rule = response.getRule();
if (rule == null || rule.getConfigJson().getRulesList().isEmpty()) {
log.warn("Delivery rule not configured");
return null;
}
final var cfg = rule.getConfigJson().getRules(0);
final String startTimeStr = cfg.getStartTime();
final String endTimeStr = cfg.getEndTime();
if (startTimeStr == null || endTimeStr == null) {
log.warn("Delivery rule time config incomplete");
return null;
}
// 格式化时间(去掉秒)
String startTime = formatTime(startTimeStr);
String endTime = formatTime(endTimeStr);
// 获取当前日期,格式化为 yyyy年MM月dd日 HH:mm
java.time.LocalDate today = java.time.LocalDate.now();
java.time.format.DateTimeFormatter dateFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日");
String dateStr = today.format(dateFormatter);
// 返回订餐时间,格式:yyyy年MM月dd日 HH:mm~yyyy年MM月dd日 HH:mm
String orderTime = dateStr + " " + startTime + "~" + dateStr + " " + endTime;
return new OrderTimeConfig(orderTime);
} catch (Exception e) {
log.error("Failed to get order time config from rule", e);
return null;
}
}
/**
* 格式化时间字符串(HH:mm:ss -> HH:mm)
*/
private String formatTime(String timeStr) {
if (timeStr == null || timeStr.length() < 5) {
return timeStr;
}
// 取前5位 HH:mm
return timeStr.substring(0, 5);
}
/**
* 获取活跃的小程序用户 Map(openId -> MpAccount)
*/
private Map<String, SingleMpAccountRpcResponse> getActiveMpAccountMap(Set<String> openIds) {
if (CollectionUtils.isEmpty(openIds)) {
return Collections.emptyMap();
}
List<SingleMpAccountRpcResponse> allAccounts = mealOrderServiceRpcClient.queryAllActiveMpAccounts(ENTERPRISE_ID);
return allAccounts.stream()
.filter(account -> openIds.contains(account.getOpenId()))
.collect(Collectors.toMap(SingleMpAccountRpcResponse::getOpenId, account -> account, (a, b) -> a));
}
/**
* 发送单条提醒消息
*
* 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示
*/
private boolean sendReminderMessage(String openId, PendingOrderSubscriptionMessageRpcResponse message, OrderTimeConfig orderTimeConfig) {
String jumpPath = message.getJumpPath();
// 发送消息
SendSubscriptionMessageResponse response = orderReminderService.sendOrderReminder(
openId,
jumpPath,
MEAL_TYPE, // thing8: 用餐类型
orderTimeConfig.orderTime(), // time1: 订餐时间
TIPS // thing2: 温馨提示
);
// 判断发送结果
if (response.getErrcode() != null && response.getErrcode() == 0) {
return true;
}
// 记录失败原因
log.warn("Failed to send reminder. openId: {}, errcode: {}, errmsg: {}",
openId, response.getErrcode(), response.getErrmsg());
return false;
}
}
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: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
*
* @param openId 用户 OpenID(小程序的 openId)
* @param page 点击跳转小程序页面路径
* @param mealType 用餐类型,例如:"下次订单即将开始"
* @param orderTime 订餐时间,例如:"13:00~21:00"
* @param tips 温馨提示,例如:"为了保证您明日正常用餐,立即订餐!"
* @return 发送结果
*/
SendSubscriptionMessageResponse sendOrderReminder(String openId, String page,
String reminderContent, String orderTime, String tips);
String mealType, String orderTime, 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: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
*
* 注意:
* 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 mealType,
String orderTime,
String tips) {
log.info("Sending order reminder to openId: {}, page: {}", openId, page);
log.info("Sending order reminder via MiniProgram to openId: {}, page: {}, mealType: {}, orderTime: {}, tips: {}",
openId, page, mealType, orderTime, 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);
// thing8: 用餐类型(如:下次订单即将开始)
Map<String, String> thing8Value = new HashMap<>();
thing8Value.put("value", mealType);
data.put("thing8", thing8Value);
// time2: 订餐时间
Map<String, String> time2Value = new HashMap<>();
time2Value.put("value", orderTime);
data.put("time2", time2Value);
// time1: 订餐时间(如:13:00~21:00)
Map<String, String> time1Value = new HashMap<>();
time1Value.put("value", orderTime);
data.put("time1", time1Value);
// thing3: 温馨提示
Map<String, String> thing3Value = new HashMap<>();
thing3Value.put("value", tips);
data.put("thing3", thing3Value);
// thing2: 温馨提示
Map<String, String> thing2Value = new HashMap<>();
thing2Value.put("value", tips);
data.put("thing2", thing2Value);
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;
}
......