# 订单与支付模块业务说明

> 本文档描述订单与支付核心链路，涵盖订单生命周期、支付体系、金额字段定义与退款逻辑。
> 帮助开发人员快速理解"为什么这样设计"以及各环节的特殊处理方式。

---

## 一、订单核心实体

### 1.1 Order 实体 (`haha-entity/.../Order.java`)

| 字段 | 类型 | 说明 |
|------|------|------|
| id | Long | 雪花算法主键 |
| orderNo | String | 设备端订单号（设备回调时写入） |
| outTradeNo | String | 外部交易号 |
| activityId | String | 哈哈平台活动ID（用于幂等防重复创建） |
| userId | Long | 用户ID |
| deviceId | String | 设备SN号 |
| totalAmount | BigDecimal | 订单总金额（原价），设备回调时写入 |
| discountAmount | BigDecimal | 订单优惠金额（营销活动 + 优惠券），默认0 |
| paidAmount | BigDecimal | 实付总金额 = totalAmount - discountAmount |
| payStatus | String | 支付状态：UNPAID / PAID / PARTIAL_REFUND / REFUND |
| payType | String | 支付方式 |
| payChannel | String | 支付渠道：wechat / alipay / alipay_credit / balance / wechat_payscore |
| transactionId | String | 第三方交易号 |
| currentRefundId | Long | 当前退款记录ID（关联 t_refund.id，冗余便于快速查询） |
| refundStatus | String | 退款状态（PENDING/REFUNDED/REJECTED，汇总字段） |
| refundAmount | BigDecimal | 退款金额（汇总字段） |
| refundTime | LocalDateTime | 退款时间 |
| refundReason | String | 退款原因 |
| payScoreOrderId | String | 支付分服务订单号（微信侧） |
| payScoreState | String | 支付分状态：CREATED / DOING / USER_PAYING / DONE / REVOKED |
| payScoreFailReason | String | 支付分扣款失败原因（记录微信 API 返回的错误信息） |
| serviceId | String | 支付分服务ID |
| serviceStartTime | LocalDateTime | 服务开始时间 |
| serviceEndTime | LocalDateTime | 服务结束时间 |
| status | Integer | 订单状态：0-已取消 1-待支付 2-已完成 3-已关闭 |
| orderType | String | 订单类型：OUT-正常消费，IN-异物放入 |
| items | String | 商品识别信息JSON |
| videoUrl | String | 识别视频URL |
| confidence | BigDecimal | AI识别置信度 |

### 1.2 订单状态枚举 (`OrderStatus`)

| code | 枚举值 | 说明 |
|------|--------|------|
| 0 | CANCELLED | 已取消 |
| 1 | PENDING_PAYMENT | 待支付 |
| 2 | COMPLETED | 已完成 |
| 3 | CLOSED | 已关闭 |

### 1.3 支付状态枚举 (`PayStatus`)

| code | 枚举值 | 说明 |
|------|--------|------|
| UNPAID | 未支付 | 订单已创建，等待支付 |
| PAID | 已支付 | 扣费成功，未发生退款 |
| PARTIAL_REFUND | 部分退款 | 累计退款 > 0 且 < 实付金额，可继续退款 |
| REFUND | 全额退款 | 累计退款 ≥ 实付金额，退款入口关闭 |

---

## 二、订单完整生命周期

```
用户扫码开门
    ↓
DoorRecord 记录开门（openType=OUT, doorStatus=OPENED）
    ↓
用户购物，关门
    ↓
设备AI识别回调 handleOrcResult()
    ↓
创建订单 createOrderFromRecognition()
    - totalAmount = 0, discountAmount = 0, paidAmount = 0
    - status = PENDING_PAYMENT, payStatus = UNPAID
    ↓
设备端计算金额（商品原价 - 营销活动优惠）
    ↓
订单回调 handleOrderCallback()
    - 写入 totalAmount（设备传来的已含营销活动优惠的金额）
    - 初始化 discountAmount = 0, paidAmount = totalAmount
    ↓
优惠券自动扣减 processCouponDiscount()
    - 如有可用优惠券：计算优惠 → 更新 discountAmount / paidAmount
    ↓
支付分扣费 / 其他支付渠道
    - 使用 paidAmount 进行扣费
    ↓
支付回调 → 更新 payStatus = PAID, status = COMPLETED
    ↓
订单完成 → 触发邀请奖励激活
```

### 关键说明

