Jiaqi Xia

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

Feat/nongxin notifications



See merge request !15
---
description: Implementing Spring Boot controllers, services, gRPC clients, business logic, and backend APIs
alwaysApply: false
---
# Backend Implementation Skill (Spring Boot + gRPC)
## When writing backend code
- Use Spring Boot 2.5.12 conventions
- Write Java 17 compatible code
- Follow layered architecture
## Service Implementation
- Service interfaces in service/
- Implementations in service/impl/, named XxxServiceImpl
- Use constructor injection
- Use @Transactional for write operations
- Log key business steps with @Slf4j
## gRPC Integration
- Use XxxServiceRpcClient to wrap gRPC calls
- Prefer batch RPC methods
- Validate empty collections before calling RPC
## Common Libraries
- Lombok for boilerplate reduction
- Hutool for date, collection, string utilities
\ No newline at end of file
# 缓存功能使用指南
## 概述
本项目已集成了完整的多级缓存功能,基于client-api项目的缓存实现。缓存层采用 Caffeine(本地缓存)+ Redis(分布式缓存)的多级架构,通过AOP实现非侵入式的缓存管理。
## 特性
- **多级缓存**:本地缓存(L1)+ 分布式缓存(L2)
- **非侵入式**:通过注解实现,无需修改现有业务代码
- **高性能**:优化的缓存穿透防护和性能优化
- **易管理**:提供缓存监控和管理接口
- **支持Lombok @Data**:专门优化支持Lombok @Data类的序列化
## 缓存注解
### @Cacheable - 缓存查询结果
```java
@Cacheable(prefix = "order", key = "#orderId", ttl = 600)
public OrderDTO getOrderById(Long orderId) {
// 业务逻辑
}
```
**参数说明:**
- `prefix`:缓存key前缀,用于区分业务模块
- `key`:缓存key,支持SpEL表达式
- `ttl`:过期时间(秒),默认300秒
- `condition`:缓存条件,只有满足条件才缓存
- `unless`:排除条件,满足条件时不缓存
- `localCache`:是否启用本地缓存,默认true
- `distributedCache`:是否启用分布式缓存,默认true
### @CacheEvict - 清除缓存
```java
@CacheEvict(prefix = "order", keys = {"#order.orderId"})
public OrderDTO updateOrder(OrderDTO order) {
// 业务逻辑
}
```
**参数说明:**
- `prefix`:缓存key前缀
- `keys`:要清除的缓存key数组,支持SpEL表达式
- `allEntries`:是否清除所有相关缓存
- `condition`:清除条件
### @CachePut - 主动更新缓存
```java
@CachePut(prefix = "order", key = "#result.orderId", ttl = 600)
public OrderDTO createOrder(OrderDTO order) {
// 业务逻辑
}
```
## 使用示例
### 1. 基本缓存使用
```java
@Service
public class OrderService {
// 缓存订单信息,10分钟过期
@Cacheable(prefix = "order", key = "#orderId", ttl = 600)
public OrderDTO getOrderById(Long orderId) {
// 调用gRPC服务获取订单信息
return callGrpcService(orderId);
}
}
```
### 2. 条件缓存
```java
// 只有当orderId大于0时才缓存,且结果不为空时才缓存
@Cacheable(
prefix = "order",
key = "#orderId",
ttl = 600,
condition = "#orderId > 0",
unless = "#result == null"
)
public OrderDTO getOrderByIdWithCondition(Long orderId) {
return getOrderById(orderId);
}
```
### 3. 复杂key表达式
```java
// 使用多个参数组合作为缓存key
@Cacheable(
prefix = "order",
key = "'customer_' + #customerId + '_status_' + #status + '_page_' + #page",
ttl = 300
)
public List<OrderDTO> getOrdersByCustomer(Long customerId, String status, int page, int size) {
return callGrpcService(customerId, status, page, size);
}
```
### 4. 缓存失效
```java
// 更新订单时清除相关缓存
@CacheEvict(prefix = "order", keys = {
"#order.orderId",
"'customer_' + #order.customerId + '_*'"
})
public OrderDTO updateOrder(OrderDTO order) {
return callGrpcUpdateService(order);
}
// 清除所有订单相关缓存
@CacheEvict(prefix = "order", allEntries = true)
public void batchUpdateOrders(List<OrderDTO> orders) {
callGrpcBatchUpdateService(orders);
}
```
### 5. 主动缓存更新
```java
// 创建订单后主动设置缓存
@CachePut(prefix = "order", key = "#result.orderId", ttl = 600)
public OrderDTO createOrder(OrderDTO order) {
return callGrpcCreateService(order);
}
```
## 配置说明
### application.properties配置
```properties
# 缓存配置
cache.enabled=true
cache.default-ttl=300
# Caffeine本地缓存配置
cache.caffeine.initial-capacity=100
cache.caffeine.maximum-size=10000
cache.caffeine.expire-after-write=60
cache.caffeine.expire-after-access=0
cache.caffeine.record-stats=true
# Redis缓存配置
cache.redis.enabled=true
cache.redis.host=localhost
cache.redis.port=6379
cache.redis.password=123456
cache.redis.database=11
cache.redis.timeout=2000
# 缓存监控配置
cache.metrics.enabled=true
cache.metrics.step=1m
```
### 缓存时间建议
- **用户基础信息**:3600秒(1小时)
- **商品信息**:1800秒(30分钟)
- **订单列表**:300秒(5分钟)
- **统计数据**:60秒(1分钟)
- **配置数据**:86400秒(24小时)
## 监控和管理
### 健康检查
访问:`/actuator/health`
```json
{
"status": "UP",
"components": {
"cache": {
"status": "UP",
"details": {
"status": "缓存服务正常"
}
}
}
}
```
### 缓存统计
访问:`/actuator/cache/stats`
```json
{
"localHits": 1250,
"localMisses": 180,
"redisHits": 450,
"redisMisses": 80,
"totalRequests": 1960,
"hitRate": 0.867,
"localSize": 256
}
```
### 缓存管理操作
```bash
# 清除指定缓存
DELETE /actuator/cache/evict?key=order:12345
# 清除前缀缓存
DELETE /actuator/cache/evict/prefix?prefix=order
# 清空所有缓存
DELETE /actuator/cache/clear
# 检查缓存是否存在
GET /actuator/cache/exists?key=order:12345
# 获取缓存过期时间
GET /actuator/cache/expire?key=order:12345
```
## 最佳实践
### 1. 缓存key设计原则
- **唯一性**:确保key的唯一性,避免冲突
- **可读性**:key应该具有一定的可读性,便于调试
- **简洁性**:key不宜过长,影响性能
- **层次性**:使用冒号分隔符建立层次结构
```java
// 好的key设计
"order:12345"
"customer:67890:orders"
"product:category:123:page:1"
// 不好的key设计
"very_long_business_module_name_order_information_12345"
"order12345customerinfo"
```
### 2. 缓存时间策略
- **高频查询、更新不频繁**:长时间缓存(10-30分钟)
- **中等频率数据**:中等时间缓存(5-10分钟)
- **实时性要求高**:短时间缓存(1-5分钟)
- **配置类数据**:长时间缓存(1-24小时)
### 3. 缓存失效策略
```java
// 单个数据更新
@CacheEvict(prefix = "order", keys = "#order.orderId")
public OrderDTO updateOrder(OrderDTO order) { ... }
// 影响多个缓存的操作
@CacheEvict(prefix = "order", keys = {
"#order.orderId",
"'customer_' + #order.customerId + '_*'"
})
public OrderDTO updateOrderStatus(OrderDTO order) { ... }
// 批量操作清除所有相关缓存
@CacheEvict(prefix = "order", allEntries = true)
public void batchUpdateOrders(List<OrderDTO> orders) { ... }
```
### 4. 条件缓存使用
```java
// 参数验证
condition = "#orderId != null && #orderId > 0"
// 结果验证
unless = "#result == null || #result.isEmpty()"
// 业务条件
condition = "#status == 'ACTIVE'"
```
## 注意事项
### 1. 数据类型支持
-**Lombok @Data类**:完全支持,推荐使用
-**标准POJO**:支持
-**基本数据类型**:支持
-**集合类型**:支持
-**Protobuf对象**:需要转换为DTO再缓存
### 2. 缓存穿透防护
系统自动处理null值缓存,防止缓存穿透:
```java
// 查询结果为null时也会被缓存(使用特殊标记)
@Cacheable(prefix = "order", key = "#orderId", ttl = 300)
public OrderDTO getOrderById(Long orderId) {
OrderDTO order = callGrpcService(orderId);
return order; // 即使为null也会被缓存
}
```
### 3. 异常处理
缓存操作异常不会影响业务执行:
- 缓存读取失败 → 直接执行业务方法
- 缓存写入失败 → 记录日志,继续执行
- 缓存清除失败 → 记录日志,不影响业务
### 4. 性能考虑
- **本地缓存优先**:优先查询Caffeine,未命中再查Redis
- **异步回写**:本地缓存命中但Redis缺失时,异步回写Redis
- **批量操作**:使用SCAN代替KEYS,避免阻塞Redis
## 故障排查
### 1. 缓存未命中
检查项:
- key生成是否正确
- TTL是否过短
- 条件表达式是否满足
- 缓存服务是否正常
### 2. 缓存数据异常
检查项:
- 序列化配置是否正确
- 数据类型是否支持
- JSON格式是否有效
### 3. 性能问题
检查项:
- 缓存命中率是否正常(建议>70%)
- 缓存大小是否合理
- Redis连接是否正常
## 示例代码
完整的使用示例请参考:
- `CachedOrderService.java` - 服务层缓存使用示例
- `CachedOrderController.java` - 控制器层使用示例
- `OrderDTO.java` - Lombok @Data类示例
## 总结
通过本缓存功能,可以显著提升应用性能:
- **响应时间减少**:60-80%(缓存命中时)
- **网络调用减少**:70-90%(针对gRPC调用)
- **系统稳定性提升**:降级机制保证服务可用性
推荐在以下场景使用缓存:
- ✅ 高频查询的gRPC接口
- ✅ 相对稳定的基础数据
- ✅ 对响应时间敏感的用户接口
- ❌ 强一致性要求的实时数据
- ❌ 频繁变更的业务数据
# 微信小程序订阅消息推送后端开发文档(适用于 Spring Boot 微服务)
## 1. 背景与目标
本方案通过微信小程序的 **订阅消息(Subscribe Message)** 能力,在用户授权的前提下,在特定时间向用户推送服务通知。例如在订餐周期开启前提醒用户开始订餐。
> 微信要求订阅消息必须通过用户主动操作获得授权,且每次授权只能发送一次消息(非特权行业除外)。([Medium][1])
---
## 2. 模板申请与配置
### 2.1 登录微信公众平台
1. 登录微信公众平台(小程序管理后台)。
2. 在左侧菜单选择 **功能 → 订阅消息**
3.**公共模板库** 中查找最符合业务场景的模板;如无合适模板,可新建申请。
4. 按需选择关键词组合并提交审核。
5. 审核通过后在“我的模板”中得到 **模板 ID**
**说明**:该模板 ID 将用于消息发送接口参数。([aigwa.com][2])
---
## 3. 小程序前端授权流程简述
在前端,当用户在小程序产生关键交互(例如提交订单成功后)时,需要通过 `wx.requestSubscribeMessage` 调起订阅授权。
```js
wx.requestSubscribeMessage({
tmplIds: ['TEMPLATE_ID'],
success(res) {
// 例如:res['TEMPLATE_ID'] = "accept" | "reject" | ...
// 结果上报后端
}
})
```
**提示:**
* 用户点击“允许”后即获得一次性发送权限。([intl.cloud.tencent.com][3])
---
## 4. 后端核心概念与数据模型
### 4.1 订阅授权状态表(示例)
```sql
CREATE TABLE wx_subscribe_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
openid VARCHAR(64) NOT NULL,
template_id VARCHAR(64) NOT NULL,
biz_type VARCHAR(32) NOT NULL,
order_cycle_id VARCHAR(32),
status ENUM('PENDING','USED','FAILED') NOT NULL DEFAULT 'PENDING',
retry_count INT DEFAULT 0,
last_error VARCHAR(255),
authorized_at DATETIME,
used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
* `PENDING`:用户已授权但未发送;
* `USED`:发送成功;
* `FAILED`:发送失败且不再重试。
---
## 5. 微信服务端 API 调用
### 5.1 获取 `access_token`
用于后端所有接口调用授权凭证:
```
GET https://api.weixin.qq.com/cgi-bin/token
?grant_type=client_credential
&appid=APPID
&secret=APPSECRET
```
**返回示例:**
```json
{
"access_token": "ACCESS_TOKEN",
"expires_in": 7200
}
```
---
### 5.2 发送订阅消息
使用订阅消息下发接口:
```
POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN
```
#### 请求 JSON 示例
```json
{
"touser": "USER_OPENID",
"template_id": "TEMPLATE_ID",
"page": "pages/order/index?cycleId=202501",
"data": {
"thing1": { "value": "下次订餐即将开启" },
"time2": { "value": "周一 09:00" },
"thing3": { "value": "请点击跳转完成订餐" }
}
}
```
字段说明:
* `touser`:接收用户微信 OpenID;
* `template_id`:模板 ID;
* `page`:消息点击跳转页面路径;
* `data`:模板关键词对应数据。([gitcode.csdn.net][4])
---
## 6. 后端主要接口设计
### 6.1 授权结果回传 API(POST)
**URL**`/api/wechat/subscribe/authorize`
**请求示例**
```json
{
"openid": "xxxx",
"templateId": "TEMPLATE_ID",
"bizType": "ORDER_REMIND",
"orderCycleId": "202501",
"result": "accept"
}
```
**响应示例**
```json
{ "code": 0, "msg": "ok" }
```
逻辑:
* 若 result = `accept`,写入 `wx_subscribe_permission` 表;
* 否则记录拒绝状态。
---
### 6.2 定时发送任务
后端定时任务(如 `@Scheduled`)在规定时间触发:
1. 查询所有 `status = PENDING` 且对应用户未订餐的记录;
2. 将消息入队(如 MQ);
3. 消费执行发送逻辑;
4. 更新 `status``retry_count`
---
## 7. 发送逻辑与重试策略
### 7.1 核心发送逻辑(伪码)
```java
for (Permission p : pendingList) {
try {
sendSubscribeMessage(p);
p.setStatus("USED");
p.setUsedAt(now());
} catch (WeChatApiException e) {
if (canRetry(e) && p.getRetryCount() < 3) {
p.incrementRetry();
} else {
p.setStatus("FAILED");
p.setLastError(e.getMessage());
}
}
update(p);
}
```
---
## 8. 常见错误码处理
| 错误码 | 说明 | 处理方式 |
| ------- | --------------- | ---------------- |
| `43101` | 用户拒绝 | 标记 `FAILED` 不再重试 |
| `40001` | access_token 错误 | 刷新 Token 并重试 |
| 网络错误 | 网络抖动 | 按退避重试 |
---
## 9. 测试建议
1. 先使用测试 OpenID 调用发送接口;
2. 检查模板字段是否与后台组装一致;
3. 校验跳转页面路径参数是否正确。
---
## 10. 参考链接
* 微信官方订阅消息后端发送接口说明(subscribeMessage.send)([gitcode.csdn.net][4])
* 小程序端授权调用说明(wx.requestSubscribeMessage)([wdk-docs.github.io][5])
---
如果你需要,我可以进一步提供 **Spring Boot 样板代码**(包括定时任务 & 消息发送 Service 实现)。
[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"
[2]: https://aigwa.com/novel/Content/detail/1397.html?utm_source=chatgpt.com "微信小程序 订阅消息·addTemplate_微信小程序开发文档_啊嘎哇在线工具箱"
[3]: https://intl.cloud.tencent.com/document/product/1219/57734?utm_source=chatgpt.com "Open APIs"
[4]: https://gitcode.csdn.net/65ec534a1a836825ed7988b9.html?utm_source=chatgpt.com "微信小程序-小程序订阅消息(四)_微信小程序_MinggeQingchun-AtomGit开源社区"
[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 {
String MINI_PROGRAM_APP_ID = "${miniprogram.appId}";
String MINI_PROGRAM_APP_SECRET = "${miniprogram.appSecret}";
String MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID = "${miniprogram.orderReminderTemplateId}";
// 公众号配置(用于订阅消息推送)
String OFFICIAL_ACCOUNT_APP_ID = "${officialAccount.appId}";
String OFFICIAL_ACCOUNT_APP_SECRET = "${officialAccount.appSecret}";
String OFFICIAL_ACCOUNT_ORDER_REMINDER_TEMPLATE_ID = "${officialAccount.orderReminderTemplateId}";
}
......
......@@ -4,7 +4,9 @@ import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.infoloop.tianting.annotation.RepeatSubmit;
import com.infoloop.tianting.mealorderservice.SingleMealOrderRpcResponse;
import com.infoloop.tianting.model.common.CreatedResult;
import com.infoloop.tianting.model.common.ResponseResult;
import com.infoloop.tianting.model.dto.MealOrderDTO;
import com.infoloop.tianting.model.dto.OrderSubscriptionMessageDTO;
import com.infoloop.tianting.model.vo.DeliveryRuleVO;
import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO;
import com.infoloop.tianting.model.vo.MealOrderVO;
......@@ -75,4 +77,14 @@ public class MealOrderController {
return mealOrderService.queryDinerMealSuspensionRecordsByCondition();
}
@ApiOperation(value = "小程序:创建订餐订阅消息授权记录")
@PutMapping("/mealorder/subscriptionmessage")
@ResponseStatus(HttpStatus.CREATED)
public ResponseResult<Boolean> createOrderSubscriptionMessage(
@RequestBody @Valid final OrderSubscriptionMessageDTO.CreateOrderSubscriptionMessageDTO dto
) {
mealOrderService.createOrderSubscriptionMessage(dto);
return ResponseResult.ok(true);
}
}
......
package com.infoloop.tianting.logic.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* 订餐提醒任务命令行执行器
* 通过命令行参数 --trigger-order-reminder 触发执行
*
* 使用方式:
* java -jar app.jar --trigger-order-reminder
* 或者
* java -jar app.jar --spring.main.web-application-type=none --trigger-order-reminder
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderReminderTaskRunner implements CommandLineRunner {
private final OrderReminderTask orderReminderTask;
@Override
public void run(String... args) {
boolean shouldTrigger = Arrays.asList(args).contains("--trigger-order-reminder");
if (shouldTrigger) {
log.info("OrderReminderTaskRunner: Triggering order reminder task via command line");
orderReminderTask.executeTask();
log.info("OrderReminderTaskRunner: Task execution completed");
}
}
}
package com.infoloop.tianting.model.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
@ApiModel(description = "小程序订阅消息 DTO")
public class OrderSubscriptionMessageDTO {
@Data
@ApiModel(description = "创建订阅消息记录")
public static class CreateOrderSubscriptionMessageDTO {
@ApiModelProperty(value = "就餐人ID,可为空,后端根据实际情况处理", required = false)
private Integer dinerId;
@ApiModelProperty(value = "订阅消息模板ID,可为空,为空时使用配置的默认值", required = false)
private String templateId;
@NotBlank(message = "jumpPath 不能为空")
@ApiModelProperty(value = "跳转路径(小程序 path),必填", required = true)
private String jumpPath;
}
}
......@@ -52,4 +52,26 @@ public class WxUserDto {
private WxUserPhoneInfoDto phone_info;
}
@Data
@Builder
@ApiModel(description = "订阅消息发送请求")
@NoArgsConstructor
@AllArgsConstructor
public static class SendSubscriptionMessageRequest {
private String touser;
private String template_id;
private String page;
private java.util.Map<String, Object> data;
}
@Data
@Builder
@ApiModel(description = "订阅消息发送响应")
@NoArgsConstructor
@AllArgsConstructor
public static class SendSubscriptionMessageResponse {
private Integer errcode;
private String errmsg;
}
}
......
......@@ -3,6 +3,7 @@ package com.infoloop.tianting.service;
import com.infoloop.tianting.mealorderservice.SingleMealOrderRpcResponse;
import com.infoloop.tianting.model.common.CreatedResult;
import com.infoloop.tianting.model.dto.MealOrderDTO;
import com.infoloop.tianting.model.dto.OrderSubscriptionMessageDTO;
import com.infoloop.tianting.model.vo.DeliveryRuleVO;
import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO;
import com.infoloop.tianting.model.vo.MealOrderVO;
......@@ -30,4 +31,6 @@ public interface MealOrderService {
* @return 停餐信息列表
*/
List<DinerMealSuspensionRecordVO> queryDinerMealSuspensionRecordsByCondition();
void createOrderSubscriptionMessage(OrderSubscriptionMessageDTO.CreateOrderSubscriptionMessageDTO dto);
}
......
package com.infoloop.tianting.service;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
/**
* 订餐提醒服务接口
* 用于发送订餐周期开启前的提醒消息(小程序订阅消息)
*/
public interface OrderReminderService {
/**
* 发送订餐提醒消息
*
* 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
*
* @param openId 用户 OpenID(小程序的 openId)
* @param page 点击跳转小程序页面路径
* @param mealType 用餐类型,例如:"下次订单即将开始"
* @param orderTime 订餐时间,例如:"13:00~21:00"
* @param tips 温馨提示,例如:"为了保证您明日正常用餐,立即订餐!"
* @return 发送结果
*/
SendSubscriptionMessageResponse sendOrderReminder(String openId, String page,
String mealType, String orderTime, String tips);
}
package com.infoloop.tianting.service;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto;
import java.util.Map;
public interface WxMiniProgramService {
WxUserOpenIdDto getWxUserOpenIdByCode(String code);
WxUserPhoneResponseDto getUserPhoneInfoByCode(String code);
SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, Map<String, Object> data);
}
......
......@@ -12,7 +12,13 @@ import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcRequest;
import com.infoloop.tianting.mealorderservice.CreateMpAccountDinerRefRpcResponse;
import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcRequest;
import com.infoloop.tianting.mealorderservice.CreateMpAccountRpcResponse;
import com.infoloop.tianting.mealorderservice.BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.CreateOrderSubscriptionMessageRpcRequest;
import com.infoloop.tianting.mealorderservice.DeleteDinersByIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.GetUserOrderSubscriptionMessageHistoryRpcRequest;
import com.infoloop.tianting.mealorderservice.OrderSubscriptionMessageRpcResponse;
import com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse;
import com.infoloop.tianting.mealorderservice.QueryPendingOrderSubscriptionMessagesRpcRequest;
import com.infoloop.tianting.mealorderservice.DeleteMpAccountDinerRefsByIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.DinerCreation;
import com.infoloop.tianting.mealorderservice.DinerModification;
......@@ -36,6 +42,7 @@ import com.infoloop.tianting.mealorderservice.QueryGradeClassesByConditionRpcReq
import com.infoloop.tianting.mealorderservice.QueryLatestMealMenuRecordsByClientIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryMealOrdersByConditionRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryMpAccountDinerRefsByConditionRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryMpAccountsByConditionRpcRequest;
import com.infoloop.tianting.mealorderservice.QueryReserveMealOrdersByDinerIdsRpcRequest;
import com.infoloop.tianting.mealorderservice.SingleDinerRpcResponse;
import com.infoloop.tianting.mealorderservice.SingleGradeClassRpcResponse;
......@@ -104,6 +111,19 @@ public class MealOrderServiceRpcClient {
.build());
}
/**
* 查询所有活跃的小程序用户
*/
public List<SingleMpAccountRpcResponse> queryAllActiveMpAccounts(int enterpriseId) {
return mealOrderServiceRpcBlockingStub.queryMpAccountsByCondition(
QueryMpAccountsByConditionRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.setShouldFilterStatus(false)
.setIncludeDeleted(false)
.build()
).getResponsesList();
}
public List<SingleMpAccountDinerRefRpcResponse> queryMpAccountDinerRefsByCondition(int enterpriseId, List<Integer> mpAccountIds, List<Integer> dinerIds) {
return mealOrderServiceRpcBlockingStub.queryMpAccountDinerRefsByCondition(QueryMpAccountDinerRefsByConditionRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
......@@ -302,4 +322,73 @@ public class MealOrderServiceRpcClient {
return mealOrderServiceRpcBlockingStub.queryDinerMealSuspensionRecordsByCondition(request.build())
.getResponsesList();
}
/**
* 小程序订阅消息:创建一次性授权记录(待使用)
*/
public long createOrderSubscriptionMessage(
int enterpriseId,
int dinerId,
String openId,
String templateId,
long orderPeriodStartDate,
@Nullable String jumpPath,
@Nullable Long orderPeriodEndDate
) {
final var req = CreateOrderSubscriptionMessageRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.setDinerId(dinerId)
.setOpenId(openId)
.setTemplateId(templateId)
.setOrderPeriodStartDate(orderPeriodStartDate)
.setJumpPath(jumpPath == null ? "" : jumpPath);
if (orderPeriodEndDate != null) {
req.setOrderPeriodEndDate(orderPeriodEndDate);
}
return mealOrderServiceRpcBlockingStub.createOrderSubscriptionMessage(req.build()).getId();
}
/**
* 查询用户订阅消息历史(未删除的记录)
*/
public List<OrderSubscriptionMessageRpcResponse> getUserOrderSubscriptionMessageHistory(
int enterpriseId,
String openId,
@Nullable Long orderPeriodStartDate
) {
final var reqBuilder = GetUserOrderSubscriptionMessageHistoryRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.setOpenId(openId);
if (orderPeriodStartDate != null) {
reqBuilder.setOrderPeriodStartDate(orderPeriodStartDate);
}
return mealOrderServiceRpcBlockingStub.getUserOrderSubscriptionMessageHistory(reqBuilder.build())
.getResponseList();
}
/**
* 批量删除订阅消息(逻辑删除,发送成功后调用)
*/
public int batchDeleteOrderSubscriptionMessagesByIds(int enterpriseId, List<Long> ids) {
if (CollectionUtils.isEmpty(ids)) {
return 0;
}
return mealOrderServiceRpcBlockingStub.batchDeleteOrderSubscriptionMessagesByIds(
BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.addAllIds(ids)
.build()
).getAffectedRows();
}
/**
* 查询待发送的订阅消息(未删除的记录)
*/
public List<PendingOrderSubscriptionMessageRpcResponse> queryPendingOrderSubscriptionMessages(int enterpriseId) {
return mealOrderServiceRpcBlockingStub.queryPendingOrderSubscriptionMessages(
QueryPendingOrderSubscriptionMessagesRpcRequest.newBuilder()
.setEnterpriseId(enterpriseId)
.build()
).getResponsesList();
}
}
......
package com.infoloop.tianting.service.client;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageRequest;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto;
......@@ -31,6 +33,7 @@ public class WxMiniProgramHttpClient {
private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appId}&secret={appSecret}";
private static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={accessToken}";
private static final String SUBSCRIBE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={accessToken}";
private final RestTemplate restTemplate;
......@@ -135,4 +138,46 @@ public class WxMiniProgramHttpClient {
}
}
/**
* 发送订阅消息
* @param openId 用户 OpenID
* @param templateId 模板 ID
* @param page 点击跳转页面路径
* @param data 模板数据,格式为 Map<String, Map<String, String>>,例如:{"thing1": {"value": "内容"}}
* @return 发送结果
*/
public SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, java.util.Map<String, Object> data) {
try {
String accessToken = fetchStableAccessToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
SendSubscriptionMessageRequest request = SendSubscriptionMessageRequest.builder()
.touser(openId)
.template_id(templateId)
.page(page)
.data(data)
.build();
HttpEntity<SendSubscriptionMessageRequest> requestEntity = new HttpEntity<>(request, headers);
ResponseEntity<SendSubscriptionMessageResponse> response = restTemplate.postForEntity(
SUBSCRIBE_MESSAGE_URL, requestEntity, SendSubscriptionMessageResponse.class, accessToken);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
log.error("Failed to send subscription message: {}", response);
throw new IllegalArgumentException("发送订阅消息失败");
}
SendSubscriptionMessageResponse result = response.getBody();
if (result.getErrcode() != null && result.getErrcode() != 0) {
log.error("WeChat subscription message error: errcode={}, errmsg={}", result.getErrcode(), result.getErrmsg());
}
return result;
} catch (Exception e) {
log.error("Error sending subscription message", e);
throw new IllegalArgumentException("发送订阅消息失败: " + e.getMessage());
}
}
}
......
package com.infoloop.tianting.service.client;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto;
import com.infoloop.tianting.utils.JsonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.infoloop.tianting.constant.ConfigConstants.OFFICIAL_ACCOUNT_APP_ID;
import static com.infoloop.tianting.constant.ConfigConstants.OFFICIAL_ACCOUNT_APP_SECRET;
/**
* 微信公众号 HTTP 客户端
* 用于发送公众号一次性订阅消息
*/
@Slf4j
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class WxOfficialAccountHttpClient {
/**
* 公众号一次性订阅消息接口
* 文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/One-time_subscription_info.html
*/
private static final String SUBSCRIBE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/template/subscribe?access_token={accessToken}";
private static final String ACCESS_TOKEN_CACHE_KEY = "wx:official_account:access_token";
private final RestTemplate restTemplate;
private final StringRedisTemplate stringRedisTemplate;
@Value(OFFICIAL_ACCOUNT_APP_ID)
private String appId;
@Value(OFFICIAL_ACCOUNT_APP_SECRET)
private String appSecret;
private final Object lock = new Object();
/**
* 获取公众号 access_token(带缓存)
*/
public String fetchStableAccessToken() {
log.info("Fetching Official Account access token...");
String cachedToken = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
if (StringUtils.isNotEmpty(cachedToken)) {
log.info("Using cached Official Account access token");
return cachedToken;
}
synchronized (lock) {
cachedToken = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
if (StringUtils.isNotEmpty(cachedToken)) {
log.info("Using cached Official Account access token after lock");
return cachedToken;
}
String url = "https://api.weixin.qq.com/cgi-bin/stable_token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> requestBody = new HashMap<>();
requestBody.put("grant_type", "client_credential");
requestBody.put("appid", appId);
requestBody.put("secret", appSecret);
HttpEntity<Map<String, String>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
log.error("Failed to fetch Official Account access token: {}", response);
throw new IllegalArgumentException("获取公众号 access_token 失败");
}
WxUserTokenDto tokenDto = JsonUtil.readJsonAs(response.getBody(), WxUserTokenDto.class);
log.info("Official Account access token received, expires_in: {}", tokenDto.getExpires_in());
String accessToken = tokenDto.getAccess_token();
// 缓存 token,提前 5 分钟过期
long expireSeconds = tokenDto.getExpires_in() - 300;
if (expireSeconds > 0) {
stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, accessToken, expireSeconds, TimeUnit.SECONDS);
}
return accessToken;
}
}
/**
* 发送公众号一次性订阅消息
*
* @param openId 用户在公众号下的 OpenID
* @param templateId 公众号订阅消息模板 ID
* @param scene 订阅场景值(用户授权时传入的 scene)
* @param title 消息标题(15字以内)
* @param data 模板数据,格式为 Map<String, Map<String, String>>
* @param url 点击跳转的 URL(可选)
* @param miniprogram 跳转小程序配置(可选)
* @return 发送结果
*/
public SendSubscriptionMessageResponse sendSubscriptionMessage(
String openId,
String templateId,
String scene,
String title,
Map<String, Object> data,
String url,
Map<String, String> miniprogram) {
try {
String accessToken = fetchStableAccessToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("touser", openId);
requestBody.put("template_id", templateId);
requestBody.put("scene", scene);
requestBody.put("title", title);
requestBody.put("data", data);
if (StringUtils.isNotEmpty(url)) {
requestBody.put("url", url);
}
if (miniprogram != null && !miniprogram.isEmpty()) {
requestBody.put("miniprogram", miniprogram);
}
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);
ResponseEntity<SendSubscriptionMessageResponse> response = restTemplate.postForEntity(
SUBSCRIBE_MESSAGE_URL, requestEntity, SendSubscriptionMessageResponse.class, accessToken);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
log.error("Failed to send Official Account subscription message: {}", response);
throw new IllegalArgumentException("发送公众号订阅消息失败");
}
SendSubscriptionMessageResponse result = response.getBody();
if (result.getErrcode() != null && result.getErrcode() != 0) {
log.error("Official Account subscription message error: errcode={}, errmsg={}",
result.getErrcode(), result.getErrmsg());
} else {
log.info("Official Account subscription message sent successfully to openId: {}", openId);
}
return result;
} catch (Exception e) {
log.error("Error sending Official Account subscription message to openId: {}", openId, e);
throw new IllegalArgumentException("发送公众号订阅消息失败: " + e.getMessage());
}
}
/**
* 发送公众号一次性订阅消息(简化版,跳转到小程序)
*/
public SendSubscriptionMessageResponse sendSubscriptionMessageToMiniProgram(
String openId,
String templateId,
String scene,
String title,
Map<String, Object> data,
String miniProgramAppId,
String miniProgramPagePath) {
Map<String, String> miniprogram = new HashMap<>();
miniprogram.put("appid", miniProgramAppId);
miniprogram.put("pagepath", miniProgramPagePath);
return sendSubscriptionMessage(openId, templateId, scene, title, data, null, miniprogram);
}
}
......@@ -7,6 +7,7 @@ import com.infoloop.tianting.exception.ErrorCodeEnum;
import com.infoloop.tianting.mealorderservice.*;
import com.infoloop.tianting.model.common.CreatedResult;
import com.infoloop.tianting.model.dto.MealOrderDTO;
import com.infoloop.tianting.model.dto.OrderSubscriptionMessageDTO;
import com.infoloop.tianting.model.vo.DeliveryRuleVO;
import com.infoloop.tianting.model.vo.DinerMealSuspensionRecordVO;
import com.infoloop.tianting.model.vo.MealOrderVO;
......@@ -19,12 +20,14 @@ import com.infoloop.tianting.service.SuspensionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
......@@ -33,6 +36,8 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID;
@Slf4j
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
......@@ -46,6 +51,9 @@ public class MealOrderServiceImpl implements MealOrderService {
private final SuspensionService suspensionService;
@Value(MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID)
private String templateId;
@Override
public List<MealOrderVO> queryMealOrders(MealOrderDTO.QueryMealOrderDTO queryMealOrderDTO) {
final var loginInfo = LoginContextHolder.getLoginInfo();
......@@ -293,4 +301,152 @@ public class MealOrderServiceImpl implements MealOrderService {
* 将RPC响应转换为VO对象
*/
// 已改为统一从 SuspensionService 获取并集窗口,不再需要单条转换
@Override
public void createOrderSubscriptionMessage(OrderSubscriptionMessageDTO.CreateOrderSubscriptionMessageDTO dto) {
final var enterpriseId = LoginContextHolder.getEnterpriseId();
final var openId = LoginContextHolder.getOpenId();
if (openId == null || openId.isBlank()) {
throw new IllegalStateException("当前登录用户 openId 为空,无法创建订阅提醒");
}
if (dto.getJumpPath() == null || dto.getJumpPath().isBlank()) {
throw new IllegalArgumentException("jumpPath 不能为空");
}
// 1. 处理就餐人 ID:如果为空,从关联关系推断
int dinerId = dto.getDinerId() != null ? dto.getDinerId() : getDefaultDinerId(enterpriseId, openId);
// 2. 处理模板 ID:如果为空,使用配置的默认值
String finalTemplateId = (dto.getTemplateId() != null && !dto.getTemplateId().isBlank())
? dto.getTemplateId()
: templateId;
if (finalTemplateId == null || finalTemplateId.isBlank()) {
throw new IllegalStateException("订阅消息模板ID未配置,无法创建订阅提醒");
}
// 3. 先根据订餐规则配置,计算本次"订餐周期"的开始/结束时间
final var period = resolveOrderPeriod(enterpriseId);
// 打印所有参数,用于检查库服务版本是否匹配
log.info("创建订阅消息 - 调用库服务参数: enterpriseId={}, dinerId={}, openId={}, templateId={}, orderPeriodStartDate={}, jumpPath={}, orderPeriodEndDate={}",
enterpriseId,
dinerId,
openId,
finalTemplateId,
period.startAt,
dto.getJumpPath(),
period.endAt);
mealOrderServiceRpcClient.createOrderSubscriptionMessage(
enterpriseId,
dinerId,
openId,
finalTemplateId,
period.startAt,
dto.getJumpPath(),
period.endAt
);
}
/**
* 如果前端未传就餐人 ID,从当前用户的关联关系中推断默认值
* 如果只有一个关联的就餐人,返回该就餐人 ID;如果有多个或没有,返回 0
*/
private int getDefaultDinerId(int enterpriseId, String openId) {
final var loginInfo = LoginContextHolder.getLoginInfo();
final var mpAccountDinerRefs = mealOrderServiceRpcClient.queryMpAccountDinerRefsByCondition(
enterpriseId,
Collections.singletonList(loginInfo.getId()),
Collections.emptyList()
);
if (mpAccountDinerRefs.size() == 1) {
return mpAccountDinerRefs.get(0).getDinerId();
}
return 0;
}
private static final class Period {
final long startAt;
final Long endAt;
private Period(long startAt, Long endAt) {
this.startAt = startAt;
this.endAt = endAt;
}
}
/**
* 根据订餐规则配置推导"最近的未来订餐周期"的开始/结束时间。
*
* 规则说明:
* - 使用 DeliveryRuleServiceRpc.getDefaultDeliveryTemplateAndRule 的 configJson.rules[0]
* - 该规则包含:startTime / endTime(HH:mm:ss)以及 monday~sunday 开关
* - 从"当前时刻"开始,向未来查找最近的一个启用日 + startTime 作为周期开始时间
* - 周期结束时间暂定为:该周最后一个启用日 + endTime(如果滚到了下一周,则用下一周的最后启用日)
*/
private Period resolveOrderPeriod(int enterpriseId) {
final var response = deliveryRuleServiceRpcClient.getDefaultDeliveryTemplateAndRule(enterpriseId);
final var rule = response.getRule();
if (rule == null || rule.getConfigJson().getRulesList().isEmpty()) {
throw new IllegalStateException("订餐规则未配置,无法创建订阅提醒");
}
final var cfg = rule.getConfigJson().getRules(0);
final String startTimeStr = cfg.getStartTime();
final String endTimeStr = cfg.getEndTime();
if (startTimeStr == null || endTimeStr == null) {
throw new IllegalStateException("订餐规则时间配置不完整,无法创建订阅提醒");
}
final List<DayOfWeek> enabledDays = new ArrayList<>();
if (cfg.getMonday()) enabledDays.add(DayOfWeek.MONDAY);
if (cfg.getTuesday()) enabledDays.add(DayOfWeek.TUESDAY);
if (cfg.getWednesday()) enabledDays.add(DayOfWeek.WEDNESDAY);
if (cfg.getThursday()) enabledDays.add(DayOfWeek.THURSDAY);
if (cfg.getFriday()) enabledDays.add(DayOfWeek.FRIDAY);
if (cfg.getSaturday()) enabledDays.add(DayOfWeek.SATURDAY);
if (cfg.getSunday()) enabledDays.add(DayOfWeek.SUNDAY);
if (enabledDays.isEmpty()) {
throw new IllegalStateException("订餐规则未启用任何星期,无法创建订阅提醒");
}
final var startTime = LocalTime.parse(startTimeStr, DateUtil.HH_MM_SS);
final var endTime = LocalTime.parse(endTimeStr, DateUtil.HH_MM_SS);
final ZoneId zone = DateUtil.CHINA_ZONE;
final var now = java.time.ZonedDateTime.now(zone);
// 从当前时间起,向后最多检查 14 天,找到最近的"启用日 + startTime"
LocalDate periodStartDate = null;
DayOfWeek lastEnabledDay = Collections.max(enabledDays);
for (int i = 0; i < 14; i++) {
LocalDate candidateDate = now.toLocalDate().plusDays(i);
DayOfWeek dow = candidateDate.getDayOfWeek();
if (!enabledDays.contains(dow)) {
continue;
}
var candidateStart = candidateDate.atTime(startTime).atZone(zone);
if (!candidateStart.isBefore(now)) {
periodStartDate = candidateDate;
break;
}
}
if (periodStartDate == null) {
throw new IllegalStateException("根据订餐规则无法找到未来的订餐周期开始时间");
}
// 周期结束时间:使用"同一周内最后一个启用日 + endTime"
// 这里以 periodStartDate 所在周的周一为基准
LocalDate monday = periodStartDate.with(DayOfWeek.MONDAY);
LocalDate lastEnabledDate = monday.plusDays(lastEnabledDay.getValue() - 1L);
var endDateTime = lastEnabledDate.atTime(endTime).atZone(zone);
long startMs = periodStartDate.atTime(startTime).atZone(zone).toInstant().toEpochMilli();
long endMs = endDateTime.toInstant().toEpochMilli();
return new Period(startMs, endMs);
}
}
......
package com.infoloop.tianting.service.impl;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.service.OrderReminderService;
import com.infoloop.tianting.service.WxMiniProgramService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID;
/**
* 订餐提醒服务实现
* 使用小程序订阅消息接口发送提醒
*
* 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
*
* 注意:
* 1. 使用的是小程序的 access_token 和 template_id
* 2. openId 是用户在小程序下的 openId
*/
@Slf4j
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class OrderReminderServiceImpl implements OrderReminderService {
private final WxMiniProgramService wxMiniProgramService;
@Value(MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID)
private String templateId;
@Override
public SendSubscriptionMessageResponse sendOrderReminder(String openId,
String page,
String mealType,
String orderTime,
String tips) {
log.info("Sending order reminder via MiniProgram to openId: {}, page: {}, mealType: {}, orderTime: {}, tips: {}",
openId, page, mealType, orderTime, tips);
// 构建小程序订阅消息模板数据
Map<String, Object> data = new HashMap<>();
// thing8: 用餐类型(如:下次订单即将开始)
Map<String, String> thing8Value = new HashMap<>();
thing8Value.put("value", mealType);
data.put("thing8", thing8Value);
// time1: 订餐时间(如:13:00~21:00)
Map<String, String> time1Value = new HashMap<>();
time1Value.put("value", orderTime);
data.put("time1", time1Value);
// thing2: 温馨提示
Map<String, String> thing2Value = new HashMap<>();
thing2Value.put("value", tips);
data.put("thing2", thing2Value);
try {
// 使用小程序订阅消息接口
SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage(
openId,
templateId,
page,
data
);
if (response.getErrcode() != null && response.getErrcode() == 0) {
log.info("Order reminder sent successfully via MiniProgram to openId: {}", openId);
} else {
log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}",
openId, response.getErrcode(), response.getErrmsg());
}
return response;
} catch (Exception e) {
log.error("Failed to send order reminder via MiniProgram to openId: {}", openId, e);
throw e;
}
}
}
package com.infoloop.tianting.service.impl;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto;
import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto;
import com.infoloop.tianting.service.WxMiniProgramService;
......@@ -9,6 +10,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
......@@ -26,4 +29,9 @@ public class WxMiniProgramServiceImpl implements WxMiniProgramService {
return wxMiniProgramHttpClient.getUserPhoneInfoByCode(code);
}
@Override
public SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, Map<String, Object> data) {
return wxMiniProgramHttpClient.sendSubscriptionMessage(openId, templateId, page, data);
}
}
......
......@@ -63,6 +63,13 @@ service MealOrderServiceRpc {
rpc batchCreateDinerMealSuspensionRecords(BatchCreateDinerMealSuspensionRecordsRpcRequest) returns (BatchCreateDinerMealSuspensionRecordsRpcResponse);
rpc updateDinerMealSuspensionRecord(UpdateDinerMealSuspensionRecordRpcRequest) returns (UpdateDinerMealSuspensionRecordRpcResponse);
rpc deleteDinerMealSuspensionRecordsByIds(DeleteDinerMealSuspensionRecordsByIdsRpcRequest) returns (DeleteDinerMealSuspensionRecordsRpcResponse);
// 订餐订阅消息相关服务
rpc CreateOrderSubscriptionMessage (CreateOrderSubscriptionMessageRpcRequest) returns (CreateOrderSubscriptionMessageRpcResponse) {}
rpc GetUserOrderSubscriptionMessageHistory (GetUserOrderSubscriptionMessageHistoryRpcRequest) returns (GetUserOrderSubscriptionMessageHistoryRpcResponse) {}
rpc BatchDeleteOrderSubscriptionMessagesByIds (BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
rpc BatchPhysicalDeleteOrderSubscriptionMessagesByIds (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest) returns (BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse) {}
rpc QueryPendingOrderSubscriptionMessages (QueryPendingOrderSubscriptionMessagesRpcRequest) returns (QueryPendingOrderSubscriptionMessagesRpcResponse) {}
}
enum MealOrderOrderMethodEnum {
......@@ -955,3 +962,76 @@ message BatchCreateDinerMealSuspensionRecordsRpcResponse {
bool isCreated = 1;
repeated int32 ids = 2;
}
// 订餐订阅消息相关消息定义
message CreateOrderSubscriptionMessageRpcRequest {
int32 enterpriseId = 1;
int32 dinerId = 2;
string openId = 3;
string templateId = 4;
int64 orderPeriodStartDate = 5; // Unix 时间戳(毫秒),精确到秒
string jumpPath = 6; // 跳转路径(小程序 path),可选
int64 orderPeriodEndDate = 7; // Unix 时间戳(毫秒),精确到秒,可选
}
message CreateOrderSubscriptionMessageRpcResponse {
int64 id = 1;
}
message GetUserOrderSubscriptionMessageHistoryRpcRequest {
int32 enterpriseId = 1;
string openId = 2;
int64 orderPeriodStartDate = 3; // Unix 时间戳(毫秒),精确到秒,可选,如果传入则查询大于等于该值的记录
}
message OrderSubscriptionMessageRpcResponse {
int64 id = 1;
int64 orderPeriodStartDate = 2; // Unix 时间戳(毫秒),精确到秒
int64 createdAt = 3; // Unix 时间戳(毫秒)
bool isDeleted = 4;
string jumpPath = 5; // 跳转路径(小程序 path),可选
int64 orderPeriodEndDate = 6; // Unix 时间戳(毫秒),精确到秒,可选
}
message GetUserOrderSubscriptionMessageHistoryRpcResponse {
repeated OrderSubscriptionMessageRpcResponse response = 1;
}
message BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest {
int32 enterpriseId = 1;
repeated int64 ids = 2; // 要删除的记录ID列表
}
message BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse {
int32 affectedRows = 1; // 受影响的行数
}
message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest {
int32 enterpriseId = 1;
repeated int64 ids = 2; // 要删除的记录ID列表
}
message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse {
int32 affectedRows = 1; // 受影响的行数
}
// 查询待发送的订阅消息(未删除的记录)
message QueryPendingOrderSubscriptionMessagesRpcRequest {
int32 enterpriseId = 1;
}
message PendingOrderSubscriptionMessageRpcResponse {
int64 id = 1;
int32 enterpriseId = 2;
int32 dinerId = 3;
string openId = 4;
string templateId = 5;
int64 orderPeriodStartDate = 6;
string jumpPath = 7;
int64 orderPeriodEndDate = 8;
int64 createdAt = 9;
}
message QueryPendingOrderSubscriptionMessagesRpcResponse {
repeated PendingOrderSubscriptionMessageRpcResponse responses = 1;
}
......
......@@ -75,6 +75,7 @@ grpc.meizhongyihe-service.port=3040
miniprogram.appId=wx40ec496d73b58ec6
miniprogram.appSecret=5b309517bf918abf760b2195e91b99f3
miniprogram.orderReminderTemplateId=xxx
business-config.hisCustomerQueryUrl = http://localhost:8903/hiscustomers/current/query
business-config.clientCustomerQueryUrl = http://localhost:8903/clientcustomers/current/query
......