Merge branch 'feat/nongxin-notifications' into 'master'
Feat/nongxin notifications See merge request !15
Showing
20 changed files
with
1403 additions
and
0 deletions
.cursor/.skills.mdc
0 → 100644
| 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 |
docs/CACHE_USAGE.md
0 → 100644
| 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 | +- ❌ 频繁变更的业务数据 |
docs/wechat_message_push.md
0 → 100644
| 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 | } | ... | ... |
This diff is collapsed. Click to expand it.
| 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 | ... | ... |
-
Please register or login to post a comment