1. **AI识别结果不含价格**：`sku_list` 只包含 `{code, quantity}`，商品价格在设备端查询
2. **订单创建时金额为0**：因AI识别回调不含价格，金额在订单回调时由设备端传入
3. **营销活动优惠在设备端计算**：设备查询适用活动并计算折扣，传回的是扣减后金额
4. **优惠券优惠在服务端计算**：`HahaCallbackServiceImpl.processCouponDiscount()` 自动匹配

### 业务背景与设计考量

#### 为什么AI识别结果不含价格？

哈哈平台（设备端SDK）的AI视觉识别只负责识别"哪个商品被拿走了"，返回商品编码（code）和数量（quantity），不涉及定价逻辑。商品价格由哈哈平台根据设备绑定的门店信息独立维护，服务端无法在AI识别回调时获取价格。这是设备平台架构决定的——**识别与定价解耦**。

#### 为什么营销活动优惠在设备端计算？

设备端在用户关门后需要**立即展示应付金额**，如果等服务端计算再返回，用户等待时间过长。此外，哈哈平台设备端本身具备查询营销活动和计算优惠的能力。因此采用**设备端计算、服务端信任**的策略：
- 设备端：查询营销活动 → 计算优惠 → 回调通知服务端
- 服务端：`MarketingRuleService` 作为备用校验能力，但当前不覆盖主流程

#### 为什么优惠券是自动扣减而非用户手动选择？

智能售卖机的购物场景是**用户关门后自动结算**，没有购物车确认环节，用户无法在结算时手动选择优惠券。因此系统在订单回调时自动为用户匹配最优优惠券（优先使用最先到期的符合条件的券），这是业务场景决定的特殊处理。

#### 为什么订单创建时金额为0？

AI识别回调（`handleOrcResult`）只告知"哪些商品被拿走了"，不含价格信息。价格和最终金额要等设备端完成计算后通过订单回调（`handleOrderCallback`）传回。两步回调之间有时间差，期间订单金额为0是正常状态，不是异常。

#### 订单幂等性保证

通过 `activityId`（哈哈平台活动ID）保证订单不重复创建。AI识别回调可能因网络重试多次到达，`createOrderFromRecognition()` 先查询是否已有该 activityId 的订单，有则直接返回，避免重复创建。

---

## 三、金额字段体系

### 3.1 三级金额定义

| 字段 | 含义 | 计算方式 | 赋值时机 |
|------|------|---------|---------|
| totalAmount | 订单总金额（原价） | 设备端计算 | 订单回调时由设备传入 |
| discountAmount | 订单优惠金额 | 营销活动优惠 + 优惠券优惠 | 优惠券扣减时写入 |
| paidAmount | 实付总金额 | totalAmount - discountAmount | 优惠券扣减后计算 |

#### 为什么需要 discountAmount 和 paidAmount 字段？

**业务背景**：早期订单表只有 `totalAmount`，运营平台和小程序展示“实付金额”时直接取 `totalAmount`。但引入优惠券后，`totalAmount` 存的是设备端传来的金额（已含营销活动优惠但不含优惠券优惠），导致前端显示的“实付”不正确。

**解决方案**：增加 `discountAmount`（记录优惠总额）和 `paidAmount`（记录实付总额），使三级金额语义清晰，前端取值简单可靠。

**特殊处理**：
- 设备端传来的 `totalAmount` 已包含营销活动优惠（如首单立减5元后传95元），但营销活动优惠金额不单独持久化到订单表
- `discountAmount` 目前仅记录**服务端计算的优惠券优惠**部分
- 历史订单迁移：`UPDATE t_order SET discount_amount = 0, paid_amount = total_amount WHERE ...`

### 3.2 金额赋值阶段

| 阶段 | totalAmount | discountAmount | paidAmount |
|------|-------------|----------------|------------|
| 创建订单 | 0 | 0 | 0 |
| 订单回调 | 设备传来 | 0 | = totalAmount |
| 优惠券扣减 | 不变 | 优惠券金额 | = total - discount |
| 支付扣费 | 不变 | 不变 | 使用此金额 |

### 3.3 前端兼容性处理

所有前端展示优先使用 `paidAmount`，兼容历史数据：

```typescript
// 小程序
const displayAmount = order.paidAmount || order.totalAmount || 0;

// 运营平台
const amount = row.paidAmount || (row.totalAmount || 0) - (row.discountAmount || 0);
```

