Showing
12 changed files
with
925 additions
and
0 deletions
.cursor/.rules.mdc
0 → 100644
| 1 | +--- | ||
| 2 | +alwaysApply: false | ||
| 3 | +--- | ||
| 4 | +# 项目代码规范 | ||
| 5 | + | ||
| 6 | +## 项目结构 | ||
| 7 | +``` | ||
| 8 | +src/main/java/com/infoloop/tianting/ | ||
| 9 | +├── controller/ # REST API 控制器 | ||
| 10 | +├── service/ # 业务逻辑接口 | ||
| 11 | +│ ├── impl/ # 业务逻辑实现 | ||
| 12 | +│ └── client/ # gRPC/HTTP 客户端封装 | ||
| 13 | +├── model/ # 数据模型 | ||
| 14 | +│ ├── dto/ # 数据传输对象 | ||
| 15 | +│ ├── vo/ # 视图对象 | ||
| 16 | +│ ├── bo/ # 业务对象 | ||
| 17 | +│ └── common/ # 通用模型 | ||
| 18 | +├── config/ # 配置类 | ||
| 19 | +├── constant/ # 常量定义 | ||
| 20 | +├── enums/ # 枚举类 | ||
| 21 | +├── exception/ # 异常定义 | ||
| 22 | +├── logic/ # 业务逻辑(定时任务、延迟任务等) | ||
| 23 | +├── utils/ # 工具类 | ||
| 24 | +└── store/ # Redis 存储封装 | ||
| 25 | +``` | ||
| 26 | + | ||
| 27 | +## 代码风格 | ||
| 28 | + | ||
| 29 | +### 类注解顺序 | ||
| 30 | +```java | ||
| 31 | +@Slf4j | ||
| 32 | +@Service // 或 @Component, @RestController | ||
| 33 | +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) | ||
| 34 | +public class XxxServiceImpl implements XxxService { | ||
| 35 | +``` | ||
| 36 | + | ||
| 37 | +### Controller 规范 | ||
| 38 | +- 使用 `@Api(tags = "模块名")` 标注 Swagger 分组 | ||
| 39 | +- 使用 `@ApiOperation(value = "接口描述")` 标注接口 | ||
| 40 | +- 使用 `@ResponseStatus` 指定 HTTP 状态码 | ||
| 41 | +- 使用 `@Valid` 进行参数校验 | ||
| 42 | + | ||
| 43 | +### Service 规范 | ||
| 44 | +- 接口定义在 `service/` 目录 | ||
| 45 | +- 实现类在 `service/impl/` 目录,命名为 `XxxServiceImpl` | ||
| 46 | +- gRPC 客户端封装在 `service/client/` 目录,命名为 `XxxServiceRpcClient` | ||
| 47 | + | ||
| 48 | +### 配置常量 | ||
| 49 | +- 配置项定义在 `ConfigConstants` 接口中 | ||
| 50 | +- 使用 `@Value(ConfigConstants.XXX)` 注入配置 | ||
| 51 | + | ||
| 52 | +### 依赖注入 | ||
| 53 | +- 优先使用构造器注入:`@RequiredArgsConstructor(onConstructor = @__(@Autowired))` | ||
| 54 | +- 配置值使用 `@Value` 注入 | ||
| 55 | + | ||
| 56 | +### 日志规范 | ||
| 57 | +- 使用 `@Slf4j` 注解 | ||
| 58 | +- 使用 `log.info/warn/error` 记录日志 | ||
| 59 | +- 异常日志使用 `log.error("message", e)` | ||
| 60 | + | ||
| 61 | +### gRPC 调用 | ||
| 62 | +- 通过 `XxxServiceRpcClient` 封装 gRPC 调用 | ||
| 63 | +- 在 `GrpcConfig` 中配置 Channel 和 Stub | ||
| 64 | + | ||
| 65 | +### 定时任务 | ||
| 66 | +- 放在 `logic/task/` 目录 | ||
| 67 | +- 使用 `@Scheduled(cron = "...")` 注解 | ||
| 68 | +- 使用 `@Component` 注册为 Bean | ||
| 69 | + | ||
| 70 | +## 命名规范 | ||
| 71 | +- DTO 类:`XxxDTO` 或内部类 `XxxDTO.CreateXxxDTO` | ||
| 72 | +- VO 类:`XxxVO` | ||
| 73 | +- 枚举类:`XxxEnum` | ||
| 74 | +- 常量类:`XxxConstants` | ||
| 75 | +- 工具类:`XxxUtil` | ||
| 76 | + | ||
| 77 | +## 禁止事项 | ||
| 78 | + | ||
| 79 | +### 禁止循环依赖 | ||
| 80 | +- **Service 之间禁止循环调用**:ServiceA 调用 ServiceB,ServiceB 不能再调用 ServiceA | ||
| 81 | +- **避免循环依赖注入**:如果出现循环依赖,需要重构代码,提取公共逻辑到新的 Service | ||
| 82 | +- **分层调用原则**:Controller → Service → RpcClient/Store,禁止反向调用 | ||
| 83 | + | ||
| 84 | +### 禁止循环调用 gRPC 方法 | ||
| 85 | +- **禁止在循环中调用 gRPC 方法**:会导致大量网络请求,严重影响性能 | ||
| 86 | +- **必须使用批量接口**:如果需要处理多条数据,必须使用批量查询/批量创建接口 | ||
| 87 | +- **先收集 ID 再批量查询**:先收集所有需要查询的 ID,一次性批量查询 | ||
| 88 | + | ||
| 89 | +```java | ||
| 90 | +// ❌ 错误示例:循环调用 gRPC | ||
| 91 | +for (Long id : ids) { | ||
| 92 | + var result = rpcClient.getById(id); // 禁止! | ||
| 93 | + results.add(result); | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +// ✅ 正确示例:批量调用 | ||
| 97 | +var results = rpcClient.getByIds(ids); // 一次批量查询 | ||
| 98 | +``` | ||
| 99 | + | ||
| 100 | +### 批量接口设计规范 | ||
| 101 | +- **Proto 定义批量方法**:`GetXxxsByIds`、`BatchCreateXxx`、`BatchUpdateXxx` | ||
| 102 | +- **RpcClient 封装批量方法**:提供 `getByIds(List<Long> ids)` 等批量方法 | ||
| 103 | +- **空集合检查**:批量方法调用前检查集合是否为空,避免无效请求 | ||
| 104 | + | ||
| 105 | +### 参数类型规范 | ||
| 106 | +- **禁止使用 Object 作为参数类型**:必须明确定义具体类型 | ||
| 107 | +- **禁止使用 Map<String, Object>**:应定义具体的 DTO 类 | ||
| 108 | +- **集合类型必须指定泛型**:使用 `List<XxxDTO>` 而非 `List` | ||
| 109 | +- **方法参数类型要明确**:避免使用 `var` 定义方法参数 | ||
| 110 | +- **返回值类型要明确**:禁止返回 `Object`,必须定义具体类型 | ||
| 111 | + | ||
| 112 | +### 示例 | ||
| 113 | +```java | ||
| 114 | +// ❌ 错误示例 | ||
| 115 | +public Object process(Map<String, Object> params) { ... } | ||
| 116 | + | ||
| 117 | +// ✅ 正确示例 | ||
| 118 | +public OrderVO process(CreateOrderDTO params) { ... } | ||
| 119 | +``` |
.cursor/.skills.mdc
0 → 100644
| 1 | +--- | ||
| 2 | +alwaysApply: false | ||
| 3 | +--- | ||
| 4 | +# 项目技术栈 | ||
| 5 | + | ||
| 6 | +## 核心框架 | ||
| 7 | +- **Spring Boot 2.5.12**:Web 应用框架 | ||
| 8 | +- **Java 17**:编程语言 | ||
| 9 | +- **Gradle**:构建工具 | ||
| 10 | + | ||
| 11 | +## 通信协议 | ||
| 12 | +- **gRPC**:微服务间通信,使用 Protobuf 定义接口 | ||
| 13 | +- **REST API**:对外 HTTP 接口,使用 Knife4j/Swagger 文档 | ||
| 14 | + | ||
| 15 | +## 数据存储 | ||
| 16 | +- **Redis (Lettuce)**:缓存、分布式锁、Session 存储 | ||
| 17 | +- **Redisson**:分布式锁实现 | ||
| 18 | +- **阿里云 OSS**:文件存储 | ||
| 19 | + | ||
| 20 | +## 认证授权 | ||
| 21 | +- **Sa-Token + JWT**:用户认证和权限管理 | ||
| 22 | + | ||
| 23 | +## 工具库 | ||
| 24 | +- **Lombok**:简化 Java 代码 | ||
| 25 | +- **Hutool**:通用工具类 | ||
| 26 | +- **Jackson**:JSON/XML 序列化 | ||
| 27 | +- **Apache POI**:Excel 处理 | ||
| 28 | + | ||
| 29 | +## 可观测性 | ||
| 30 | +- **Zipkin Brave**:分布式链路追踪 | ||
| 31 | +- **Spring Actuator**:健康检查和监控 | ||
| 32 | + | ||
| 33 | +## 异步处理 | ||
| 34 | +- **Spring Scheduling**:定时任务 | ||
| 35 | +- **Spring Async**:异步执行 | ||
| 36 | +- **WebSocket**:实时通信 |
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,6 @@ public interface ConfigConstants { | ... | @@ -81,5 +81,6 @@ public interface ConfigConstants { |
| 81 | 81 | ||
| 82 | String MINI_PROGRAM_APP_ID = "${miniprogram.appId}"; | 82 | String MINI_PROGRAM_APP_ID = "${miniprogram.appId}"; |
| 83 | String MINI_PROGRAM_APP_SECRET = "${miniprogram.appSecret}"; | 83 | String MINI_PROGRAM_APP_SECRET = "${miniprogram.appSecret}"; |
| 84 | + String MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID = "${miniprogram.orderReminderTemplateId}"; | ||
| 84 | 85 | ||
| 85 | } | 86 | } | ... | ... |
| ... | @@ -52,4 +52,26 @@ public class WxUserDto { | ... | @@ -52,4 +52,26 @@ public class WxUserDto { |
| 52 | private WxUserPhoneInfoDto phone_info; | 52 | private WxUserPhoneInfoDto phone_info; |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | + @Data | ||
| 56 | + @Builder | ||
| 57 | + @ApiModel(description = "订阅消息发送请求") | ||
| 58 | + @NoArgsConstructor | ||
| 59 | + @AllArgsConstructor | ||
| 60 | + public static class SendSubscriptionMessageRequest { | ||
| 61 | + private String touser; | ||
| 62 | + private String template_id; | ||
| 63 | + private String page; | ||
| 64 | + private java.util.Map<String, Object> data; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @Data | ||
| 68 | + @Builder | ||
| 69 | + @ApiModel(description = "订阅消息发送响应") | ||
| 70 | + @NoArgsConstructor | ||
| 71 | + @AllArgsConstructor | ||
| 72 | + public static class SendSubscriptionMessageResponse { | ||
| 73 | + private Integer errcode; | ||
| 74 | + private String errmsg; | ||
| 75 | + } | ||
| 76 | + | ||
| 55 | } | 77 | } | ... | ... |
| 1 | +package com.infoloop.tianting.service; | ||
| 2 | + | ||
| 3 | +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse; | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * 订餐提醒服务接口 | ||
| 7 | + * 用于发送订餐周期开启前的提醒消息 | ||
| 8 | + */ | ||
| 9 | +public interface OrderReminderService { | ||
| 10 | + /** | ||
| 11 | + * 发送订餐提醒消息 | ||
| 12 | + * | ||
| 13 | + * @param openId 用户 OpenID | ||
| 14 | + * @param page 点击跳转页面路径,例如:pages/order/index?cycleId=202501 | ||
| 15 | + * @param reminderContent 提醒内容,例如:"下次订餐即将开启" | ||
| 16 | + * @param orderTime 订餐时间,例如:"周一 09:00 至 周三 12:00" | ||
| 17 | + * @param tips 温馨提示,例如:"请点击卡片,准时进入小程序完成预订" | ||
| 18 | + * @return 发送结果 | ||
| 19 | + */ | ||
| 20 | + SendSubscriptionMessageResponse sendOrderReminder(String openId, String page, | ||
| 21 | + String reminderContent, String orderTime, String tips); | ||
| 22 | +} |
| 1 | package com.infoloop.tianting.service; | 1 | package com.infoloop.tianting.service; |
| 2 | 2 | ||
| 3 | +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse; | ||
| 3 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; | 4 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; |
| 4 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; | 5 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; |
| 5 | 6 | ||
| 7 | +import java.util.Map; | ||
| 8 | + | ||
| 6 | public interface WxMiniProgramService { | 9 | public interface WxMiniProgramService { |
| 7 | WxUserOpenIdDto getWxUserOpenIdByCode(String code); | 10 | WxUserOpenIdDto getWxUserOpenIdByCode(String code); |
| 8 | WxUserPhoneResponseDto getUserPhoneInfoByCode(String code); | 11 | WxUserPhoneResponseDto getUserPhoneInfoByCode(String code); |
| 12 | + SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, Map<String, Object> data); | ||
| 9 | } | 13 | } | ... | ... |
| 1 | package com.infoloop.tianting.service.client; | 1 | package com.infoloop.tianting.service.client; |
| 2 | 2 | ||
| 3 | +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageRequest; | ||
| 4 | +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse; | ||
| 3 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; | 5 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; |
| 4 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; | 6 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; |
| 5 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto; | 7 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserTokenDto; |
| ... | @@ -31,6 +33,7 @@ public class WxMiniProgramHttpClient { | ... | @@ -31,6 +33,7 @@ public class WxMiniProgramHttpClient { |
| 31 | 33 | ||
| 32 | private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appId}&secret={appSecret}"; | 34 | private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appId}&secret={appSecret}"; |
| 33 | private static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={accessToken}"; | 35 | private static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={accessToken}"; |
| 36 | + private static final String SUBSCRIBE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={accessToken}"; | ||
| 34 | 37 | ||
| 35 | private final RestTemplate restTemplate; | 38 | private final RestTemplate restTemplate; |
| 36 | 39 | ||
| ... | @@ -135,4 +138,46 @@ public class WxMiniProgramHttpClient { | ... | @@ -135,4 +138,46 @@ public class WxMiniProgramHttpClient { |
| 135 | } | 138 | } |
| 136 | } | 139 | } |
| 137 | 140 | ||
| 141 | + /** | ||
| 142 | + * 发送订阅消息 | ||
| 143 | + * @param openId 用户 OpenID | ||
| 144 | + * @param templateId 模板 ID | ||
| 145 | + * @param page 点击跳转页面路径 | ||
| 146 | + * @param data 模板数据,格式为 Map<String, Map<String, String>>,例如:{"thing1": {"value": "内容"}} | ||
| 147 | + * @return 发送结果 | ||
| 148 | + */ | ||
| 149 | + public SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, java.util.Map<String, Object> data) { | ||
| 150 | + try { | ||
| 151 | + String accessToken = fetchStableAccessToken(); | ||
| 152 | + HttpHeaders headers = new HttpHeaders(); | ||
| 153 | + headers.setContentType(MediaType.APPLICATION_JSON); | ||
| 154 | + | ||
| 155 | + SendSubscriptionMessageRequest request = SendSubscriptionMessageRequest.builder() | ||
| 156 | + .touser(openId) | ||
| 157 | + .template_id(templateId) | ||
| 158 | + .page(page) | ||
| 159 | + .data(data) | ||
| 160 | + .build(); | ||
| 161 | + | ||
| 162 | + HttpEntity<SendSubscriptionMessageRequest> requestEntity = new HttpEntity<>(request, headers); | ||
| 163 | + ResponseEntity<SendSubscriptionMessageResponse> response = restTemplate.postForEntity( | ||
| 164 | + SUBSCRIBE_MESSAGE_URL, requestEntity, SendSubscriptionMessageResponse.class, accessToken); | ||
| 165 | + | ||
| 166 | + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { | ||
| 167 | + log.error("Failed to send subscription message: {}", response); | ||
| 168 | + throw new IllegalArgumentException("发送订阅消息失败"); | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + SendSubscriptionMessageResponse result = response.getBody(); | ||
| 172 | + if (result.getErrcode() != null && result.getErrcode() != 0) { | ||
| 173 | + log.error("WeChat subscription message error: errcode={}, errmsg={}", result.getErrcode(), result.getErrmsg()); | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + return result; | ||
| 177 | + } catch (Exception e) { | ||
| 178 | + log.error("Error sending subscription message", e); | ||
| 179 | + throw new IllegalArgumentException("发送订阅消息失败: " + e.getMessage()); | ||
| 180 | + } | ||
| 181 | + } | ||
| 182 | + | ||
| 138 | } | 183 | } | ... | ... |
| 1 | +package com.infoloop.tianting.service.impl; | ||
| 2 | + | ||
| 3 | +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse; | ||
| 4 | +import com.infoloop.tianting.service.OrderReminderService; | ||
| 5 | +import com.infoloop.tianting.service.WxMiniProgramService; | ||
| 6 | +import lombok.RequiredArgsConstructor; | ||
| 7 | +import lombok.extern.slf4j.Slf4j; | ||
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 9 | +import org.springframework.beans.factory.annotation.Value; | ||
| 10 | +import org.springframework.stereotype.Service; | ||
| 11 | + | ||
| 12 | +import java.util.HashMap; | ||
| 13 | +import java.util.Map; | ||
| 14 | + | ||
| 15 | +import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID; | ||
| 16 | + | ||
| 17 | +/** | ||
| 18 | + * 订餐提醒服务实现 | ||
| 19 | + * 将业务参数转换为微信订阅消息模板格式并发送 | ||
| 20 | + */ | ||
| 21 | +@Slf4j | ||
| 22 | +@Service | ||
| 23 | +@RequiredArgsConstructor(onConstructor = @__(@Autowired)) | ||
| 24 | +public class OrderReminderServiceImpl implements OrderReminderService { | ||
| 25 | + | ||
| 26 | + private final WxMiniProgramService wxMiniProgramService; | ||
| 27 | + | ||
| 28 | + @Value(MINI_PROGRAM_ORDER_REMINDER_TEMPLATE_ID) | ||
| 29 | + private String templateId; | ||
| 30 | + | ||
| 31 | + @Override | ||
| 32 | + public SendSubscriptionMessageResponse sendOrderReminder(String openId, | ||
| 33 | + String page, | ||
| 34 | + String reminderContent, | ||
| 35 | + String orderTime, | ||
| 36 | + String tips) { | ||
| 37 | + log.info("Sending order reminder to openId: {}, page: {}", openId, page); | ||
| 38 | + | ||
| 39 | + // 将业务参数转换为微信模板数据格式 | ||
| 40 | + // 根据文档,模板字段为:thing1 (提醒内容), time2 (订餐时间), thing3 (温馨提示) | ||
| 41 | + Map<String, Object> data = new HashMap<>(); | ||
| 42 | + | ||
| 43 | + // thing1: 提醒内容 | ||
| 44 | + Map<String, String> thing1Value = new HashMap<>(); | ||
| 45 | + thing1Value.put("value", reminderContent); | ||
| 46 | + data.put("thing1", thing1Value); | ||
| 47 | + | ||
| 48 | + // time2: 订餐时间 | ||
| 49 | + Map<String, String> time2Value = new HashMap<>(); | ||
| 50 | + time2Value.put("value", orderTime); | ||
| 51 | + data.put("time2", time2Value); | ||
| 52 | + | ||
| 53 | + // thing3: 温馨提示 | ||
| 54 | + Map<String, String> thing3Value = new HashMap<>(); | ||
| 55 | + thing3Value.put("value", tips); | ||
| 56 | + data.put("thing3", thing3Value); | ||
| 57 | + | ||
| 58 | + try { | ||
| 59 | + SendSubscriptionMessageResponse response = wxMiniProgramService.sendSubscriptionMessage( | ||
| 60 | + openId, templateId, page, data); | ||
| 61 | + | ||
| 62 | + if (response.getErrcode() != null && response.getErrcode() == 0) { | ||
| 63 | + log.info("Order reminder sent successfully to openId: {}", openId); | ||
| 64 | + } else { | ||
| 65 | + log.warn("Order reminder sent with error. openId: {}, errcode: {}, errmsg: {}", | ||
| 66 | + openId, response.getErrcode(), response.getErrmsg()); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + return response; | ||
| 70 | + } catch (Exception e) { | ||
| 71 | + log.error("Failed to send order reminder to openId: {}", openId, e); | ||
| 72 | + throw e; | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | +} |
| 1 | package com.infoloop.tianting.service.impl; | 1 | package com.infoloop.tianting.service.impl; |
| 2 | 2 | ||
| 3 | +import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse; | ||
| 3 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; | 4 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserOpenIdDto; |
| 4 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; | 5 | import com.infoloop.tianting.model.dto.WxUserDto.WxUserPhoneResponseDto; |
| 5 | import com.infoloop.tianting.service.WxMiniProgramService; | 6 | import com.infoloop.tianting.service.WxMiniProgramService; |
| ... | @@ -9,6 +10,8 @@ import lombok.extern.slf4j.Slf4j; | ... | @@ -9,6 +10,8 @@ import lombok.extern.slf4j.Slf4j; |
| 9 | import org.springframework.beans.factory.annotation.Autowired; | 10 | import org.springframework.beans.factory.annotation.Autowired; |
| 10 | import org.springframework.stereotype.Service; | 11 | import org.springframework.stereotype.Service; |
| 11 | 12 | ||
| 13 | +import java.util.Map; | ||
| 14 | + | ||
| 12 | @Slf4j | 15 | @Slf4j |
| 13 | @Service | 16 | @Service |
| 14 | @RequiredArgsConstructor(onConstructor = @__(@Autowired)) | 17 | @RequiredArgsConstructor(onConstructor = @__(@Autowired)) |
| ... | @@ -26,4 +29,9 @@ public class WxMiniProgramServiceImpl implements WxMiniProgramService { | ... | @@ -26,4 +29,9 @@ public class WxMiniProgramServiceImpl implements WxMiniProgramService { |
| 26 | return wxMiniProgramHttpClient.getUserPhoneInfoByCode(code); | 29 | return wxMiniProgramHttpClient.getUserPhoneInfoByCode(code); |
| 27 | } | 30 | } |
| 28 | 31 | ||
| 32 | + @Override | ||
| 33 | + public SendSubscriptionMessageResponse sendSubscriptionMessage(String openId, String templateId, String page, Map<String, Object> data) { | ||
| 34 | + return wxMiniProgramHttpClient.sendSubscriptionMessage(openId, templateId, page, data); | ||
| 35 | + } | ||
| 36 | + | ||
| 29 | } | 37 | } | ... | ... |
| ... | @@ -75,6 +75,7 @@ grpc.meizhongyihe-service.port=3040 | ... | @@ -75,6 +75,7 @@ grpc.meizhongyihe-service.port=3040 |
| 75 | 75 | ||
| 76 | miniprogram.appId=wx40ec496d73b58ec6 | 76 | miniprogram.appId=wx40ec496d73b58ec6 |
| 77 | miniprogram.appSecret=5b309517bf918abf760b2195e91b99f3 | 77 | miniprogram.appSecret=5b309517bf918abf760b2195e91b99f3 |
| 78 | +miniprogram.orderReminderTemplateId=xxx | ||
| 78 | 79 | ||
| 79 | business-config.hisCustomerQueryUrl = http://localhost:8903/hiscustomers/current/query | 80 | business-config.hisCustomerQueryUrl = http://localhost:8903/hiscustomers/current/query |
| 80 | business-config.clientCustomerQueryUrl = http://localhost:8903/clientcustomers/current/query | 81 | business-config.clientCustomerQueryUrl = http://localhost:8903/clientcustomers/current/query | ... | ... |
-
Please register or login to post a comment