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 +---
2 +alwaysApply: false
3 +---
4 +# 项目技术栈
5 +
6 +## 核心框架
7 +- **Spring Boot 2.5.12**:Web 应用框架
8 +- **Java 17**:编程语言
9 +- **Gradle**:构建工具
10 +
11 +## 通信协议
12 +- **gRPC**:微服务间通信,使用 Protobuf 定义接口
13 +- **REST API**:对外 HTTP 接口,使用 Knife4j/Swagger 文档
14 +
15 +## 数据存储
16 +- **Redis (Lettuce)**:缓存、分布式锁、Session 存储
17 +- **Redisson**:分布式锁实现
18 +- **阿里云 OSS**:文件存储
19 +
20 +## 认证授权
21 +- **Sa-Token + JWT**:用户认证和权限管理
22 +
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**:实时通信
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,6 @@ public interface ConfigConstants { ...@@ -81,5 +81,6 @@ 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}";
84 85
85 } 86 }
......
...@@ -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 }
......
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 + * @param openId 用户 OpenID
14 + * @param page 点击跳转页面路径,例如:pages/order/index?cycleId=202501
15 + * @param reminderContent 提醒内容,例如:"下次订餐即将开启"
16 + * @param orderTime 订餐时间,例如:"周一 09:00 至 周三 12:00"
17 + * @param tips 温馨提示,例如:"请点击卡片,准时进入小程序完成预订"
18 + * @return 发送结果
19 + */
20 + SendSubscriptionMessageResponse sendOrderReminder(String openId, String page,
21 + String reminderContent, String orderTime, String tips);
22 +}
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 }
......
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.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 +@Slf4j
22 +@Service
23 +@RequiredArgsConstructor(onConstructor = @__(@Autowired))
24 +public class OrderReminderServiceImpl implements OrderReminderService {
25 +
26 + private final WxMiniProgramService wxMiniProgramService;
27 +
28 + @Value(MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID)
29 + private String templateId;
30 +
31 + @Override
32 + public SendSubscriptionMessageResponse sendOrderReminder(String openId,
33 + String page,
34 + String reminderContent,
35 + String orderTime,
36 + String tips) {
37 + log.info("Sending order reminder to openId: {}, page: {}", openId, page);
38 +
39 + // 将业务参数转换为微信模板数据格式
40 + // 根据文档,模板字段为:thing1 (提醒内容), time2 (订餐时间), thing3 (温馨提示)
41 + Map<String, Object> data = new HashMap<>();
42 +
43 + // thing1: 提醒内容
44 + Map<String, String> thing1Value = new HashMap<>();
45 + thing1Value.put("value", reminderContent);
46 + data.put("thing1", thing1Value);
47 +
48 + // time2: 订餐时间
49 + Map<String, String> time2Value = new HashMap<>();
50 + time2Value.put("value", orderTime);
51 + data.put("time2", time2Value);
52 +
53 + // thing3: 温馨提示
54 + Map<String, String> thing3Value = new HashMap<>();
55 + thing3Value.put("value", tips);
56 + data.put("thing3", thing3Value);
57 +
58 + try {
59 + SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage(
60 + openId, templateId, page, data);
61 +
62 + if (response.getErrcode() != null && response.getErrcode() == 0) {
63 + log.info("Order reminder sent successfully to openId: {}", openId);
64 + } else {
65 + log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}",
66 + openId, response.getErrcode(), response.getErrmsg());
67 + }
68 +
69 + return response;
70 + } catch (Exception e) {
71 + log.error("Failed to send order reminder to openId: {}", openId, e);
72 + throw e;
73 + }
74 + }
75 +}
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 }
......
...@@ -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
......