Toggle navigation
Toggle navigation
This project
Loading...
Sign in
jiujian
/
client-customer-order-api
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
Jiaqi Xia
2026-02-04 11:25:09 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
47c629757b69bdb24f83a2466aa572931db5a442
47c62975
1 parent
dc9adbf4
feat: 微信点餐推送
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
644 additions
and
175 deletions
.cursor/.rules.mdc
.cursor/.skills.mdc
src/main/java/com/infoloop/tianting/constant/ConfigConstants.java
src/main/java/com/infoloop/tianting/logic/task/OrderReminderTask.java
src/main/java/com/infoloop/tianting/logic/task/OrderReminderTaskRunner.java
src/main/java/com/infoloop/tianting/service/OrderReminderService.java
src/main/java/com/infoloop/tianting/service/client/MealOrderServiceRpcClient.java
src/main/java/com/infoloop/tianting/service/client/WxOfficialAccountHttpClient.java
src/main/java/com/infoloop/tianting/service/impl/OrderReminderServiceImpl.java
src/main/proto/MealOrderService.proto
.cursor/.rules.mdc
deleted
100644 → 0
View file @
dc9adbf
---
alwaysApply: false
---
# 项目代码规范
## 项目结构
```
src/main/java/com/infoloop/tianting/
├── controller/ # REST API 控制器
├── service/ # 业务逻辑接口
│ ├── impl/ # 业务逻辑实现
│ └── client/ # gRPC/HTTP 客户端封装
├── model/ # 数据模型
│ ├── dto/ # 数据传输对象
│ ├── vo/ # 视图对象
│ ├── bo/ # 业务对象
│ └── common/ # 通用模型
├── config/ # 配置类
├── constant/ # 常量定义
├── enums/ # 枚举类
├── exception/ # 异常定义
├── logic/ # 业务逻辑(定时任务、延迟任务等)
├── utils/ # 工具类
└── store/ # Redis 存储封装
```
## 代码风格
### 类注解顺序
```java
@Slf4j
@Service // 或 @Component, @RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class XxxServiceImpl implements XxxService {
```
### Controller 规范
- 使用 `@Api(tags = "模块名")` 标注 Swagger 分组
- 使用 `@ApiOperation(value = "接口描述")` 标注接口
- 使用 `@ResponseStatus` 指定 HTTP 状态码
- 使用 `@Valid` 进行参数校验
### Service 规范
- 接口定义在 `service/` 目录
- 实现类在 `service/impl/` 目录,命名为 `XxxServiceImpl`
- gRPC 客户端封装在 `service/client/` 目录,命名为 `XxxServiceRpcClient`
### 配置常量
- 配置项定义在 `ConfigConstants` 接口中
- 使用 `@Value(ConfigConstants.XXX)` 注入配置
### 依赖注入
- 优先使用构造器注入:`@RequiredArgsConstructor(onConstructor = @__(@Autowired))`
- 配置值使用 `@Value` 注入
### 日志规范
- 使用 `@Slf4j` 注解
- 使用 `log.info/warn/error` 记录日志
- 异常日志使用 `log.error("message", e)`
### gRPC 调用
- 通过 `XxxServiceRpcClient` 封装 gRPC 调用
- 在 `GrpcConfig` 中配置 Channel 和 Stub
### 定时任务
- 放在 `logic/task/` 目录
- 使用 `@Scheduled(cron = "...")` 注解
- 使用 `@Component` 注册为 Bean
## 命名规范
- DTO 类:`XxxDTO` 或内部类 `XxxDTO.CreateXxxDTO`
- VO 类:`XxxVO`
- 枚举类:`XxxEnum`
- 常量类:`XxxConstants`
- 工具类:`XxxUtil`
## 禁止事项
### 禁止循环依赖
- **Service 之间禁止循环调用**:ServiceA 调用 ServiceB,ServiceB 不能再调用 ServiceA
- **避免循环依赖注入**:如果出现循环依赖,需要重构代码,提取公共逻辑到新的 Service
- **分层调用原则**:Controller → Service → RpcClient/Store,禁止反向调用
### 禁止循环调用 gRPC 方法
- **禁止在循环中调用 gRPC 方法**:会导致大量网络请求,严重影响性能
- **必须使用批量接口**:如果需要处理多条数据,必须使用批量查询/批量创建接口
- **先收集 ID 再批量查询**:先收集所有需要查询的 ID,一次性批量查询
```java
// ❌ 错误示例:循环调用 gRPC
for (Long id : ids) {
var result = rpcClient.getById(id); // 禁止!
results.add(result);
}
// ✅ 正确示例:批量调用
var results = rpcClient.getByIds(ids); // 一次批量查询
```
### 批量接口设计规范
- **Proto 定义批量方法**:`GetXxxsByIds`、`BatchCreateXxx`、`BatchUpdateXxx`
- **RpcClient 封装批量方法**:提供 `getByIds(List<Long> ids)` 等批量方法
- **空集合检查**:批量方法调用前检查集合是否为空,避免无效请求
### 参数类型规范
- **禁止使用 Object 作为参数类型**:必须明确定义具体类型
- **禁止使用 Map<String, Object>**:应定义具体的 DTO 类
- **集合类型必须指定泛型**:使用 `List<XxxDTO>` 而非 `List`
- **方法参数类型要明确**:避免使用 `var` 定义方法参数
- **返回值类型要明确**:禁止返回 `Object`,必须定义具体类型
### 示例
```java
// ❌ 错误示例
public Object process(Map<String, Object> params) { ... }
// ✅ 正确示例
public OrderVO process(CreateOrderDTO params) { ... }
```
.cursor/.skills.mdc
View file @
47c6297
---
description: Implementing Spring Boot controllers, services, gRPC clients, business logic, and backend APIs
alwaysApply: false
---
#
项目技术栈
#
Backend Implementation Skill (Spring Boot + gRPC)
##
核心框架
-
**Spring Boot 2.5.12**:Web 应用框架
-
**Java 17**:编程语言
-
**Gradle**:构建工具
##
When writing backend code
-
Use Spring Boot 2.5.12 conventions
-
Write Java 17 compatible code
-
Follow layered architecture
## 通信协议
- **gRPC**:微服务间通信,使用 Protobuf 定义接口
- **REST API**:对外 HTTP 接口,使用 Knife4j/Swagger 文档
## 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
##
数据存储
-
**Redis (Lettuce)**:缓存、分布式锁、Session 存储
-
**Redisson**:分布式锁实现
-
**阿里云 OSS**:文件存储
##
gRPC Integration
-
Use XxxServiceRpcClient to wrap gRPC calls
-
Prefer batch RPC methods
-
Validate empty collections before calling RPC
## 认证授权
- **Sa-Token + JWT**:用户认证和权限管理
## 工具库
- **Lombok**:简化 Java 代码
- **Hutool**:通用工具类
- **Jackson**:JSON/XML 序列化
- **Apache POI**:Excel 处理
## 可观测性
- **Zipkin Brave**:分布式链路追踪
- **Spring Actuator**:健康检查和监控
## 异步处理
- **Spring Scheduling**:定时任务
- **Spring Async**:异步执行
- **WebSocket**:实时通信
## Common Libraries
- Lombok for boilerplate reduction
- Hutool for date, collection, string utilities
\ No newline at end of file
...
...
src/main/java/com/infoloop/tianting/constant/ConfigConstants.java
View file @
47c6297
...
...
@@ -83,4 +83,9 @@ public interface ConfigConstants {
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}"
;
}
...
...
src/main/java/com/infoloop/tianting/logic/task/OrderReminderTask.java
0 → 100644
View file @
47c6297
package
com
.
infoloop
.
tianting
.
logic
.
task
;
import
com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse
;
import
com.infoloop.tianting.mealorderservice.SingleMpAccountRpcResponse
;
import
com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse
;
import
com.infoloop.tianting.service.OrderReminderService
;
import
com.infoloop.tianting.service.client.DeliveryRuleServiceRpcClient
;
import
com.infoloop.tianting.service.client.MealOrderServiceRpcClient
;
import
lombok.extern.slf4j.Slf4j
;
import
org.apache.commons.collections4.CollectionUtils
;
import
org.springframework.beans.factory.annotation.Qualifier
;
import
org.springframework.scheduling.annotation.Scheduled
;
import
org.springframework.stereotype.Component
;
import
java.util.ArrayList
;
import
java.util.Collections
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Set
;
import
java.util.concurrent.CompletableFuture
;
import
java.util.concurrent.ConcurrentHashMap
;
import
java.util.concurrent.Executor
;
import
java.util.concurrent.atomic.AtomicInteger
;
import
java.util.stream.Collectors
;
import
static
com
.
infoloop
.
tianting
.
constant
.
ConfigConstants
.
ASYNC_EXECUTOR
;
/**
* 订餐提醒定时任务
* 在订餐周期开启前(周一 9:00)向已授权的家长发送微信服务通知
*
* 推送逻辑:
* 1. 查询所有待发送的订阅消息(未删除的记录)
* 2. 按 openId 去重,每个用户只推送一次
* 3. 通过 openId 查询活跃的小程序用户
* 4. 根据配送规则配置生成订餐时间提示
* 5. 发送消息,发送成功后标记为已删除
*/
@Slf4j
@Component
public
class
OrderReminderTask
{
private
static
final
int
ENTERPRISE_ID
=
449
;
/**
* 用餐类型
*/
private
static
final
String
MEAL_TYPE
=
"下次订餐即将开启"
;
/**
* 温馨提醒内容
*/
private
static
final
String
TIPS
=
"请点击卡片,准时进入小程序完成预订"
;
/**
* 订餐时间配置(格式:HH:mm~HH:mm)
*/
private
record
OrderTimeConfig
(
String
orderTime
)
{}
private
final
MealOrderServiceRpcClient
mealOrderServiceRpcClient
;
private
final
DeliveryRuleServiceRpcClient
deliveryRuleServiceRpcClient
;
private
final
OrderReminderService
orderReminderService
;
private
final
Executor
asyncExecutor
;
public
OrderReminderTask
(
MealOrderServiceRpcClient
mealOrderServiceRpcClient
,
DeliveryRuleServiceRpcClient
deliveryRuleServiceRpcClient
,
OrderReminderService
orderReminderService
,
@Qualifier
(
ASYNC_EXECUTOR
)
Executor
asyncExecutor
)
{
this
.
mealOrderServiceRpcClient
=
mealOrderServiceRpcClient
;
this
.
deliveryRuleServiceRpcClient
=
deliveryRuleServiceRpcClient
;
this
.
orderReminderService
=
orderReminderService
;
this
.
asyncExecutor
=
asyncExecutor
;
}
/**
* 每周一 9:00 执行订餐提醒推送
*/
@Scheduled
(
cron
=
"${task.orderReminder.cron:0 0 9 * * MON}"
)
public
void
executeTask
()
{
log
.
info
(
"OrderReminderTask started"
);
try
{
// 1. 获取配送规则配置,用于生成订餐时间提示
OrderTimeConfig
orderTimeConfig
=
getOrderTimeConfigFromRule
();
if
(
orderTimeConfig
==
null
)
{
log
.
warn
(
"Failed to get order time config from rule, using default"
);
orderTimeConfig
=
new
OrderTimeConfig
(
"请查看小程序"
);
}
log
.
info
(
"Order time config: orderTime={}"
,
orderTimeConfig
.
orderTime
());
// 2. 查询所有待发送的订阅消息(未删除的记录)
List
<
PendingOrderSubscriptionMessageRpcResponse
>
pendingMessages
=
mealOrderServiceRpcClient
.
queryPendingOrderSubscriptionMessages
(
ENTERPRISE_ID
);
if
(
pendingMessages
.
isEmpty
())
{
log
.
info
(
"No pending order subscription messages to send"
);
return
;
}
log
.
info
(
"Found {} pending order subscription messages"
,
pendingMessages
.
size
());
// 3. 提取所有 openId,查询活跃的小程序用户
Set
<
String
>
openIds
=
pendingMessages
.
stream
()
.
map
(
PendingOrderSubscriptionMessageRpcResponse:
:
getOpenId
)
.
collect
(
Collectors
.
toSet
());
Map
<
String
,
SingleMpAccountRpcResponse
>
activeMpAccountMap
=
getActiveMpAccountMap
(
openIds
);
log
.
info
(
"Found {} active mp accounts from {} openIds"
,
activeMpAccountMap
.
size
(),
openIds
.
size
());
int
totalMessages
=
pendingMessages
.
size
();
AtomicInteger
skippedInactive
=
new
AtomicInteger
(
0
);
AtomicInteger
skippedDuplicate
=
new
AtomicInteger
(
0
);
AtomicInteger
sentCount
=
new
AtomicInteger
(
0
);
List
<
Long
>
sentMessageIds
=
Collections
.
synchronizedList
(
new
ArrayList
<>());
Set
<
String
>
sentOpenIds
=
ConcurrentHashMap
.
newKeySet
();
// 线程安全的 Set,用于 openId 去重
// 4. 按 openId 去重,筛选出需要发送的消息
List
<
PendingOrderSubscriptionMessageRpcResponse
>
messagesToSend
=
new
ArrayList
<>();
for
(
PendingOrderSubscriptionMessageRpcResponse
message
:
pendingMessages
)
{
String
openId
=
message
.
getOpenId
();
// 检查用户是否活跃
if
(!
activeMpAccountMap
.
containsKey
(
openId
))
{
log
.
debug
(
"Skipping message {} - user {} is not active"
,
message
.
getId
(),
openId
);
skippedInactive
.
incrementAndGet
();
sentMessageIds
.
add
(
message
.
getId
());
continue
;
}
// openId 去重:每个用户只推送一次
if
(!
sentOpenIds
.
add
(
openId
))
{
log
.
debug
(
"Skipping message {} - already queued for openId: {}"
,
message
.
getId
(),
openId
);
skippedDuplicate
.
incrementAndGet
();
sentMessageIds
.
add
(
message
.
getId
());
continue
;
}
messagesToSend
.
add
(
message
);
}
log
.
info
(
"Messages to send: {}, Skipped inactive: {}, Skipped duplicate: {}"
,
messagesToSend
.
size
(),
skippedInactive
.
get
(),
skippedDuplicate
.
get
());
// 5. 多线程并发发送消息
final
OrderTimeConfig
finalOrderTimeConfig
=
orderTimeConfig
;
List
<
CompletableFuture
<
Void
>>
futures
=
messagesToSend
.
stream
()
.
map
(
message
->
CompletableFuture
.
runAsync
(()
->
{
try
{
boolean
success
=
sendReminderMessage
(
message
.
getOpenId
(),
message
,
finalOrderTimeConfig
);
if
(
success
)
{
sentMessageIds
.
add
(
message
.
getId
());
sentCount
.
incrementAndGet
();
}
}
catch
(
Exception
e
)
{
log
.
error
(
"Failed to send reminder to openId: {}, messageId: {}"
,
message
.
getOpenId
(),
message
.
getId
(),
e
);
}
},
asyncExecutor
))
.
toList
();
// 等待所有任务完成
CompletableFuture
.
allOf
(
futures
.
toArray
(
new
CompletableFuture
[
0
])).
join
();
// 6. 批量标记已发送的消息(逻辑删除)
if
(!
sentMessageIds
.
isEmpty
())
{
int
affectedRows
=
mealOrderServiceRpcClient
.
batchDeleteOrderSubscriptionMessagesByIds
(
ENTERPRISE_ID
,
sentMessageIds
);
log
.
info
(
"Marked {} messages as sent"
,
affectedRows
);
}
log
.
info
(
"OrderReminderTask completed. Total: {}, Sent: {}, SkippedInactive: {}, SkippedDuplicate: {}"
,
totalMessages
,
sentCount
.
get
(),
skippedInactive
.
get
(),
skippedDuplicate
.
get
());
}
catch
(
Exception
e
)
{
log
.
error
(
"OrderReminderTask failed"
,
e
);
}
}
/**
* 从配送规则配置中获取订餐时间配置
* 返回订餐时间,格式:yyyy年MM月dd日 HH:mm~HH:mm
*/
private
OrderTimeConfig
getOrderTimeConfigFromRule
()
{
try
{
final
var
response
=
deliveryRuleServiceRpcClient
.
getDefaultDeliveryTemplateAndRule
(
ENTERPRISE_ID
);
final
var
rule
=
response
.
getRule
();
if
(
rule
==
null
||
rule
.
getConfigJson
().
getRulesList
().
isEmpty
())
{
log
.
warn
(
"Delivery rule not configured"
);
return
null
;
}
final
var
cfg
=
rule
.
getConfigJson
().
getRules
(
0
);
final
String
startTimeStr
=
cfg
.
getStartTime
();
final
String
endTimeStr
=
cfg
.
getEndTime
();
if
(
startTimeStr
==
null
||
endTimeStr
==
null
)
{
log
.
warn
(
"Delivery rule time config incomplete"
);
return
null
;
}
// 格式化时间(去掉秒)
String
startTime
=
formatTime
(
startTimeStr
);
String
endTime
=
formatTime
(
endTimeStr
);
// 获取当前日期,格式化为 yyyy年MM月dd日 HH:mm
java
.
time
.
LocalDate
today
=
java
.
time
.
LocalDate
.
now
();
java
.
time
.
format
.
DateTimeFormatter
dateFormatter
=
java
.
time
.
format
.
DateTimeFormatter
.
ofPattern
(
"yyyy年MM月dd日"
);
String
dateStr
=
today
.
format
(
dateFormatter
);
// 返回订餐时间,格式:yyyy年MM月dd日 HH:mm~yyyy年MM月dd日 HH:mm
String
orderTime
=
dateStr
+
" "
+
startTime
+
"~"
+
dateStr
+
" "
+
endTime
;
return
new
OrderTimeConfig
(
orderTime
);
}
catch
(
Exception
e
)
{
log
.
error
(
"Failed to get order time config from rule"
,
e
);
return
null
;
}
}
/**
* 格式化时间字符串(HH:mm:ss -> HH:mm)
*/
private
String
formatTime
(
String
timeStr
)
{
if
(
timeStr
==
null
||
timeStr
.
length
()
<
5
)
{
return
timeStr
;
}
// 取前5位 HH:mm
return
timeStr
.
substring
(
0
,
5
);
}
/**
* 获取活跃的小程序用户 Map(openId -> MpAccount)
*/
private
Map
<
String
,
SingleMpAccountRpcResponse
>
getActiveMpAccountMap
(
Set
<
String
>
openIds
)
{
if
(
CollectionUtils
.
isEmpty
(
openIds
))
{
return
Collections
.
emptyMap
();
}
List
<
SingleMpAccountRpcResponse
>
allAccounts
=
mealOrderServiceRpcClient
.
queryAllActiveMpAccounts
(
ENTERPRISE_ID
);
return
allAccounts
.
stream
()
.
filter
(
account
->
openIds
.
contains
(
account
.
getOpenId
()))
.
collect
(
Collectors
.
toMap
(
SingleMpAccountRpcResponse:
:
getOpenId
,
account
->
account
,
(
a
,
b
)
->
a
));
}
/**
* 发送单条提醒消息
*
* 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示
*/
private
boolean
sendReminderMessage
(
String
openId
,
PendingOrderSubscriptionMessageRpcResponse
message
,
OrderTimeConfig
orderTimeConfig
)
{
String
jumpPath
=
message
.
getJumpPath
();
// 发送消息
SendSubscriptionMessageResponse
response
=
orderReminderService
.
sendOrderReminder
(
openId
,
jumpPath
,
MEAL_TYPE
,
// thing8: 用餐类型
orderTimeConfig
.
orderTime
(),
// time1: 订餐时间
TIPS
// thing2: 温馨提示
);
// 判断发送结果
if
(
response
.
getErrcode
()
!=
null
&&
response
.
getErrcode
()
==
0
)
{
return
true
;
}
// 记录失败原因
log
.
warn
(
"Failed to send reminder. openId: {}, errcode: {}, errmsg: {}"
,
openId
,
response
.
getErrcode
(),
response
.
getErrmsg
());
return
false
;
}
}
src/main/java/com/infoloop/tianting/logic/task/OrderReminderTaskRunner.java
0 → 100644
View file @
47c6297
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"
);
}
}
}
src/main/java/com/infoloop/tianting/service/OrderReminderService.java
View file @
47c6297
...
...
@@ -4,19 +4,24 @@ import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse
/**
* 订餐提醒服务接口
* 用于发送订餐周期开启前的提醒消息
* 用于发送订餐周期开启前的提醒消息
(小程序订阅消息)
*/
public
interface
OrderReminderService
{
/**
* 发送订餐提醒消息
*
* @param openId 用户 OpenID
* @param page 点击跳转页面路径,例如:pages/order/index?cycleId=202501
* @param reminderContent 提醒内容,例如:"下次订餐即将开启"
* @param orderTime 订餐时间,例如:"周一 09:00 至 周三 12:00"
* @param tips 温馨提示,例如:"请点击卡片,准时进入小程序完成预订"
* 小程序模板字段(模板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
reminderContent
,
String
orderTime
,
String
tips
);
String
mealType
,
String
orderTime
,
String
tips
);
}
...
...
src/main/java/com/infoloop/tianting/service/client/MealOrderServiceRpcClient.java
View file @
47c6297
...
...
@@ -12,8 +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
;
...
...
@@ -37,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
;
...
...
@@ -105,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
)
...
...
@@ -328,4 +347,48 @@ public class MealOrderServiceRpcClient {
}
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
();
}
}
...
...
src/main/java/com/infoloop/tianting/service/client/WxOfficialAccountHttpClient.java
0 → 100644
View file @
47c6297
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
);
}
}
src/main/java/com/infoloop/tianting/service/impl/OrderReminderServiceImpl.java
View file @
47c6297
...
...
@@ -16,7 +16,16 @@ import static com.infoloop.tianting.constant.ConfigConstants.MINI_PROGRAM_ORDER_
/**
* 订餐提醒服务实现
* 将业务参数转换为微信订阅消息模板格式并发送
* 使用小程序订阅消息接口发送提醒
*
* 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
* - thing8: 用餐类型(如:下次订单即将开始)
* - time1: 订餐时间(如:13:00~21:00)
* - thing2: 温馨提示(如:为了保证您明日正常用餐,立即订餐!)
*
* 注意:
* 1. 使用的是小程序的 access_token 和 template_id
* 2. openId 是用户在小程序下的 openId
*/
@Slf4j
@Service
...
...
@@ -31,36 +40,41 @@ public class OrderReminderServiceImpl implements OrderReminderService {
@Override
public
SendSubscriptionMessageResponse
sendOrderReminder
(
String
openId
,
String
page
,
String
reminderContent
,
String
mealType
,
String
orderTime
,
String
tips
)
{
log
.
info
(
"Sending order reminder to openId: {}, page: {}"
,
openId
,
page
);
log
.
info
(
"Sending order reminder via MiniProgram to openId: {}, page: {}, mealType: {}, orderTime: {}, tips: {}"
,
openId
,
page
,
mealType
,
orderTime
,
tips
);
// 将业务参数转换为微信模板数据格式
// 根据文档,模板字段为:thing1 (提醒内容), time2 (订餐时间), thing3 (温馨提示)
// 构建小程序订阅消息模板数据
Map
<
String
,
Object
>
data
=
new
HashMap
<>();
// thing
1: 提醒内容
Map
<
String
,
String
>
thing
1
Value
=
new
HashMap
<>();
thing
1Value
.
put
(
"value"
,
reminderContent
);
data
.
put
(
"thing
1"
,
thing1
Value
);
// thing
8: 用餐类型(如:下次订单即将开始)
Map
<
String
,
String
>
thing
8
Value
=
new
HashMap
<>();
thing
8Value
.
put
(
"value"
,
mealType
);
data
.
put
(
"thing
8"
,
thing8
Value
);
// time
2: 订餐时间
Map
<
String
,
String
>
time
2
Value
=
new
HashMap
<>();
time
2
Value
.
put
(
"value"
,
orderTime
);
data
.
put
(
"time
2"
,
time2
Value
);
// time
1: 订餐时间(如:13:00~21:00)
Map
<
String
,
String
>
time
1
Value
=
new
HashMap
<>();
time
1
Value
.
put
(
"value"
,
orderTime
);
data
.
put
(
"time
1"
,
time1
Value
);
// thing
3
: 温馨提示
Map
<
String
,
String
>
thing
3
Value
=
new
HashMap
<>();
thing
3
Value
.
put
(
"value"
,
tips
);
data
.
put
(
"thing
3"
,
thing3
Value
);
// thing
2
: 温馨提示
Map
<
String
,
String
>
thing
2
Value
=
new
HashMap
<>();
thing
2
Value
.
put
(
"value"
,
tips
);
data
.
put
(
"thing
2"
,
thing2
Value
);
try
{
// 使用小程序订阅消息接口
SendSubscriptionMessageResponse
response
=
wxMiniProgramService
.
sendSubscriptionMessage
(
openId
,
templateId
,
page
,
data
);
openId
,
templateId
,
page
,
data
);
if
(
response
.
getErrcode
()
!=
null
&&
response
.
getErrcode
()
==
0
)
{
log
.
info
(
"Order reminder sent successfully to openId: {}"
,
openId
);
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
());
...
...
@@ -68,7 +82,7 @@ public class OrderReminderServiceImpl implements OrderReminderService {
return
response
;
}
catch
(
Exception
e
)
{
log
.
error
(
"Failed to send order reminder to openId: {}"
,
openId
,
e
);
log
.
error
(
"Failed to send order reminder
via MiniProgram
to openId: {}"
,
openId
,
e
);
throw
e
;
}
}
...
...
src/main/proto/MealOrderService.proto
View file @
47c6297
...
...
@@ -69,6 +69,7 @@ service MealOrderServiceRpc {
rpc
GetUserOrderSubscriptionMessageHistory
(
GetUserOrderSubscriptionMessageHistoryRpcRequest
)
returns
(
GetUserOrderSubscriptionMessageHistoryRpcResponse
)
{}
rpc
BatchDeleteOrderSubscriptionMessagesByIds
(
BatchDeleteOrderSubscriptionMessagesByIdsRpcRequest
)
returns
(
BatchDeleteOrderSubscriptionMessagesByIdsRpcResponse
)
{}
rpc
BatchPhysicalDeleteOrderSubscriptionMessagesByIds
(
BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest
)
returns
(
BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcResponse
)
{}
rpc
QueryPendingOrderSubscriptionMessages
(
QueryPendingOrderSubscriptionMessagesRpcRequest
)
returns
(
QueryPendingOrderSubscriptionMessagesRpcResponse
)
{}
}
enum
MealOrderOrderMethodEnum
{
...
...
@@ -1013,3 +1014,24 @@ message BatchPhysicalDeleteOrderSubscriptionMessagesByIdsRpcRequest {
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
;
}
...
...
Please
register
or
login
to post a comment