**退款页面**使用 `paidAmount` 作为退款金额上限，退款金额不能超过用户实际支付的金额。

#### 为什么退款基于 paidAmount？

退款金额基于用户实际支付的金额（`paidAmount`）来计算和校验：
- 全额退款时退 `paidAmount`（实付金额）
- 部分退款时，累计退款金额不能超过 `paidAmount`
- 这确保了用户不会退回超过自己实际支付的金额，避免因优惠券/营销活动优惠导致的"多退"问题

---

## 四、支付体系

### 4.1 支付渠道 (`PaymentChannel`)

| code | 枚举值 | 说明 |
|------|--------|------|
| wechat | WECHAT | 微信支付（JSAPI） |
| alipay | ALIPAY | 支付宝支付 |
| alipay_credit | ALIPAY_CREDIT | 支付宝信用支付（芝麻信用） |
| balance | BALANCE | 余额支付 |
| wechat_payscore | WECHAT_PAYSCORE | 微信支付分（先享后付） |

### 4.2 支付架构

```
PaymentService (门面)
    ├── PaymentChannelFactory (渠道工厂)
    ├── WxPayStrategy (微信支付策略)
    ├── AliPayStrategy (支付宝策略)
    ├── AliCreditPayStrategy (信用支付策略)
    └── PayScoreService (支付分服务)
```

- **策略模式**：`PaymentStrategy` 接口，每种渠道一个实现
- **门面服务**：`PaymentServiceImpl` 统一调度，上层无需感知渠道差异

### 4.3 微信支付分流程（核心支付方式）

#### 为什么微信支付分是核心支付方式？

智能售卖机场景下，用户**先开门购物，后结算付款**，属于典型的“先享后付”场景。微信支付分天然支持这种模式：
1. 用户扫码时创建支付分服务订单（不扣款，仅授权）
2. 用户购物完成后，服务端完结支付分订单（自动扣款）
3. 无需用户在购物后手动支付，体验最流畅

相比传统支付（用户需主动扫码付款），支付分消除了“忘记付款”的风险，也提升了转化率。

#### 扫码开门的支付分强制校验

为保证扣费安全性，扫码开门流程中会强制检查用户是否已开通微信支付分：
- 未开通 → 引导用户授权开通 → 开通成功后才能开门
- 已开通 → 创建支付分服务订单 → 开门
- 这保证了每个开门用户都具备扣费能力，避免“开门购物但无法扣款”的风险

```
用户扫码 → 检查支付分开通状态
    ↓
未开通 → enablePayscore() → 引导用户授权
    ↓
已开通 → createUserPayScoreOrder() → 创建支付分服务订单
    ↓
用户开门购物 → 关门 → 订单回调
    ↓
completeUserPayScoreOrder() → 完结支付分（自动扣款）
    ↓
  扣款成功(DONE) ──→ 微信回调 PAYSCORE.USER_PAID ──→ 订单完成
  扣款失败(余额不足等) ──→ 本地标记 USER_PAYING，记录 failReason
    ↓                        ↓
  微信支付分自动推送待支付提醒     PayScoreSyncTask 每 5 分钟同步状态
    ↓                        ↓
  用户完成支付 → 微信回调 → DONE → 订单完成
```

**支付分服务订单状态（PayScoreState 枚举）**：
- CREATED：已创建（用户扫码时创建服务订单）
- DOING：进行中（用户确认使用服务，正在购物）
- USER_PAYING：用户支付中（扣款已发起但用户尚未完成支付，如银行卡余额不足）
- DONE：已完成（扣款成功）
- REVOKED：已取消（用户或商户取消服务订单）

#### 扣款失败的 USER_PAYING 处理

当完结支付分订单时，若用户微信绑定的银行卡余额不足，微信 API 返回状态为 `USER_PAYING`，此时：

1. **服务端标记**：`payScoreState` 设为 `USER_PAYING`，`payScoreFailReason` 记录失败原因
2. **微信端自动催收**：微信支付分平台会自动向用户推送"待支付提醒"通知，无需商户额外开发
3. **定时状态同步**：`PayScoreSyncTask` 每 5 分钟查询 `USER_PAYING` 订单并调用微信 `queryServiceOrder` 同步最新状态
4. **超时预警**：超过 24 小时仍未支付则记录 warn 日志，供运营关注处理

