Jiaqi Xia

Merge branch 'feat/nongxin-notifications' into 'master'

Feat/nongxin notifications



See merge request !15
1 +---
2 +description: Implementing Spring Boot controllers, services, gRPC clients, business logic, and backend APIs
3 +alwaysApply: false
4 +---
5 +# Backend Implementation Skill (Spring Boot + gRPC)
6 +
7 +## When writing backend code
8 +- Use Spring Boot 2.5.12 conventions
9 +- Write Java 17 compatible code
10 +- Follow layered architecture
11 +
12 +## Service Implementation
13 +- Service interfaces in service/
14 +- Implementations in service/impl/, named XxxServiceImpl
15 +- Use constructor injection
16 +- Use @Transactional for write operations
17 +- Log key business steps with @Slf4j
18 +
19 +## gRPC Integration
20 +- Use XxxServiceRpcClient to wrap gRPC calls
21 +- Prefer batch RPC methods
22 +- Validate empty collections before calling RPC
23 +
24 +## Common Libraries
25 +- Lombok for boilerplate reduction
26 +- Hutool for date, collection, string utilities
...\ No newline at end of file ...\ No newline at end of file
1 +# 缓存功能使用指南
2 +
3 +## 概述
4 +
5 +本项目已集成了完整的多级缓存功能,基于client-api项目的缓存实现。缓存层采用 Caffeine(本地缓存)+ Redis(分布式缓存)的多级架构,通过AOP实现非侵入式的缓存管理。
6 +
7 +## 特性
8 +
9 +- **多级缓存**:本地缓存(L1)+ 分布式缓存(L2)
10 +- **非侵入式**:通过注解实现,无需修改现有业务代码
11 +- **高性能**:优化的缓存穿透防护和性能优化
12 +- **易管理**:提供缓存监控和管理接口
13 +- **支持Lombok @Data**:专门优化支持Lombok @Data类的序列化
14 +
15 +## 缓存注解
16 +
17 +### @Cacheable - 缓存查询结果
18 +
19 +```java
20 +@Cacheable(prefix = "order", key = "#orderId", ttl = 600)
21 +public OrderDTO getOrderById(Long orderId) {
22 + // 业务逻辑
23 +}
24 +```
25 +
26 +**参数说明:**
27 +- `prefix`:缓存key前缀,用于区分业务模块
28 +- `key`:缓存key,支持SpEL表达式
29 +- `ttl`:过期时间(秒),默认300秒
30 +- `condition`:缓存条件,只有满足条件才缓存
31 +- `unless`:排除条件,满足条件时不缓存
32 +- `localCache`:是否启用本地缓存,默认true
33 +- `distributedCache`:是否启用分布式缓存,默认true
34 +
35 +### @CacheEvict - 清除缓存
36 +
37 +```java
38 +@CacheEvict(prefix = "order", keys = {"#order.orderId"})
39 +public OrderDTO updateOrder(OrderDTO order) {
40 + // 业务逻辑
41 +}
42 +```
43 +
44 +**参数说明:**
45 +- `prefix`:缓存key前缀
46 +- `keys`:要清除的缓存key数组,支持SpEL表达式
47 +- `allEntries`:是否清除所有相关缓存
48 +- `condition`:清除条件
49 +
50 +### @CachePut - 主动更新缓存
51 +
52 +```java
53 +@CachePut(prefix = "order", key = "#result.orderId", ttl = 600)
54 +public OrderDTO createOrder(OrderDTO order) {
55 + // 业务逻辑
56 +}
57 +```
58 +
59 +## 使用示例
60 +
61 +### 1. 基本缓存使用
62 +
63 +```java
64 +@Service
65 +public class OrderService {
66 +
67 + // 缓存订单信息,10分钟过期
68 + @Cacheable(prefix = "order", key = "#orderId", ttl = 600)
69 + public OrderDTO getOrderById(Long orderId) {
70 + // 调用gRPC服务获取订单信息
71 + return callGrpcService(orderId);
72 + }
73 +}
74 +```
75 +
76 +### 2. 条件缓存
77 +
78 +```java
79 +// 只有当orderId大于0时才缓存,且结果不为空时才缓存
80 +@Cacheable(
81 + prefix = "order",
82 + key = "#orderId",
83 + ttl = 600,
84 + condition = "#orderId > 0",
85 + unless = "#result == null"
86 +)
87 +public OrderDTO getOrderByIdWithCondition(Long orderId) {
88 + return getOrderById(orderId);
89 +}
90 +```
91 +
92 +### 3. 复杂key表达式
93 +
94 +```java
95 +// 使用多个参数组合作为缓存key
96 +@Cacheable(
97 + prefix = "order",
98 + key = "'customer_' + #customerId + '_status_' + #status + '_page_' + #page",
99 + ttl = 300
100 +)
101 +public List<OrderDTO> getOrdersByCustomer(Long customerId, String status, int page, int size) {
102 + return callGrpcService(customerId, status, page, size);
103 +}
104 +```
105 +
106 +### 4. 缓存失效
107 +
108 +```java
109 +// 更新订单时清除相关缓存
110 +@CacheEvict(prefix = "order", keys = {
111 + "#order.orderId",
112 + "'customer_' + #order.customerId + '_*'"
113 +})
114 +public OrderDTO updateOrder(OrderDTO order) {
115 + return callGrpcUpdateService(order);
116 +}
117 +
118 +// 清除所有订单相关缓存
119 +@CacheEvict(prefix = "order", allEntries = true)
120 +public void batchUpdateOrders(List<OrderDTO> orders) {
121 + callGrpcBatchUpdateService(orders);
122 +}
123 +```
124 +
125 +### 5. 主动缓存更新
126 +
127 +```java
128 +// 创建订单后主动设置缓存
129 +@CachePut(prefix = "order", key = "#result.orderId", ttl = 600)
130 +public OrderDTO createOrder(OrderDTO order) {
131 + return callGrpcCreateService(order);
132 +}
133 +```
134 +
135 +## 配置说明
136 +
137 +### application.properties配置
138 +
139 +```properties
140 +# 缓存配置
141 +cache.enabled=true
142 +cache.default-ttl=300
143 +
144 +# Caffeine本地缓存配置
145 +cache.caffeine.initial-capacity=100
146 +cache.caffeine.maximum-size=10000
147 +cache.caffeine.expire-after-write=60
148 +cache.caffeine.expire-after-access=0
149 +cache.caffeine.record-stats=true
150 +
151 +# Redis缓存配置
152 +cache.redis.enabled=true
153 +cache.redis.host=localhost
154 +cache.redis.port=6379
155 +cache.redis.password=123456
156 +cache.redis.database=11
157 +cache.redis.timeout=2000
158 +
159 +# 缓存监控配置
160 +cache.metrics.enabled=true
161 +cache.metrics.step=1m
162 +```
163 +
164 +### 缓存时间建议
165 +
166 +- **用户基础信息**:3600秒(1小时)
167 +- **商品信息**:1800秒(30分钟)
168 +- **订单列表**:300秒(5分钟)
169 +- **统计数据**:60秒(1分钟)
170 +- **配置数据**:86400秒(24小时)
171 +
172 +## 监控和管理
173 +
174 +### 健康检查
175 +
176 +访问:`/actuator/health`
177 +
178 +```json
179 +{
180 + "status": "UP",
181 + "components": {
182 + "cache": {
183 + "status": "UP",
184 + "details": {
185 + "status": "缓存服务正常"
186 + }
187 + }
188 + }
189 +}
190 +```
191 +
192 +### 缓存统计
193 +
194 +访问:`/actuator/cache/stats`
195 +
196 +```json
197 +{
198 + "localHits": 1250,
199 + "localMisses": 180,
200 + "redisHits": 450,
201 + "redisMisses": 80,
202 + "totalRequests": 1960,
203 + "hitRate": 0.867,
204 + "localSize": 256
205 +}
206 +```
207 +
208 +### 缓存管理操作
209 +
210 +```bash
211 +# 清除指定缓存
212 +DELETE /actuator/cache/evict?key=order:12345
213 +
214 +# 清除前缀缓存
215 +DELETE /actuator/cache/evict/prefix?prefix=order
216 +
217 +# 清空所有缓存
218 +DELETE /actuator/cache/clear
219 +
220 +# 检查缓存是否存在
221 +GET /actuator/cache/exists?key=order:12345
222 +
223 +# 获取缓存过期时间
224 +GET /actuator/cache/expire?key=order:12345
225 +```
226 +
227 +## 最佳实践
228 +
229 +### 1. 缓存key设计原则
230 +
231 +- **唯一性**:确保key的唯一性,避免冲突
232 +- **可读性**:key应该具有一定的可读性,便于调试
233 +- **简洁性**:key不宜过长,影响性能
234 +- **层次性**:使用冒号分隔符建立层次结构
235 +
236 +```java
237 +// 好的key设计
238 +"order:12345"
239 +"customer:67890:orders"
240 +"product:category:123:page:1"
241 +
242 +// 不好的key设计
243 +"very_long_business_module_name_order_information_12345"
244 +"order12345customerinfo"
245 +```
246 +
247 +### 2. 缓存时间策略
248 +
249 +- **高频查询、更新不频繁**:长时间缓存(10-30分钟)
250 +- **中等频率数据**:中等时间缓存(5-10分钟)
251 +- **实时性要求高**:短时间缓存(1-5分钟)
252 +- **配置类数据**:长时间缓存(1-24小时)
253 +
254 +### 3. 缓存失效策略
255 +
256 +```java
257 +// 单个数据更新
258 +@CacheEvict(prefix = "order", keys = "#order.orderId")
259 +public OrderDTO updateOrder(OrderDTO order) { ... }
260 +
261 +// 影响多个缓存的操作
262 +@CacheEvict(prefix = "order", keys = {
263 + "#order.orderId",
264 + "'customer_' + #order.customerId + '_*'"
265 +})
266 +public OrderDTO updateOrderStatus(OrderDTO order) { ... }
267 +
268 +// 批量操作清除所有相关缓存
269 +@CacheEvict(prefix = "order", allEntries = true)
270 +public void batchUpdateOrders(List<OrderDTO> orders) { ... }
271 +```
272 +
273 +### 4. 条件缓存使用
274 +
275 +```java
276 +// 参数验证
277 +condition = "#orderId != null && #orderId > 0"
278 +
279 +// 结果验证
280 +unless = "#result == null || #result.isEmpty()"
281 +
282 +// 业务条件
283 +condition = "#status == 'ACTIVE'"
284 +```
285 +
286 +## 注意事项
287 +
288 +### 1. 数据类型支持
289 +
290 +-**Lombok @Data类**:完全支持,推荐使用
291 +-**标准POJO**:支持
292 +-**基本数据类型**:支持
293 +-**集合类型**:支持
294 +-**Protobuf对象**:需要转换为DTO再缓存
295 +
296 +### 2. 缓存穿透防护
297 +
298 +系统自动处理null值缓存,防止缓存穿透:
299 +
300 +```java
301 +// 查询结果为null时也会被缓存(使用特殊标记)
302 +@Cacheable(prefix = "order", key = "#orderId", ttl = 300)
303 +public OrderDTO getOrderById(Long orderId) {
304 + OrderDTO order = callGrpcService(orderId);
305 + return order; // 即使为null也会被缓存
306 +}
307 +```
308 +
309 +### 3. 异常处理
310 +
311 +缓存操作异常不会影响业务执行:
312 +
313 +- 缓存读取失败 → 直接执行业务方法
314 +- 缓存写入失败 → 记录日志,继续执行
315 +- 缓存清除失败 → 记录日志,不影响业务
316 +
317 +### 4. 性能考虑
318 +
319 +- **本地缓存优先**:优先查询Caffeine,未命中再查Redis
320 +- **异步回写**:本地缓存命中但Redis缺失时,异步回写Redis
321 +- **批量操作**:使用SCAN代替KEYS,避免阻塞Redis
322 +
323 +## 故障排查
324 +
325 +### 1. 缓存未命中
326 +
327 +检查项:
328 +- key生成是否正确
329 +- TTL是否过短
330 +- 条件表达式是否满足
331 +- 缓存服务是否正常
332 +
333 +### 2. 缓存数据异常
334 +
335 +检查项:
336 +- 序列化配置是否正确
337 +- 数据类型是否支持
338 +- JSON格式是否有效
339 +
340 +### 3. 性能问题
341 +
342 +检查项:
343 +- 缓存命中率是否正常(建议>70%)
344 +- 缓存大小是否合理
345 +- Redis连接是否正常
346 +
347 +## 示例代码
348 +
349 +完整的使用示例请参考:
350 +- `CachedOrderService.java` - 服务层缓存使用示例
351 +- `CachedOrderController.java` - 控制器层使用示例
352 +- `OrderDTO.java` - Lombok @Data类示例
353 +
354 +## 总结
355 +
356 +通过本缓存功能,可以显著提升应用性能:
357 +
358 +- **响应时间减少**:60-80%(缓存命中时)
359 +- **网络调用减少**:70-90%(针对gRPC调用)
360 +- **系统稳定性提升**:降级机制保证服务可用性
361 +
362 +推荐在以下场景使用缓存:
363 +- ✅ 高频查询的gRPC接口
364 +- ✅ 相对稳定的基础数据
365 +- ✅ 对响应时间敏感的用户接口
366 +- ❌ 强一致性要求的实时数据
367 +- ❌ 频繁变更的业务数据
1 +# 微信小程序订阅消息推送后端开发文档(适用于 Spring Boot 微服务)
2 +
3 +## 1. 背景与目标
4 +
5 +本方案通过微信小程序的 **订阅消息(Subscribe Message)** 能力,在用户授权的前提下,在特定时间向用户推送服务通知。例如在订餐周期开启前提醒用户开始订餐。
6 +
7 +> 微信要求订阅消息必须通过用户主动操作获得授权,且每次授权只能发送一次消息(非特权行业除外)。([Medium][1])
8 +
9 +---
10 +
11 +## 2. 模板申请与配置
12 +
13 +### 2.1 登录微信公众平台
14 +
15 +1. 登录微信公众平台(小程序管理后台)。
16 +2. 在左侧菜单选择 **功能 → 订阅消息**
17 +3.**公共模板库** 中查找最符合业务场景的模板;如无合适模板,可新建申请。
18 +4. 按需选择关键词组合并提交审核。
19 +5. 审核通过后在“我的模板”中得到 **模板 ID**
20 +
21 +**说明**:该模板 ID 将用于消息发送接口参数。([aigwa.com][2])
22 +
23 +---
24 +
25 +## 3. 小程序前端授权流程简述
26 +
27 +在前端,当用户在小程序产生关键交互(例如提交订单成功后)时,需要通过 `wx.requestSubscribeMessage` 调起订阅授权。
28 +
29 +```js
30 +wx.requestSubscribeMessage({
31 + tmplIds: ['TEMPLATE_ID'],
32 + success(res) {
33 + // 例如:res['TEMPLATE_ID'] = "accept" | "reject" | ...
34 + // 结果上报后端
35 + }
36 +})
37 +```
38 +
39 +**提示:**
40 +
41 +* 用户点击“允许”后即获得一次性发送权限。([intl.cloud.tencent.com][3])
42 +
43 +---
44 +
45 +## 4. 后端核心概念与数据模型
46 +
47 +### 4.1 订阅授权状态表(示例)
48 +
49 +```sql
50 +CREATE TABLE wx_subscribe_permission (
51 + id BIGINT PRIMARY KEY AUTO_INCREMENT,
52 + openid VARCHAR(64) NOT NULL,
53 + template_id VARCHAR(64) NOT NULL,
54 + biz_type VARCHAR(32) NOT NULL,
55 + order_cycle_id VARCHAR(32),
56 + status ENUM('PENDING','USED','FAILED') NOT NULL DEFAULT 'PENDING',
57 + retry_count INT DEFAULT 0,
58 + last_error VARCHAR(255),
59 + authorized_at DATETIME,
60 + used_at DATETIME,
61 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
62 + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
63 +);
64 +```
65 +
66 +* `PENDING`:用户已授权但未发送;
67 +* `USED`:发送成功;
68 +* `FAILED`:发送失败且不再重试。
69 +
70 +---
71 +
72 +## 5. 微信服务端 API 调用
73 +
74 +### 5.1 获取 `access_token`
75 +
76 +用于后端所有接口调用授权凭证:
77 +
78 +```
79 +GET https://api.weixin.qq.com/cgi-bin/token
80 +?grant_type=client_credential
81 +&appid=APPID
82 +&secret=APPSECRET
83 +```
84 +
85 +**返回示例:**
86 +
87 +```json
88 +{
89 + "access_token": "ACCESS_TOKEN",
90 + "expires_in": 7200
91 +}
92 +```
93 +
94 +---
95 +
96 +### 5.2 发送订阅消息
97 +
98 +使用订阅消息下发接口:
99 +
100 +```
101 +POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN
102 +```
103 +
104 +#### 请求 JSON 示例
105 +
106 +```json
107 +{
108 + "touser": "USER_OPENID",
109 + "template_id": "TEMPLATE_ID",
110 + "page": "pages/order/index?cycleId=202501",
111 + "data": {
112 + "thing1": { "value": "下次订餐即将开启" },
113 + "time2": { "value": "周一 09:00" },
114 + "thing3": { "value": "请点击跳转完成订餐" }
115 + }
116 +}
117 +```
118 +
119 +字段说明:
120 +
121 +* `touser`:接收用户微信 OpenID;
122 +* `template_id`:模板 ID;
123 +* `page`:消息点击跳转页面路径;
124 +* `data`:模板关键词对应数据。([gitcode.csdn.net][4])
125 +
126 +---
127 +
128 +## 6. 后端主要接口设计
129 +
130 +### 6.1 授权结果回传 API(POST)
131 +
132 +**URL**`/api/wechat/subscribe/authorize`
133 +
134 +**请求示例**
135 +
136 +```json
137 +{
138 + "openid": "xxxx",
139 + "templateId": "TEMPLATE_ID",
140 + "bizType": "ORDER_REMIND",
141 + "orderCycleId": "202501",
142 + "result": "accept"
143 +}
144 +```
145 +
146 +**响应示例**
147 +
148 +```json
149 +{ "code": 0, "msg": "ok" }
150 +```
151 +
152 +逻辑:
153 +
154 +* 若 result = `accept`,写入 `wx_subscribe_permission` 表;
155 +* 否则记录拒绝状态。
156 +
157 +---
158 +
159 +### 6.2 定时发送任务
160 +
161 +后端定时任务(如 `@Scheduled`)在规定时间触发:
162 +
163 +1. 查询所有 `status = PENDING` 且对应用户未订餐的记录;
164 +2. 将消息入队(如 MQ);
165 +3. 消费执行发送逻辑;
166 +4. 更新 `status``retry_count`
167 +
168 +---
169 +
170 +## 7. 发送逻辑与重试策略
171 +
172 +### 7.1 核心发送逻辑(伪码)
173 +
174 +```java
175 +for (Permission p : pendingList) {
176 + try {
177 + sendSubscribeMessage(p);
178 + p.setStatus("USED");
179 + p.setUsedAt(now());
180 + } catch (WeChatApiException e) {
181 + if (canRetry(e) && p.getRetryCount() < 3) {
182 + p.incrementRetry();
183 + } else {
184 + p.setStatus("FAILED");
185 + p.setLastError(e.getMessage());
186 + }
187 + }
188 + update(p);
189 +}
190 +```
191 +
192 +---
193 +
194 +## 8. 常见错误码处理
195 +
196 +| 错误码 | 说明 | 处理方式 |
197 +| ------- | --------------- | ---------------- |
198 +| `43101` | 用户拒绝 | 标记 `FAILED` 不再重试 |
199 +| `40001` | access_token 错误 | 刷新 Token 并重试 |
200 +| 网络错误 | 网络抖动 | 按退避重试 |
201 +
202 +---
203 +
204 +## 9. 测试建议
205 +
206 +1. 先使用测试 OpenID 调用发送接口;
207 +2. 检查模板字段是否与后台组装一致;
208 +3. 校验跳转页面路径参数是否正确。
209 +
210 +---
211 +
212 +## 10. 参考链接
213 +
214 +* 微信官方订阅消息后端发送接口说明(subscribeMessage.send)([gitcode.csdn.net][4])
215 +* 小程序端授权调用说明(wx.requestSubscribeMessage)([wdk-docs.github.io][5])
216 +
217 +---
218 +
219 +如果你需要,我可以进一步提供 **Spring Boot 样板代码**(包括定时任务 & 消息发送 Service 实现)。
220 +
221 +[1]: https://medium.com/china-software-development/everything-about-wechat-subscription-message-884246d71cb3?utm_source=chatgpt.com "Everything about WeChat Subscription Message | by David Yu | Shanghai Coders | Medium"
222 +[2]: https://aigwa.com/novel/Content/detail/1397.html?utm_source=chatgpt.com "微信小程序 订阅消息·addTemplate_微信小程序开发文档_啊嘎哇在线工具箱"
223 +[3]: https://intl.cloud.tencent.com/document/product/1219/57734?utm_source=chatgpt.com "Open APIs"
224 +[4]: https://gitcode.csdn.net/65ec534a1a836825ed7988b9.html?utm_source=chatgpt.com "微信小程序-小程序订阅消息(四)_微信小程序_MinggeQingchun-AtomGit开源社区"
225 +[5]: https://wdk-docs.github.io/wxadev-docs/api/subscribe-message/wx.requestSubscribeMessage.html?utm_source=chatgpt.com "wx.requestSubscribeMessage — wxadev v2.21.0 文档"
...@@ -81,5 +81,11 @@ public interface ConfigConstants { ...@@ -81,5 +81,11 @@ public interface ConfigConstants {
81 81
82 String MINI_PROGRAM_APP_ID = "${miniprogram.appId}"; 82 String MINI_PROGRAM_APP_ID = "${miniprogram.appId}";
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}";
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}";
84 90
85 } 91 }
......
...@@ -4,7 +4,9 @@ import com.github.xiaoymin.knife4j.annotations.ApiSupport; ...@@ -4,7 +4,9 @@ import com.github.xiaoymin.knife4j.annotations.ApiSupport;
4 import com.infoloop.tianting.annotation.RepeatSubmit; 4 import com.infoloop.tianting.annotation.RepeatSubmit;
5 import com.infoloop.tianting.mealorderservice.SingleMealOrderRpcResponse; 5 import com.infoloop.tianting.mealorderservice.SingleMealOrderRpcResponse;
6 import com.infoloop.tianting.model.common.CreatedResult; 6 import com.infoloop.tianting.model.common.CreatedResult;
7 +import com.infoloop.tianting.model.common.ResponseResult;
7 import com.infoloop.tianting.model.dto.MealOrderDTO; 8 import com.infoloop.tianting.model.dto.MealOrderDTO;
9 +import com.infoloop.tianting.model.dto.OrderSubscriptionMessageDTO;
8 import com.infoloop.tianting.model.vo.DeliveryRuleVO; 10 import com.infoloop.tianting.model.vo.DeliveryRuleVO;
9 import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO; 11 import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO;
10 import com.infoloop.tianting.model.vo.MealOrderVO; 12 import com.infoloop.tianting.model.vo.MealOrderVO;
...@@ -75,4 +77,14 @@ public class MealOrderController { ...@@ -75,4 +77,14 @@ public class MealOrderController {
75 return mealOrderService.queryDinerMealSuspensionRecordsByCondition(); 77 return mealOrderService.queryDinerMealSuspensionRecordsByCondition();
76 } 78 }
77 79
80 + @ApiOperation(value = "小程序:创建订餐订阅消息授权记录")
81 + @PutMapping("/mealorder/subscriptionmessage")
82 + @ResponseStatus(HttpStatus.CREATED)
83 + public ResponseResult<Boolean> createOrderSubscriptionMessage(
84 + @RequestBody @Valid final OrderSubscriptionMessageDTO.CreateOrderSubscriptionMessageDTO dto
85 + ) {
86 + mealOrderService.createOrderSubscriptionMessage(dto);
87 + return ResponseResult.ok(true);
88 + }
89 +
78 } 90 }
......
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 +}
1 +package com.infoloop.tianting.model.dto;
2 +
3 +import io.swagger.annotations.ApiModel;
4 +import io.swagger.annotations.ApiModelProperty;
5 +import lombok.Data;
6 +
7 +import javax.validation.constraints.NotBlank;
8 +import javax.validation.constraints.NotNull;
9 +
10 +@Data
11 +@ApiModel(description = "小程序订阅消息 DTO")
12 +public class OrderSubscriptionMessageDTO {
13 +
14 + @Data
15 + @ApiModel(description = "创建订阅消息记录")
16 + public static class CreateOrderSubscriptionMessageDTO {
17 +
18 + @ApiModelProperty(value = "就餐人ID,可为空,后端根据实际情况处理", required = false)
19 + private Integer dinerId;
20 +
21 + @ApiModelProperty(value = "订阅消息模板ID,可为空,为空时使用配置的默认值", required = false)
22 + private String templateId;
23 +
24 + @NotBlank(message = "jumpPath 不能为空")
25 + @ApiModelProperty(value = "跳转路径(小程序 path),必填", required = true)
26 + private String jumpPath;
27 + }
28 +}
29 +
...@@ -52,4 +52,26 @@ public class WxUserDto { ...@@ -52,4 +52,26 @@ public class WxUserDto {
52 private WxUserPhoneInfoDto phone_info; 52 private WxUserPhoneInfoDto phone_info;
53 } 53 }
54 54
55 + @Data
56 + @Builder
57 + @ApiModel(description = "订阅消息发送请求")
58 + @NoArgsConstructor
59 + @AllArgsConstructor
60 + public static class SendSubscriptionMessageRequest {
61 + private String touser;
62 + private String template_id;
63 + private String page;
64 + private java.util.Map<String, Object> data;
65 + }
66 +
67 + @Data
68 + @Builder
69 + @ApiModel(description = "订阅消息发送响应")
70 + @NoArgsConstructor
71 + @AllArgsConstructor
72 + public static class SendSubscriptionMessageResponse {
73 + private Integer errcode;
74 + private String errmsg;
75 + }
76 +
55 } 77 }
......
...@@ -3,6 +3,7 @@ package com.infoloop.tianting.service; ...@@ -3,6 +3,7 @@ package com.infoloop.tianting.service;
3 import com.infoloop.tianting.mealorderservice.SingleMealOrderRpcResponse; 3 import com.infoloop.tianting.mealorderservice.SingleMealOrderRpcResponse;
4 import com.infoloop.tianting.model.common.CreatedResult; 4 import com.infoloop.tianting.model.common.CreatedResult;
5 import com.infoloop.tianting.model.dto.MealOrderDTO; 5 import com.infoloop.tianting.model.dto.MealOrderDTO;
6 +import com.infoloop.tianting.model.dto.OrderSubscriptionMessageDTO;
6 import com.infoloop.tianting.model.vo.DeliveryRuleVO; 7 import com.infoloop.tianting.model.vo.DeliveryRuleVO;
7 import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO; 8 import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO;
8 import com.infoloop.tianting.model.vo.MealOrderVO; 9 import com.infoloop.tianting.model.vo.MealOrderVO;
...@@ -30,4 +31,6 @@ public interface MealOrderService { ...@@ -30,4 +31,6 @@ public interface MealOrderService {
30 * @return 停餐信息列表 31 * @return 停餐信息列表
31 */ 32 */
32 List<DinerMealSuspensionRecordVO> queryDinerMealSuspensionRecordsByCondition(); 33 List<DinerMealSuspensionRecordVO> queryDinerMealSuspensionRecordsByCondition();
34 +
35 + void createOrderSubscriptionMessage(OrderSubscriptionMessageDTO.CreateOrderSubscriptionMessageDTO dto);
33 } 36 }
......
1 +package com.infoloop.tianting.service;
2 +
3 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
4 +
5 +/**
6 + * 订餐提醒服务接口
7 + * 用于发送订餐周期开启前的提醒消息(小程序订阅消息)
8 + */
9 +public interface OrderReminderService {
10 + /**
11 + * 发送订餐提醒消息
12 + *
13 + * 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
14 + * - thing8: 用餐类型(如:下次订单即将开始)
15 + * - time1: 订餐时间(如:13:00~21:00)
16 + * - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
17 + *
18 + * @param openId 用户 OpenID(小程序的 openId)
19 + * @param page 点击跳转小程序页面路径
20 + * @param mealType 用餐类型,例如:"下次订单即将开始"
21 + * @param orderTime 订餐时间,例如:"13:00~21:00"
22 + * @param tips 温馨提示,例如:"为了保证您明日正常用餐,立即订餐!"
23 + * @return 发送结果
24 + */
25 + SendSubscriptionMessageResponse sendOrderReminder(String openId, String page,
26 + String mealType, String orderTime, String tips);
27 +}
1 package com.infoloop.tianting.service; 1 package com.infoloop.tianting.service;
2 2
3 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
3 import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; 4 import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto;
4 import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; 5 import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto;
5 6
7 +import java.util.Map;
8 +
6 public interface WxMiniProgramService { 9 public interface WxMiniProgramService {
7 WxUserOpenIdDto getWxUserOpenIdByCode(String code); 10 WxUserOpenIdDto getWxUserOpenIdByCode(String code);
8 WxUserPhoneResponseDto getUserPhoneInfoByCode(String code); 11 WxUserPhoneResponseDto getUserPhoneInfoByCode(String code);
12 + SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, Map<String, Object> data);
9 } 13 }
......
...@@ -12,7 +12,13 @@ import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcRequest; ...@@ -12,7 +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;
16 +import com.infoloop.tianting.mealorderservice.CreateOrderSubscriptionMessageRpcRequest;
15 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;
16 import com.infoloop.tianting.mealorderservice.DeleteMpAccountDinerRefsByIdsRpcRequest; 22 import com.infoloop.tianting.mealorderservice.DeleteMpAccountDinerRefsByIdsRpcRequest;
17 import com.infoloop.tianting.mealorderservice.DinerCreation; 23 import com.infoloop.tianting.mealorderservice.DinerCreation;
18 import com.infoloop.tianting.mealorderservice.DinerModification; 24 import com.infoloop.tianting.mealorderservice.DinerModification;
...@@ -36,6 +42,7 @@ import com.infoloop.tianting.mealorderservice.QueryGradeClassesByConditionRpcReq ...@@ -36,6 +42,7 @@ import com.infoloop.tianting.mealorderservice.QueryGradeClassesByConditionRpcReq
36 import com.infoloop.tianting.mealorderservice.QueryLatestMealMenuRecordsByClientIdsRpcRequest; 42 import com.infoloop.tianting.mealorderservice.QueryLatestMealMenuRecordsByClientIdsRpcRequest;
37 import com.infoloop.tianting.mealorderservice.QueryMealOrdersByConditionRpcRequest; 43 import com.infoloop.tianting.mealorderservice.QueryMealOrdersByConditionRpcRequest;
38 import com.infoloop.tianting.mealorderservice.QueryMpAccountDinerRefsByConditionRpcRequest; 44 import com.infoloop.tianting.mealorderservice.QueryMpAccountDinerRefsByConditionRpcRequest;
45 +import com.infoloop.tianting.mealorderservice.QueryMpAccountsByConditionRpcRequest;
39 import com.infoloop.tianting.mealorderservice.QueryReserveMealOrdersByDinerIdsRpcRequest; 46 import com.infoloop.tianting.mealorderservice.QueryReserveMealOrdersByDinerIdsRpcRequest;
40 import com.infoloop.tianting.mealorderservice.SingleDinerRpcResponse; 47 import com.infoloop.tianting.mealorderservice.SingleDinerRpcResponse;
41 import com.infoloop.tianting.mealorderservice.SingleGradeClassRpcResponse; 48 import com.infoloop.tianting.mealorderservice.SingleGradeClassRpcResponse;
...@@ -104,6 +111,19 @@ public class MealOrderServiceRpcClient { ...@@ -104,6 +111,19 @@ public class MealOrderServiceRpcClient {
104 .build()); 111 .build());
105 } 112 }
106 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 +
107 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) {
108 return mealOrderServiceRpcBlockingStub.queryMpAccountDinerRefsByCondition(QueryMpAccountDinerRefsByConditionRpcRequest.newBuilder() 128 return mealOrderServiceRpcBlockingStub.queryMpAccountDinerRefsByCondition(QueryMpAccountDinerRefsByConditionRpcRequest.newBuilder()
109 .setEnterpriseId(enterpriseId) 129 .setEnterpriseId(enterpriseId)
...@@ -302,4 +322,73 @@ public class MealOrderServiceRpcClient { ...@@ -302,4 +322,73 @@ public class MealOrderServiceRpcClient {
302 return mealOrderServiceRpcBlockingStub.queryDinerMealSuspensionRecordsByCondition(request.build()) 322 return mealOrderServiceRpcBlockingStub.queryDinerMealSuspensionRecordsByCondition(request.build())
303 .getResponsesList(); 323 .getResponsesList();
304 } 324 }
325 +
326 + /**
327 + * 小程序订阅消息:创建一次性授权记录(待使用)
328 + */
329 + public long createOrderSubscriptionMessage(
330 + int enterpriseId,
331 + int dinerId,
332 + String openId,
333 + String templateId,
334 + long orderPeriodStartDate,
335 + @Nullable String jumpPath,
336 + @Nullable Long orderPeriodEndDate
337 + ) {
338 + final var req = CreateOrderSubscriptionMessageRpcRequest.newBuilder()
339 + .setEnterpriseId(enterpriseId)
340 + .setDinerId(dinerId)
341 + .setOpenId(openId)
342 + .setTemplateId(templateId)
343 + .setOrderPeriodStartDate(orderPeriodStartDate)
344 + .setJumpPath(jumpPath == null ? "" : jumpPath);
345 + if (orderPeriodEndDate != null) {
346 + req.setOrderPeriodEndDate(orderPeriodEndDate);
347 + }
348 + return mealOrderServiceRpcBlockingStub.createOrderSubscriptionMessage(req.build()).getId();
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 + }
305 } 394 }
......
1 package com.infoloop.tianting.service.client; 1 package com.infoloop.tianting.service.client;
2 2
3 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageRequest;
4 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
3 import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; 5 import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto;
4 import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; 6 import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto;
5 import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto; 7 import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto;
...@@ -31,6 +33,7 @@ public class WxMiniProgramHttpClient { ...@@ -31,6 +33,7 @@ public class WxMiniProgramHttpClient {
31 33
32 private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appId}&secret={appSecret}"; 34 private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appId}&secret={appSecret}";
33 private static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={accessToken}"; 35 private static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={accessToken}";
36 + private static final String SUBSCRIBE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={accessToken}";
34 37
35 private final RestTemplate restTemplate; 38 private final RestTemplate restTemplate;
36 39
...@@ -135,4 +138,46 @@ public class WxMiniProgramHttpClient { ...@@ -135,4 +138,46 @@ public class WxMiniProgramHttpClient {
135 } 138 }
136 } 139 }
137 140
141 + /**
142 + * 发送订阅消息
143 + * @param openId 用户 OpenID
144 + * @param templateId 模板 ID
145 + * @param page 点击跳转页面路径
146 + * @param data 模板数据,格式为 Map<String, Map<String, String>>,例如:{"thing1": {"value": "内容"}}
147 + * @return 发送结果
148 + */
149 + public SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, java.util.Map<String, Object> data) {
150 + try {
151 + String accessToken = fetchStableAccessToken();
152 + HttpHeaders headers = new HttpHeaders();
153 + headers.setContentType(MediaType.APPLICATION_JSON);
154 +
155 + SendSubscriptionMessageRequest request = SendSubscriptionMessageRequest.builder()
156 + .touser(openId)
157 + .template_id(templateId)
158 + .page(page)
159 + .data(data)
160 + .build();
161 +
162 + HttpEntity<SendSubscriptionMessageRequest> requestEntity = new HttpEntity<>(request, headers);
163 + ResponseEntity<SendSubscriptionMessageResponse> response = restTemplate.postForEntity(
164 + SUBSCRIBE_MESSAGE_URL, requestEntity, SendSubscriptionMessageResponse.class, accessToken);
165 +
166 + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
167 + log.error("Failed to send subscription message: {}", response);
168 + throw new IllegalArgumentException("发送订阅消息失败");
169 + }
170 +
171 + SendSubscriptionMessageResponse result = response.getBody();
172 + if (result.getErrcode() != null && result.getErrcode() != 0) {
173 + log.error("WeChat subscription message error: errcode={}, errmsg={}", result.getErrcode(), result.getErrmsg());
174 + }
175 +
176 + return result;
177 + } catch (Exception e) {
178 + log.error("Error sending subscription message", e);
179 + throw new IllegalArgumentException("发送订阅消息失败: " + e.getMessage());
180 + }
181 + }
182 +
138 } 183 }
......
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 +}
...@@ -7,6 +7,7 @@ import com.infoloop.tianting.exception.ErrorCodeEnum; ...@@ -7,6 +7,7 @@ import com.infoloop.tianting.exception.ErrorCodeEnum;
7 import com.infoloop.tianting.mealorderservice.*; 7 import com.infoloop.tianting.mealorderservice.*;
8 import com.infoloop.tianting.model.common.CreatedResult; 8 import com.infoloop.tianting.model.common.CreatedResult;
9 import com.infoloop.tianting.model.dto.MealOrderDTO; 9 import com.infoloop.tianting.model.dto.MealOrderDTO;
10 +import com.infoloop.tianting.model.dto.OrderSubscriptionMessageDTO;
10 import com.infoloop.tianting.model.vo.DeliveryRuleVO; 11 import com.infoloop.tianting.model.vo.DeliveryRuleVO;
11 import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO; 12 import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO;
12 import com.infoloop.tianting.model.vo.MealOrderVO; 13 import com.infoloop.tianting.model.vo.MealOrderVO;
...@@ -19,12 +20,14 @@ import com.infoloop.tianting.service.SuspensionService; ...@@ -19,12 +20,14 @@ import com.infoloop.tianting.service.SuspensionService;
19 import lombok.RequiredArgsConstructor; 20 import lombok.RequiredArgsConstructor;
20 import lombok.extern.slf4j.Slf4j; 21 import lombok.extern.slf4j.Slf4j;
21 import org.springframework.beans.factory.annotation.Autowired; 22 import org.springframework.beans.factory.annotation.Autowired;
23 +import org.springframework.beans.factory.annotation.Value;
22 import org.springframework.stereotype.Service; 24 import org.springframework.stereotype.Service;
23 25
24 import java.time.DayOfWeek; 26 import java.time.DayOfWeek;
25 import java.time.LocalDate; 27 import java.time.LocalDate;
26 import java.time.LocalDateTime; 28 import java.time.LocalDateTime;
27 import java.time.LocalTime; 29 import java.time.LocalTime;
30 +import java.time.ZoneId;
28 import java.util.ArrayList; 31 import java.util.ArrayList;
29 import java.util.Collections; 32 import java.util.Collections;
30 import java.util.Date; 33 import java.util.Date;
...@@ -33,6 +36,8 @@ import java.util.Map; ...@@ -33,6 +36,8 @@ import java.util.Map;
33 import java.util.function.Function; 36 import java.util.function.Function;
34 import java.util.stream.Collectors; 37 import java.util.stream.Collectors;
35 38
39 +import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID;
40 +
36 @Slf4j 41 @Slf4j
37 @Service 42 @Service
38 @RequiredArgsConstructor(onConstructor = @__(@Autowired)) 43 @RequiredArgsConstructor(onConstructor = @__(@Autowired))
...@@ -46,6 +51,9 @@ public class MealOrderServiceImpl implements MealOrderService { ...@@ -46,6 +51,9 @@ public class MealOrderServiceImpl implements MealOrderService {
46 51
47 private final SuspensionService suspensionService; 52 private final SuspensionService suspensionService;
48 53
54 + @Value(MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID)
55 + private String templateId;
56 +
49 @Override 57 @Override
50 public List<MealOrderVO> queryMealOrders(MealOrderDTO.QueryMealOrderDTO queryMealOrderDTO) { 58 public List<MealOrderVO> queryMealOrders(MealOrderDTO.QueryMealOrderDTO queryMealOrderDTO) {
51 final var loginInfo = LoginContextHolder.getLoginInfo(); 59 final var loginInfo = LoginContextHolder.getLoginInfo();
...@@ -293,4 +301,152 @@ public class MealOrderServiceImpl implements MealOrderService { ...@@ -293,4 +301,152 @@ public class MealOrderServiceImpl implements MealOrderService {
293 * 将RPC响应转换为VO对象 301 * 将RPC响应转换为VO对象
294 */ 302 */
295 // 已改为统一从 SuspensionService 获取并集窗口,不再需要单条转换 303 // 已改为统一从 SuspensionService 获取并集窗口,不再需要单条转换
304 +
305 + @Override
306 + public void createOrderSubscriptionMessage(OrderSubscriptionMessageDTO.CreateOrderSubscriptionMessageDTO dto) {
307 + final var enterpriseId = LoginContextHolder.getEnterpriseId();
308 + final var openId = LoginContextHolder.getOpenId();
309 +
310 + if (openId == null || openId.isBlank()) {
311 + throw new IllegalStateException("当前登录用户 openId 为空,无法创建订阅提醒");
312 + }
313 + if (dto.getJumpPath() == null || dto.getJumpPath().isBlank()) {
314 + throw new IllegalArgumentException("jumpPath 不能为空");
315 + }
316 +
317 + // 1. 处理就餐人 ID:如果为空,从关联关系推断
318 + int dinerId = dto.getDinerId() != null ? dto.getDinerId() : getDefaultDinerId(enterpriseId, openId);
319 +
320 + // 2. 处理模板 ID:如果为空,使用配置的默认值
321 + String finalTemplateId = (dto.getTemplateId() != null && !dto.getTemplateId().isBlank())
322 + ? dto.getTemplateId()
323 + : templateId;
324 + if (finalTemplateId == null || finalTemplateId.isBlank()) {
325 + throw new IllegalStateException("订阅消息模板ID未配置,无法创建订阅提醒");
326 + }
327 +
328 + // 3. 先根据订餐规则配置,计算本次"订餐周期"的开始/结束时间
329 + final var period = resolveOrderPeriod(enterpriseId);
330 +
331 + // 打印所有参数,用于检查库服务版本是否匹配
332 + log.info("创建订阅消息 - 调用库服务参数: enterpriseId={}, dinerId={}, openId={}, templateId={}, orderPeriodStartDate={}, jumpPath={}, orderPeriodEndDate={}",
333 + enterpriseId,
334 + dinerId,
335 + openId,
336 + finalTemplateId,
337 + period.startAt,
338 + dto.getJumpPath(),
339 + period.endAt);
340 +
341 + mealOrderServiceRpcClient.createOrderSubscriptionMessage(
342 + enterpriseId,
343 + dinerId,
344 + openId,
345 + finalTemplateId,
346 + period.startAt,
347 + dto.getJumpPath(),
348 + period.endAt
349 + );
350 + }
351 +
352 + /**
353 + * 如果前端未传就餐人 ID,从当前用户的关联关系中推断默认值
354 + * 如果只有一个关联的就餐人,返回该就餐人 ID;如果有多个或没有,返回 0
355 + */
356 + private int getDefaultDinerId(int enterpriseId, String openId) {
357 + final var loginInfo = LoginContextHolder.getLoginInfo();
358 + final var mpAccountDinerRefs = mealOrderServiceRpcClient.queryMpAccountDinerRefsByCondition(
359 + enterpriseId,
360 + Collections.singletonList(loginInfo.getId()),
361 + Collections.emptyList()
362 + );
363 + if (mpAccountDinerRefs.size() == 1) {
364 + return mpAccountDinerRefs.get(0).getDinerId();
365 + }
366 + return 0;
367 + }
368 +
369 + private static final class Period {
370 + final long startAt;
371 + final Long endAt;
372 +
373 + private Period(long startAt, Long endAt) {
374 + this.startAt = startAt;
375 + this.endAt = endAt;
376 + }
377 + }
378 +
379 + /**
380 + * 根据订餐规则配置推导"最近的未来订餐周期"的开始/结束时间。
381 + *
382 + * 规则说明:
383 + * - 使用 DeliveryRuleServiceRpc.getDefaultDeliveryTemplateAndRule 的 configJson.rules[0]
384 + * - 该规则包含:startTime / endTime(HH:mm:ss)以及 monday~sunday 开关
385 + * - 从"当前时刻"开始,向未来查找最近的一个启用日 + startTime 作为周期开始时间
386 + * - 周期结束时间暂定为:该周最后一个启用日 + endTime(如果滚到了下一周,则用下一周的最后启用日)
387 + */
388 + private Period resolveOrderPeriod(int enterpriseId) {
389 + final var response = deliveryRuleServiceRpcClient.getDefaultDeliveryTemplateAndRule(enterpriseId);
390 + final var rule = response.getRule();
391 + if (rule == null || rule.getConfigJson().getRulesList().isEmpty()) {
392 + throw new IllegalStateException("订餐规则未配置,无法创建订阅提醒");
393 + }
394 +
395 + final var cfg = rule.getConfigJson().getRules(0);
396 + final String startTimeStr = cfg.getStartTime();
397 + final String endTimeStr = cfg.getEndTime();
398 + if (startTimeStr == null || endTimeStr == null) {
399 + throw new IllegalStateException("订餐规则时间配置不完整,无法创建订阅提醒");
400 + }
401 +
402 + final List<DayOfWeek> enabledDays = new ArrayList<>();
403 + if (cfg.getMonday()) enabledDays.add(DayOfWeek.MONDAY);
404 + if (cfg.getTuesday()) enabledDays.add(DayOfWeek.TUESDAY);
405 + if (cfg.getWednesday()) enabledDays.add(DayOfWeek.WEDNESDAY);
406 + if (cfg.getThursday()) enabledDays.add(DayOfWeek.THURSDAY);
407 + if (cfg.getFriday()) enabledDays.add(DayOfWeek.FRIDAY);
408 + if (cfg.getSaturday()) enabledDays.add(DayOfWeek.SATURDAY);
409 + if (cfg.getSunday()) enabledDays.add(DayOfWeek.SUNDAY);
410 + if (enabledDays.isEmpty()) {
411 + throw new IllegalStateException("订餐规则未启用任何星期,无法创建订阅提醒");
412 + }
413 +
414 + final var startTime = LocalTime.parse(startTimeStr, DateUtil.HH_MM_SS);
415 + final var endTime = LocalTime.parse(endTimeStr, DateUtil.HH_MM_SS);
416 +
417 + final ZoneId zone = DateUtil.CHINA_ZONE;
418 + final var now = java.time.ZonedDateTime.now(zone);
419 +
420 + // 从当前时间起,向后最多检查 14 天,找到最近的"启用日 + startTime"
421 + LocalDate periodStartDate = null;
422 + DayOfWeek lastEnabledDay = Collections.max(enabledDays);
423 +
424 + for (int i = 0; i < 14; i++) {
425 + LocalDate candidateDate = now.toLocalDate().plusDays(i);
426 + DayOfWeek dow = candidateDate.getDayOfWeek();
427 + if (!enabledDays.contains(dow)) {
428 + continue;
429 + }
430 + var candidateStart = candidateDate.atTime(startTime).atZone(zone);
431 + if (!candidateStart.isBefore(now)) {
432 + periodStartDate = candidateDate;
433 + break;
434 + }
435 + }
436 +
437 + if (periodStartDate == null) {
438 + throw new IllegalStateException("根据订餐规则无法找到未来的订餐周期开始时间");
439 + }
440 +
441 + // 周期结束时间:使用"同一周内最后一个启用日 + endTime"
442 + // 这里以 periodStartDate 所在周的周一为基准
443 + LocalDate monday = periodStartDate.with(DayOfWeek.MONDAY);
444 + LocalDate lastEnabledDate = monday.plusDays(lastEnabledDay.getValue() - 1L);
445 + var endDateTime = lastEnabledDate.atTime(endTime).atZone(zone);
446 +
447 + long startMs = periodStartDate.atTime(startTime).atZone(zone).toInstant().toEpochMilli();
448 + long endMs = endDateTime.toInstant().toEpochMilli();
449 +
450 + return new Period(startMs, endMs);
451 + }
296 } 452 }
......
1 +package com.infoloop.tianting.service.impl;
2 +
3 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
4 +import com.infoloop.tianting.service.OrderReminderService;
5 +import com.infoloop.tianting.service.WxMiniProgramService;
6 +import lombok.RequiredArgsConstructor;
7 +import lombok.extern.slf4j.Slf4j;
8 +import org.springframework.beans.factory.annotation.Autowired;
9 +import org.springframework.beans.factory.annotation.Value;
10 +import org.springframework.stereotype.Service;
11 +
12 +import java.util.HashMap;
13 +import java.util.Map;
14 +
15 +import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID;
16 +
17 +/**
18 + * 订餐提醒服务实现
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
29 + */
30 +@Slf4j
31 +@Service
32 +@RequiredArgsConstructor(onConstructor = @__(@Autowired))
33 +public class OrderReminderServiceImpl implements OrderReminderService {
34 +
35 + private final WxMiniProgramService wxMiniProgramService;
36 +
37 + @Value(MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID)
38 + private String templateId;
39 +
40 + @Override
41 + public SendSubscriptionMessageResponse sendOrderReminder(String openId,
42 + String page,
43 + String mealType,
44 + String orderTime,
45 + String tips) {
46 + log.info("Sending order reminder via MiniProgram to openId: {}, page: {}, mealType: {}, orderTime: {}, tips: {}",
47 + openId, page, mealType, orderTime, tips);
48 +
49 + // 构建小程序订阅消息模板数据
50 + Map<String, Object> data = new HashMap<>();
51 +
52 + // thing8: 用餐类型(如:下次订单即将开始)
53 + Map<String, String> thing8Value = new HashMap<>();
54 + thing8Value.put("value", mealType);
55 + data.put("thing8", thing8Value);
56 +
57 + // time1: 订餐时间(如:13:00~21:00)
58 + Map<String, String> time1Value = new HashMap<>();
59 + time1Value.put("value", orderTime);
60 + data.put("time1", time1Value);
61 +
62 + // thing2: 温馨提示
63 + Map<String, String> thing2Value = new HashMap<>();
64 + thing2Value.put("value", tips);
65 + data.put("thing2", thing2Value);
66 +
67 + try {
68 + // 使用小程序订阅消息接口
69 + SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage(
70 + openId,
71 + templateId,
72 + page,
73 + data
74 + );
75 +
76 + if (response.getErrcode() != null && response.getErrcode() == 0) {
77 + log.info("Order reminder sent successfully via MiniProgram to openId: {}", openId);
78 + } else {
79 + log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}",
80 + openId, response.getErrcode(), response.getErrmsg());
81 + }
82 +
83 + return response;
84 + } catch (Exception e) {
85 + log.error("Failed to send order reminder via MiniProgram to openId: {}", openId, e);
86 + throw e;
87 + }
88 + }
89 +}
1 package com.infoloop.tianting.service.impl; 1 package com.infoloop.tianting.service.impl;
2 2
3 +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
3 import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; 4 import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto;
4 import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; 5 import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto;
5 import com.infoloop.tianting.service.WxMiniProgramService; 6 import com.infoloop.tianting.service.WxMiniProgramService;
...@@ -9,6 +10,8 @@ import lombok.extern.slf4j.Slf4j; ...@@ -9,6 +10,8 @@ import lombok.extern.slf4j.Slf4j;
9 import org.springframework.beans.factory.annotation.Autowired; 10 import org.springframework.beans.factory.annotation.Autowired;
10 import org.springframework.stereotype.Service; 11 import org.springframework.stereotype.Service;
11 12
13 +import java.util.Map;
14 +
12 @Slf4j 15 @Slf4j
13 @Service 16 @Service
14 @RequiredArgsConstructor(onConstructor = @__(@Autowired)) 17 @RequiredArgsConstructor(onConstructor = @__(@Autowired))
...@@ -26,4 +29,9 @@ public class WxMiniProgramServiceImpl implements WxMiniProgramService { ...@@ -26,4 +29,9 @@ public class WxMiniProgramServiceImpl implements WxMiniProgramService {
26 return wxMiniProgramHttpClient.getUserPhoneInfoByCode(code); 29 return wxMiniProgramHttpClient.getUserPhoneInfoByCode(code);
27 } 30 }
28 31
32 + @Override
33 + public SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, Map<String, Object> data) {
34 + return wxMiniProgramHttpClient.sendSubscriptionMessage(openId, templateId, page, data);
35 + }
36 +
29 } 37 }
......
...@@ -63,6 +63,13 @@ service MealOrderServiceRpc { ...@@ -63,6 +63,13 @@ service MealOrderServiceRpc {
63 rpc batchCreateDinerMealSuspensionRecords(BatchCreateDinerMealSuspensionRecordsRpcRequest) returns (BatchCreateDinerMealSuspensionRecordsRpcResponse); 63 rpc batchCreateDinerMealSuspensionRecords(BatchCreateDinerMealSuspensionRecordsRpcRequest) returns (BatchCreateDinerMealSuspensionRecordsRpcResponse);
64 rpc updateDinerMealSuspensionRecord(UpdateDinerMealSuspensionRecordRpcRequest) returns (UpdateDinerMealSuspensionRecordRpcResponse); 64 rpc updateDinerMealSuspensionRecord(UpdateDinerMealSuspensionRecordRpcRequest) returns (UpdateDinerMealSuspensionRecordRpcResponse);
65 rpc deleteDinerMealSuspensionRecordsByIds(DeleteDinerMealSuspensionRecordsByIdsRpcRequest) returns (DeleteDinerMealSuspensionRecordsRpcResponse); 65 rpc deleteDinerMealSuspensionRecordsByIds(DeleteDinerMealSuspensionRecordsByIdsRpcRequest) returns (DeleteDinerMealSuspensionRecordsRpcResponse);
66 +
67 + // 订餐订阅消息相关服务
68 + rpc CreateOrderSubscriptionMessage (CreateOrderSubscriptionMessageRpcRequest) returns (CreateOrderSubscriptionMessageRpcResponse) {}
69 + rpc GetUserOrderSubscriptionMessageHistory (GetUserOrderSubscriptionMessageHistoryRpcRequest) returns (GetUserOrderSubscriptionMessageHistoryRpcResponse) {}
70 + rpc BatchDeleteOrderSubscriptionMessagesByIds (BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
71 + rpc BatchPhysicalDeleteOrderSubscriptionMessagesByIds (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
72 + rpc QueryPendingOrderSubscriptionMessages (QueryPendingOrderSubscriptionMessagesRpcRequest) returns (QueryPendingOrderSubscriptionMessagesRpcResponse) {}
66 } 73 }
67 74
68 enum MealOrderOrderMethodEnum { 75 enum MealOrderOrderMethodEnum {
...@@ -955,3 +962,76 @@ message BatchCreateDinerMealSuspensionRecordsRpcResponse { ...@@ -955,3 +962,76 @@ message BatchCreateDinerMealSuspensionRecordsRpcResponse {
955 bool isCreated = 1; 962 bool isCreated = 1;
956 repeated int32 ids = 2; 963 repeated int32 ids = 2;
957 } 964 }
965 +
966 +// 订餐订阅消息相关消息定义
967 +message CreateOrderSubscriptionMessageRpcRequest {
968 + int32 enterpriseId = 1;
969 + int32 dinerId = 2;
970 + string openId = 3;
971 + string templateId = 4;
972 + int64 orderPeriodStartDate = 5; // Unix 时间戳(毫秒),精确到秒
973 + string jumpPath = 6; // 跳转路径(小程序 path),可选
974 + int64 orderPeriodEndDate = 7; // Unix 时间戳(毫秒),精确到秒,可选
975 +}
976 +
977 +message CreateOrderSubscriptionMessageRpcResponse {
978 + int64 id = 1;
979 +}
980 +
981 +message GetUserOrderSubscriptionMessageHistoryRpcRequest {
982 + int32 enterpriseId = 1;
983 + string openId = 2;
984 + int64 orderPeriodStartDate = 3; // Unix 时间戳(毫秒),精确到秒,可选,如果传入则查询大于等于该值的记录
985 +}
986 +
987 +message OrderSubscriptionMessageRpcResponse {
988 + int64 id = 1;
989 + int64 orderPeriodStartDate = 2; // Unix 时间戳(毫秒),精确到秒
990 + int64 createdAt = 3; // Unix 时间戳(毫秒)
991 + bool isDeleted = 4;
992 + string jumpPath = 5; // 跳转路径(小程序 path),可选
993 + int64 orderPeriodEndDate = 6; // Unix 时间戳(毫秒),精确到秒,可选
994 +}
995 +
996 +message GetUserOrderSubscriptionMessageHistoryRpcResponse {
997 + repeated OrderSubscriptionMessageRpcResponse response = 1;
998 +}
999 +
1000 +message BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest {
1001 + int32 enterpriseId = 1;
1002 + repeated int64 ids = 2; // 要删除的记录ID列表
1003 +}
1004 +
1005 +message BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse {
1006 + int32 affectedRows = 1; // 受影响的行数
1007 +}
1008 +
1009 +message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest {
1010 + int32 enterpriseId = 1;
1011 + repeated int64 ids = 2; // 要删除的记录ID列表
1012 +}
1013 +
1014 +message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse {
1015 + int32 affectedRows = 1; // 受影响的行数
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 +}
......
...@@ -75,6 +75,7 @@ grpc.meizhongyihe-service.port=3040 ...@@ -75,6 +75,7 @@ grpc.meizhongyihe-service.port=3040
75 75
76 miniprogram.appId=wx40ec496d73b58ec6 76 miniprogram.appId=wx40ec496d73b58ec6
77 miniprogram.appSecret=5b309517bf918abf760b2195e91b99f3 77 miniprogram.appSecret=5b309517bf918abf760b2195e91b99f3
78 +miniprogram.orderReminderTemplateId=xxx
78 79
79 business-config.hisCustomerQueryUrl = http://localhost:8903/hiscustomers/current/query 80 business-config.hisCustomerQueryUrl = http://localhost:8903/hiscustomers/current/query
80 business-config.clientCustomerQueryUrl = http://localhost:8903/clientcustomers/current/query 81 business-config.clientCustomerQueryUrl = http://localhost:8903/clientcustomers/current/query
......