Jiaqi Xia

feat: 微信点餐推送

1 ----
2 -alwaysApply: false
3 ----
4 -# 项目代码规范
5 -
6 -## 项目结构
7 -```
8 -src/main/java/com/infoloop/tianting/
9 -├── controller/ # REST API 控制器
10 -├── service/ # 业务逻辑接口
11 -│ ├── impl/ # 业务逻辑实现
12 -│ └── client/ # gRPC/HTTP 客户端封装
13 -├── model/ # 数据模型
14 -│ ├── dto/ # 数据传输对象
15 -│ ├── vo/ # 视图对象
16 -│ ├── bo/ # 业务对象
17 -│ └── common/ # 通用模型
18 -├── config/ # 配置类
19 -├── constant/ # 常量定义
20 -├── enums/ # 枚举类
21 -├── exception/ # 异常定义
22 -├── logic/ # 业务逻辑(定时任务、延迟任务等)
23 -├── utils/ # 工具类
24 -└── store/ # Redis 存储封装
25 -```
26 -
27 -## 代码风格
28 -
29 -### 类注解顺序
30 -```java
31 -@Slf4j
32 -@Service // 或 @Component, @RestController
33 -@RequiredArgsConstructor(onConstructor = @__(@Autowired))
34 -public class XxxServiceImpl implements XxxService {
35 -```
36 -
37 -### Controller 规范
38 -- 使用 `@Api(tags = "模块名")` 标注 Swagger 分组
39 -- 使用 `@ApiOperation(value = "接口描述")` 标注接口
40 -- 使用 `@ResponseStatus` 指定 HTTP 状态码
41 -- 使用 `@Valid` 进行参数校验
42 -
43 -### Service 规范
44 -- 接口定义在 `service/` 目录
45 -- 实现类在 `service/impl/` 目录,命名为 `XxxServiceImpl`
46 -- gRPC 客户端封装在 `service/client/` 目录,命名为 `XxxServiceRpcClient`
47 -
48 -### 配置常量
49 -- 配置项定义在 `ConfigConstants` 接口中
50 -- 使用 `@Value(ConfigConstants.XXX)` 注入配置
51 -
52 -### 依赖注入
53 -- 优先使用构造器注入:`@RequiredArgsConstructor(onConstructor = @__(@Autowired))`
54 -- 配置值使用 `@Value` 注入
55 -
56 -### 日志规范
57 -- 使用 `@Slf4j` 注解
58 -- 使用 `log.info/warn/error` 记录日志
59 -- 异常日志使用 `log.error("message", e)`
60 -
61 -### gRPC 调用
62 -- 通过 `XxxServiceRpcClient` 封装 gRPC 调用
63 -- 在 `GrpcConfig` 中配置 Channel 和 Stub
64 -
65 -### 定时任务
66 -- 放在 `logic/task/` 目录
67 -- 使用 `@Scheduled(cron = "...")` 注解
68 -- 使用 `@Component` 注册为 Bean
69 -
70 -## 命名规范
71 -- DTO 类:`XxxDTO` 或内部类 `XxxDTO.CreateXxxDTO`
72 -- VO 类:`XxxVO`
73 -- 枚举类:`XxxEnum`
74 -- 常量类:`XxxConstants`
75 -- 工具类:`XxxUtil`
76 -
77 -## 禁止事项
78 -
79 -### 禁止循环依赖
80 -- **Service 之间禁止循环调用**:ServiceA 调用 ServiceB,ServiceB 不能再调用 ServiceA
81 -- **避免循环依赖注入**:如果出现循环依赖,需要重构代码,提取公共逻辑到新的 Service
82 -- **分层调用原则**:Controller → Service → RpcClient/Store,禁止反向调用
83 -
84 -### 禁止循环调用 gRPC 方法
85 -- **禁止在循环中调用 gRPC 方法**:会导致大量网络请求,严重影响性能
86 -- **必须使用批量接口**:如果需要处理多条数据,必须使用批量查询/批量创建接口
87 -- **先收集 ID 再批量查询**:先收集所有需要查询的 ID,一次性批量查询
88 -
89 -```java
90 -// ❌ 错误示例:循环调用 gRPC
91 -for (Long id : ids) {
92 - var result = rpcClient.getById(id); // 禁止!
93 - results.add(result);
94 -}
95 -
96 -// ✅ 正确示例:批量调用
97 -var results = rpcClient.getByIds(ids); // 一次批量查询
98 -```
99 -
100 -### 批量接口设计规范
101 -- **Proto 定义批量方法**:`GetXxxsByIds`、`BatchCreateXxx`、`BatchUpdateXxx`
102 -- **RpcClient 封装批量方法**:提供 `getByIds(List<Long> ids)` 等批量方法
103 -- **空集合检查**:批量方法调用前检查集合是否为空,避免无效请求
104 -
105 -### 参数类型规范
106 -- **禁止使用 Object 作为参数类型**:必须明确定义具体类型
107 -- **禁止使用 Map<String, Object>**:应定义具体的 DTO 类
108 -- **集合类型必须指定泛型**:使用 `List<XxxDTO>` 而非 `List`
109 -- **方法参数类型要明确**:避免使用 `var` 定义方法参数
110 -- **返回值类型要明确**:禁止返回 `Object`,必须定义具体类型
111 -
112 -### 示例
113 -```java
114 -// ❌ 错误示例
115 -public Object process(Map<String, Object> params) { ... }
116 -
117 -// ✅ 正确示例
118 -public OrderVO process(CreateOrderDTO params) { ... }
119 -```
1 --- 1 ---
2 +description: Implementing Spring Boot controllers, services, gRPC clients, business logic, and backend APIs
2 alwaysApply: false 3 alwaysApply: false
3 --- 4 ---
4 -# 项目技术栈 5 +# Backend Implementation Skill (Spring Boot + gRPC)
5 6
6 -## 核心框架 7 +## When writing backend code
7 -- **Spring Boot 2.5.12**:Web 应用框架 8 +- Use Spring Boot 2.5.12 conventions
8 -- **Java 17**:编程语言 9 +- Write Java 17 compatible code
9 -- **Gradle**:构建工具 10 +- Follow layered architecture
10 11
11 -## 通信协议 12 +## Service Implementation
12 -- **gRPC**:微服务间通信,使用 Protobuf 定义接口 13 +- Service interfaces in service/
13 -- **REST API**:对外 HTTP 接口,使用 Knife4j/Swagger 文档 14 +- Implementations in service/impl/, named XxxServiceImpl
15 +- Use constructor injection
16 +- Use @Transactional for write operations
17 +- Log key business steps with @Slf4j
14 18
15 -## 数据存储 19 +## gRPC Integration
16 -- **Redis (Lettuce)**:缓存、分布式锁、Session 存储 20 +- Use XxxServiceRpcClient to wrap gRPC calls
17 -- **Redisson**:分布式锁实现 21 +- Prefer batch RPC methods
18 -- **阿里云 OSS**:文件存储 22 +- Validate empty collections before calling RPC
19 23
20 -## 认证授权 24 +## Common Libraries
21 -- **Sa-Token + JWT**:用户认证和权限管理 25 +- Lombok for boilerplate reduction
22 - 26 +- Hutool for date, collection, string utilities
23 -## 工具库
24 -- **Lombok**:简化 Java 代码
25 -- **Hutool**:通用工具类
26 -- **Jackson**:JSON/XML 序列化
27 -- **Apache POI**:Excel 处理
28 -
29 -## 可观测性
30 -- **Zipkin Brave**:分布式链路追踪
31 -- **Spring Actuator**:健康检查和监控
32 -
33 -## 异步处理
34 -- **Spring Scheduling**:定时任务
35 -- **Spring Async**:异步执行
36 -- **WebSocket**:实时通信
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -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 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: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
14 - * @param page 点击跳转页面路径,例如:pages/order/index?cycleId=202501 14 + * - thing8: 用餐类型(如:下次订单即将开始)
15 - * @param reminderContent 提醒内容,例如:"下次订餐即将开启" 15 + * - time1: 订餐时间(如:13:00~21:00)
16 - * @param orderTime 订餐时间,例如:"周一 09:00 至 周三 12:00" 16 + * - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
17 - * @param tips 温馨提示,例如:"请点击卡片,准时进入小程序完成预订" 17 + *
18 + * @param openId 用户 OpenID(小程序的 openId)
19 + * @param page 点击跳转小程序页面路径
20 + * @param mealType 用餐类型,例如:"下次订单即将开始"
21 + * @param orderTime 订餐时间,例如:"13:00~21: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 mealType, String orderTime, 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: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
22 + * - thing8: 用餐类型(如:下次订单即将开始)
23 + * - time1: 订餐时间(如:13:00~21:00)
24 + * - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
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 mealType,
35 String orderTime, 44 String orderTime,
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: {}, mealType: {}, orderTime: {}, tips: {}",
47 + openId, page, mealType, orderTime, 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 + // thing8: 用餐类型(如:下次订单即将开始)
44 - Map<String, String> thing1Value = new HashMap<>(); 53 + Map<String, String> thing8Value = new HashMap<>();
45 - thing1Value.put("value", reminderContent); 54 + thing8Value.put("value", mealType);
46 - data.put("thing1", thing1Value); 55 + data.put("thing8", thing8Value);
47 56
48 - // time2: 订餐时间 57 + // time1: 订餐时间(如:13:00~21:00)
49 - Map<String, String> time2Value = new HashMap<>(); 58 + Map<String, String> time1Value = new HashMap<>();
50 - time2Value.put("value", orderTime); 59 + time1Value.put("value", orderTime);
51 - data.put("time2", time2Value); 60 + data.put("time1", time1Value);
52 61
53 - // thing3: 温馨提示 62 + // thing2: 温馨提示
54 - Map<String, String> thing3Value = new HashMap<>(); 63 + Map<String, String> thing2Value = new HashMap<>();
55 - thing3Value.put("value", tips); 64 + thing2Value.put("value", tips);
56 - data.put("thing3", thing3Value); 65 + data.put("thing2", thing2Value);
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 +}
......