```
扣款失败(USER_PAYING)
    ↓
本地: payScoreState=USER_PAYING, payScoreFailReason=记录原因
    ↓
微信端自动推送待支付提醒（微信原生能力）
    ↓
PayScoreSyncTask 每 5 分钟同步
    ↓
用户支付成功 → 微信回调 PAYSCORE.USER_PAID → DONE → 订单完成
用户超时未付 → 24h 后 warn 日志 → 运营介入
```

### 4.4 退款流程

#### 退款数据模型

退款功能使用独立的 `t_refund` 和 `t_refund_item` 表管理，不再仅依赖 `t_order` 上的汇总字段。

**t_refund（退款记录表）**：

| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT | 退款记录ID（雪花算法） |
| order_id | BIGINT | 订单ID |
| order_no | VARCHAR(64) | 订单号 |
| refund_no | VARCHAR(64) | 退款单号（RF + orderNo + timestamp） |
| refund_amount | DECIMAL(10,2) | 本次退款金额 |
| total_amount | DECIMAL(10,2) | 订单原始总金额 |
| status | VARCHAR(20) | 退款状态：PENDING/REFUNDED/REJECTED |
| reason | VARCHAR(500) | 退款原因 |
| remark | VARCHAR(500) | 审核备注 |
| transaction_id | VARCHAR(100) | 第三方退款交易号 |
| refund_time | DATETIME | 退款完成时间 |
| reviewer_id | BIGINT | 审核人ID |
| review_time | DATETIME | 审核时间 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |

**t_refund_item（退款商品明细表）**：

| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT | 退款商品明细ID |
| refund_id | BIGINT | 退款记录ID（关联 t_refund.id） |
| order_goods_id | BIGINT | 订单商品明细ID（关联 t_order_goods.id） |
| product_id | BIGINT | 商品ID |
| product_name | VARCHAR(255) | 商品名称 |
| pic | VARCHAR(500) | 商品图片 |
| quantity | INT | 退款数量 |
| price | DECIMAL(10,2) | 商品单价 |
| refund_amount | DECIMAL(10,2) | 退款金额（quantity * price） |

**t_order 汇总字段**（旧字段保留，冗余便于订单列表直接展示）：

| 字段 | 说明 |
|------|------|
| currentRefundId | 当前退款记录ID |
| refundStatus | 退款状态汇总 |
| refundAmount | 退款金额汇总 |
| refundTime | 退款时间 |
| refundReason | 退款原因 |

#### 退款金额三种模式

| 优先级 | 模式 | 触发条件 |
|--------|------|---------|
| 1（最高） | 按商品退款 | 传入 `products` 列表，金额 = Σ(quantity × price) |
| 2 | 自定义金额退款 | 传入 `refundAmount` |
| 3 | 全额退款 | 两者都不传，金额 = paidAmount |

**金额校验**：
- 退款金额 ≤ `paidAmount - 该订单历史累计已退款金额`
- 逐商品校验：退款数量 ≤ `购买数量 - 该商品已退款数量`
- 确保多次部分退款总额不超过实付金额，已全额退款的商品不可重复选择

#### 退款流程

用户端和管理后台走不同的退款路径：

**用户端（小程序）**：提交申请 → 等待审核

```
用户提交退款（refund.vue）
  → POST /payment/refund
  → PaymentServiceImpl.refund(Map)  // immediate=false（默认）
  → RefundService.createRefundRecord()
      ├── 创建 t_refund（status=PENDING）
      ├── 创建 t_refund_item（商品明细）
      ├── 更新 t_order.refundStatus = PENDING
      └── 返回”退款申请已提交，请等待审核”
  → 管理员在运营平台审核
      ├── 通过 → approveRefund()
      │         → 执行第三方退款 API
      │         → t_refund.status = REFUNDED
      │         → t_order.refundAmount 累加
      │         → 累计 ≥ 实付 → payStatus = REFUND
      │         → 累计 < 实付 → payStatus = PARTIAL_REFUND
      └── 拒绝 → rejectRefund()
                → t_refund.status = REJECTED
                → 清除 t_order.refundStatus / currentRefundId
                → 保留 t_order.refundAmount（累计已退金额）
                → 用户可重新提交申请
```

**管理后台直接退款**：即时执行

```
管理员在订单列表点击退款（hook.tsx handleRefund）
  → POST /order/{id}/refund
  → PaymentServiceImpl.refund(Map)  // immediate=true
  → RefundService.createRefundRecord()
  → 立即执行第三方退款 API
  → t_refund.status = REFUNDED
```

**多次退款**：

