Files
junhong_cmp_fiber/docs/order-expiration/功能总结.md
huang e661b59bb9
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m58s
feat: 实现订单超时自动取消功能,支持钱包余额解冻和 Asynq Scheduler 统一调度
- 新增 expires_at 字段和复合索引,待支付订单 30 分钟超时自动取消
- 实现 cancelOrder/unfreezeWalletForCancel 钱包余额解冻逻辑
- 创建 Asynq 定时任务(order_expire/alert_check/data_cleanup)
- 将原有 time.Ticker 轮询迁移至 Asynq Scheduler 统一调度
- 同步 delta specs 到 main specs 并归档变更
2026-02-28 17:16:15 +08:00

182 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 订单超时自动取消功能
## 功能概述
为待支付订单(微信/支付宝)添加 30 分钟超时自动取消机制。超时后自动取消订单并解冻钱包余额(如有冻结)。
## 核心设计
### 超时流程
```
用户下单(微信/支付宝)
├── 设置 expires_at = 当前时间 + 30 分钟
├── 订单状态: payment_status = 1待支付
├── 场景 1: 用户在 30 分钟内支付
│ ├── 支付成功 → 清除 expires_at设为 NULL
│ └── 订单正常完成
└── 场景 2: 超过 30 分钟未支付
├── Asynq Scheduler 每分钟触发扫描
├── 查询 expires_at <= NOW() AND payment_status = 1
├── 取消订单 → payment_status = 5已取消
├── 清除 expires_at
└── 解冻钱包余额(如有)
```
### 不设置超时的场景
- **钱包支付**:立即扣款,无需超时
- **线下支付**:管理员手动确认,无需超时
- **混合支付**:需要在线支付部分才设置超时
## 技术实现
### 数据库变更
```sql
-- 迁移文件: migrations/000069_add_order_expiration.up.sql
ALTER TABLE tb_order ADD COLUMN expires_at TIMESTAMPTZ;
-- 部分索引: 仅索引待支付订单,减少索引大小
CREATE INDEX idx_order_expires ON tb_order (expires_at, payment_status)
WHERE expires_at IS NOT NULL AND payment_status = 1;
```
### 涉及文件
| 层级 | 文件 | 变更说明 |
|------|------|----------|
| 迁移 | `migrations/000069_add_order_expiration.up.sql` | 添加 expires_at 字段和索引 |
| 迁移 | `migrations/000069_add_order_expiration.down.sql` | 回滚脚本 |
| 常量 | `pkg/constants/constants.go` | 添加任务类型和超时参数 |
| 模型 | `internal/model/order.go` | 添加 ExpiresAt 字段 |
| DTO | `internal/model/dto/order_dto.go` | 添加 ExpiresAt、IsExpired 响应字段 |
| Store | `internal/store/postgres/order_store.go` | 添加 FindExpiredOrders、is_expired 过滤 |
| Service | `internal/service/order/service.go` | 创建订单设置超时、取消逻辑、批量取消 |
| 任务 | `internal/task/order_expire.go` | 订单超时任务处理器 |
| 任务 | `internal/task/alert_check.go` | 告警检查任务处理器(从 ticker 迁移) |
| 任务 | `internal/task/data_cleanup.go` | 数据清理任务处理器(从 ticker 迁移) |
| 队列 | `pkg/queue/types.go` | 添加 OrderExpirer 接口和 WorkerStores/Services 字段 |
| 队列 | `pkg/queue/handler.go` | 注册 3 个新任务处理器 |
| Bootstrap | `internal/bootstrap/worker_stores.go` | 添加 CardWallet Store |
| Bootstrap | `internal/bootstrap/worker_services.go` | 添加 OrderService 初始化 |
| Worker | `cmd/worker/main.go` | 替换 ticker 为 Asynq Scheduler |
### 常量定义
```go
// pkg/constants/constants.go
TaskTypeOrderExpire = "order:expire" // 订单超时任务
TaskTypeAlertCheck = "alert:check" // 告警检查任务
TaskTypeDataCleanup = "data:cleanup" // 数据清理任务
OrderExpireTimeout = 30 * time.Minute // 订单超时时间
OrderExpireBatchSize = 100 // 每次批量取消数量
```
### 接口变更
#### 订单列表查询新增过滤参数
```
GET /api/admin/orders?is_expired=true
GET /api/h5/orders?is_expired=true
```
- `is_expired=true`: 仅返回已超时的订单
- `is_expired=false`: 仅返回未超时的订单
#### 订单响应新增字段
```json
{
"expires_at": "2025-02-28T12:30:00+08:00",
"is_expired": false
}
```
- `expires_at`: 超时时间,`null` 表示无超时(钱包/线下支付)
- `is_expired`: 是否已超时(计算字段)
## 定时任务调度器重构
### 变更前time.Ticker
```go
// cmd/worker/main.go 中的 goroutine
alertChecker := startAlertChecker(ctx, ...) // time.Ticker 每分钟
cleanupChecker := startCleanupScheduler(ctx, ...) // time.Timer 每天凌晨 2 点
```
**问题**
- 单点运行,无法分布式
- 无重试机制
- 无任务状态监控
### 变更后Asynq Scheduler
```go
// Asynq Scheduler 统一管理
asynqScheduler.Register("@every 1m", asynq.NewTask("order:expire", nil))
asynqScheduler.Register("@every 1m", asynq.NewTask("alert:check", nil))
asynqScheduler.Register("0 2 * * *", asynq.NewTask("data:cleanup", nil))
```
**优势**
- 通过 Redis 实现分布式调度
- 自动重试失败任务
- 可通过 Asynq Dashboard 监控
- 统一的任务处理模式
### 调度规则
| 任务 | 调度表达式 | 说明 |
|------|-----------|------|
| 订单超时取消 | `@every 1m` | 每分钟扫描一次 |
| 告警检查 | `@every 1m` | 每分钟检查一次 |
| 数据清理 | `0 2 * * *` | 每天凌晨 2 点执行 |
## 钱包解冻逻辑
### 取消订单时的解冻流程
```
cancelOrder(ctx, order)
├── 幂等更新: WHERE payment_status = 1 → 5
├── 清除 expires_at
├── 如果是代理钱包支付 (payment_method = wallet, buyer_type = agent)
│ └── AgentWalletStore.UnfreezeBalanceWithTx(tx, shopID, amount)
└── 如果是卡钱包支付 (payment_method = wallet/mixed, buyer_type != agent)
└── 直接更新 frozen_balance -= amount (WHERE frozen_balance >= amount)
```
### 幂等性保障
- 使用 `WHERE payment_status = 1` 条件更新,确保只取消待支付订单
- `RowsAffected == 0` 说明订单已被处理(已支付或已取消),直接跳过
- 批量取消时,单个订单失败不影响其他订单
## 循环依赖解决方案
`internal/service/order` 导入 `pkg/queue`(使用 queue.Client`pkg/queue/types.go` 需要引用 OrderService。
**解决方案**:在 `pkg/queue/types.go` 定义 `OrderExpirer` 接口,`internal/task/order_expire.go` 定义同名局部接口。Go 的结构化类型系统使 `order.Service` 自动满足两个接口,无需显式声明。
```go
// pkg/queue/types.go
type OrderExpirer interface {
CancelExpiredOrders(ctx context.Context) (int, error)
}
// WorkerServices 中使用接口类型
OrderExpirer OrderExpirer
// internal/task/order_expire.go局部接口避免导入 pkg/queue
type OrderExpirer interface {
CancelExpiredOrders(ctx context.Context) (int, error)
}
```