# 订单超时自动取消功能

## 功能概述

为待支付订单（微信/支付宝）添加 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)
}
```