```
订单实付 100 元，购 3 件商品：
  第一次退 2 件，金额 60 元 → payStatus = PARTIAL_REFUND，refundAmount = 60
  第二次退 1 件，金额 40 元 → 累计 100 ≥ 100 → payStatus = REFUND
  退款入口关闭，已退商品不可再选
```

#### 退款追溯

- 每笔退款在 `t_refund_item` 中记录了退款商品明细（`order_goods_id`、`product_id`、`product_name`、`quantity`、`price`），可通过 `refund_id` 关联查询
- 管理后台退款详情页展示的是实际退款商品，而非订单全部商品
- 小程序订单详情页展示退款记录时间线（绿色=已退款，红色=已拒绝，橙色=审核中），含每次申请的金额、原因、商品明细和拒绝原因

#### 退款特殊处理

- **支付分退款**：支付分订单完结后走微信退款接口，需传入原始支付分订单号。每次退款生成唯一 `out_refund_no`（含时间戳），避免重试时参数不一致导致微信 `INVALID_REQUEST`
- **优惠券不可退回**：订单退款时，已使用的优惠券状态不会回滚为”未使用”，这是当前的业务规则
- **部分退款**：支持选择商品退款（金额由商品自动计算）或自定义金额退款，多次退款累计不超过实付金额。已全额退款的商品前端禁选、后端校验
- **异物放入订单**（orderType=IN）不展示给用户端，退款需在运营平台操作

---

## 五、核心代码文件索引

| 文件 | 职责 |
|------|------|
| `haha-entity/.../Order.java` | 订单实体 |
| `haha-entity/.../OrderGoods.java` | 订单商品明细 |
| `haha-entity/.../Refund.java` | 退款记录实体（t_refund） |
| `haha-entity/.../RefundItem.java` | 退款商品明细实体（t_refund_item） |
| `haha-entity/.../dto/RefundDTO.java` | 退款请求 DTO（含 products、refundAmount） |
| `haha-mapper/.../RefundMapper.java` | 退款 Mapper |
| `haha-mapper/.../RefundItemMapper.java` | 退款商品明细 Mapper |
| `haha-service/.../RefundService.java` | 退款服务接口 |
| `haha-service/.../impl/RefundServiceImpl.java` | 退款服务实现（创建、审核、查询退款） |
| `haha-service/.../OrderServiceImpl.java` | 订单服务（创建、查询、退款） |
| `haha-service/.../HahaCallbackServiceImpl.java` | 设备回调处理（订单回调、优惠券扣减） |
| `haha-service/.../payment/PaymentService.java` | 支付门面接口 |
| `haha-service/.../payment/impl/PaymentServiceImpl.java` | 支付门面实现（含退款编排逻辑） |
| `haha-service/.../payment/payscore/PayScoreServiceImpl.java` | 支付分服务实现（含扣款失败 USER_PAYING 处理） |
| `haha-service/.../payment/payscore/impl/WxPayScoreStrategy.java` | 微信支付分 API 策略实现 |
| `haha-admin/.../task/PayScoreSyncTask.java` | 支付分 USER_PAYING 状态定时同步任务 |
| `haha-admin/.../OrderController.java` | 运营平台订单API |
| `haha-admin/.../RefundApplicationController.java` | 运营平台退款申请管理API |
| `haha-miniapp/.../OrderController.java` | 小程序订单API |
| `haha-miniapp/.../PaymentController.java` | 小程序支付API（含退款接口） |
| `haha-miniapp/.../PayScoreController.java` | 小程序支付分API |
| `haha-common/.../constant/OrderConstants.java` | 订单常量定义 |
| `haha-common/.../constant/PaymentConstants.java` | 支付常量（退款前缀、退款期限等） |
| `haha-common/.../enums/OrderStatus.java` | 订单状态枚举 |
| `haha-common/.../enums/PayStatus.java` | 支付状态枚举 |
| `haha-common/.../enums/PaymentChannel.java` | 支付渠道枚举 |
| `haha-common/.../enums/RefundStatus.java` | 退款状态枚举 |
| `haha-common/.../enums/PayScoreState.java` | 支付分状态枚举 |
| `haha-common/.../vo/RefundApplicationVO.java` | 退款申请视图对象 |
| `haha-common/.../vo/RefundProductVO.java` | 退款商品视图对象 |
| `haha-admin/.../db/migration/V5__create_refund_tables.sql` | 退款表 DDL 迁移脚本 |
