# 确认订单页（单商品）— 技术方案（C 端）

> **依据：** 《确认订单页（单商品）功能需求.md》v1.0  
> **关联：** 《确认订单页（多商品）技术方案》v1.3（**共用 checkout/order/pay 模块**）、《微信支付入驻与对接技术方案》v1.1、《支付流程.md》v1.3；C 端《商品详情内页技术方案》v1.4  
> **范围：** C 端 **`source=BUY_NOW`** 确认页 preview/submit + **`/api/order/**`** 微信支付/关单；写入 **`biz_order` / `biz_order_item`**（**单行**）；**不含** 购物车路径、C 端 H5 真实调起支付。  
> **原则：** **复用** 多商品 checkout/order/pay；**支付成功** 扣库存；**待支付不扣**；**不删购物车**（`cart_item_id=NULL`）。

---

## 1. 技术架构

| 项 | 选型 |
|----|------|
| 基础框架 | RuoYi **v3.9.2**（`springboot2` 分支） |
| 数据库 | **MySQL 5.7.39** |
| ORM / 响应 | MyBatis；`AjaxResult`（`code`、`msg`、`data`） |
| 鉴权 | 须 **会员 Token**（`MemberWebConfig` → `/api/checkout/**`、`/api/order/**`） |
| 订单号 | `O` + `yyyyMMdd` + 6 位序号（与平台订单方案一致） |
| 支付超时 | `sys_config`：`order.pay.timeout.minutes`，默认 **1440** |
| 支付 v1.2 | **微信 JSAPI**；`wechat.pay.mock=true` 开发直付；生产见《微信支付入驻与对接技术方案》 |
| 结算 | **平台代收**（本期不涉及商户入驻路由） |
| 定时关单 | **`OrderPayTimeoutJob`**（O8） |

### 1.1 与多商品方案的关系

| 维度 | 多商品专册 | **本册（单商品）** |
|------|------------|-------------------|
| 后端模块 | `order` checkout + order + **`pay`** | **同一套**，不另建包 |
| `source` | `CART` / `BUY_NOW` | **固定 `BUY_NOW`** |
| 行数 | ≥1 | **强制 1 行** |
| 购物车 | prepare + pay 后删行 | **不涉及** |
| 运费 | 多行聚合 | **单行简化**（§2.4） |
| 前端页 | 可共用 UI | **独立路由** + 详情跳转（见前端技术方案） |

> **实现策略：** `CheckoutAppServiceImpl` 对 `source=BUY_NOW` 校验 **`items.size()==1`**；preview/submit/pay **与多商品共用**；pay 落单走 **`OrderPaySuccessServiceImpl.completeWeChatPay`**。

### 1.2 模块落位（已实现）

```text
baqing-shop/src/main/java/com/ruoyi/web/modules/
├── order/
│   ├── controller/
│   │   ├── CheckoutAppController.java        # /api/checkout/**  preview/submit
│   │   └── OrderAppController.java           # /api/order/**      pay/cancel/detail
│   ├── service/
│   │   ├── ICheckoutAppService / CheckoutAppServiceImpl   # BUY_NOW 单行分支
│   │   ├── IOrderAppService / OrderAppServiceImpl
│   │   └── IOrderPaySuccessService / OrderPaySuccessServiceImpl
│   ├── support/CheckoutSupport.java
│   └── constant/CheckoutConstants.java       # SOURCE_BUY_NOW 等
└── pay/
    ├── service/IWeChatPayService / WeChatPayServiceImpl
    ├── controller/WeChatPayNotifyController  # POST /api/pay/wechat/notify
    ├── support/WeChatPaySupport              # JSAPI V3 + 回调验签
    └── domain/BizPayRecord                   # biz_pay_record

account/
├── POST /api/member/wx/bind                  # 生产绑 openid（Mock 可跳过）
└── biz_member.wx_openid
```

### 1.3 协作链（立即购买 · 本册主路径）

```text
商品详情 · 立即购买
    → 前端携带 goodsId、quantity、specText（可选）
    → POST /api/checkout/preview  { source: "BUY_NOW", items: [单行], addressId? }
    → 用户改量/换地址 → 再次 preview（或前端本地算量 + 提交前 preview）
    → POST /api/checkout/submit   { source: "BUY_NOW", addressId, items: [单行+remark] }
        → INSERT biz_order(0) + biz_order_item ×1（cart_item_id=NULL）
    → POST /api/order/{orderId}/pay
        → [Mock] ensurePayRecord + completeWeChatPay（扣库存、status=1）
        → [生产] JSAPI 下单 → payParams → 微信回调 → completeWeChatPay
    → 或 POST /api/order/{orderId}/pay/cancel → status=4, close_type=2

biz_member_address ──► consignee_* 快照
biz_goods_freight  ──► 单行运费
```

| 关联模块 | 协作 |
|----------|------|
| **C 端 · 商品详情** | **唯一上游**；`/can-purchase` + 携带 `goodsId/quantity/specText` |
| **C 端 · 我的服务** | 地址 **只读** `/api/member/address/list` |
| **C 端 · 购物车** | **无**；不调用 prepare、不删 cart 行 |
| **平台 · 订单管理** | 同源 `biz_order`；平台 `/agri/order` 履约 |
| **商家 · 运费模版** | `biz_goods_freight` + Facade 计费 |

### 1.4 跨模块 Facade

| 接口 | 本路径用法 |
|------|------------|
| **`IGoodsPurchaseFacade.canPurchase(goodsId)`** | preview/submit **四条件** |
| **`IMemberFacade.isMemberEnabled`** | 全部写操作 |
| **`IFreightTemplateFacade.calcFreight`** | 模版运费（单行） |
| **`BizGoodsFreightMapper.selectByGoodsId`** | 读发品运费配置 |
| **`BizRegionMapper`** | `region_code` → **省级 id**（§2.3） |

**库存扣减（pay 时）：**

```sql
UPDATE biz_goods SET stock = stock - #{qty}
WHERE goods_id = #{goodsId} AND stock >= #{qty} AND del_flag = '0'
-- 影响行数=0 → 支付失败，订单保持待支付
```

---

## 2. 数据库设计

### 2.1 复用表

| 表 | 本路径用途 | DDL |
|----|------------|-----|
| `biz_order` | 主表；**单行** `goods_amount` | `sql/biz_order.sql` |
| `biz_order_item` | **1 条** 明细快照 | `sql/biz_order.sql` + `sql/biz_order_item_checkout.sql` |
| `biz_member_address` | 读地址；快照写主表 | `sql/biz_member.sql` |
| `biz_goods` / `biz_goods_freight` | 价/库/运费 | 已有 |
| `biz_pay_record` | 支付流水；`out_trade_no=order_no` | [`sql/biz_pay_record.sql`](../../../sql/biz_pay_record.sql) |
| `biz_member.wx_openid` | 生产 JSAPI payer.openid | [`sql/biz_member.sql`](../../../sql/biz_member.sql) |
| `biz_goods_service_snapshot` | 预览 `serviceDesc` | 已有 |

**BUY_NOW 特征字段：**

| 字段 | 值 |
|------|-----|
| `biz_order_item.cart_item_id` | **NULL** |
| `biz_order_item.spec_key` | **`''`**（v1 统一规格） |
| `biz_order_item.buyer_remark` | 提交入参 |
| `biz_order_item.service_desc` | 提交时拼接服务快照 |

### 2.2 `biz_order` 写入（与多商品一致）

| 字段 | 说明 |
|------|------|
| order_status | submit **`0`**；pay 成功 **`1`**；cancel **`4`** |
| goods_amount | `unit_price × quantity`（单行） |
| freight_amount | `CheckoutSupport.calcFreightSingle`（§2.4） |
| pay_amount | goods_amount + freight_amount |
| pay_expire_time | create_time + 超时分钟 |
| close_type | cancel → **`2`** 买家取消 |

### 2.3 地址省级解析

`biz_member_address.region_code` 为 **区县级** 编码；运费模版按 **省级** 匹配：

```text
CheckoutSupport.resolveProvinceId(regionCode)
    → 查 biz_region（按 code 或 id）
    → 向上取 level=1 省级 region.id
    → 供 IFreightTemplateFacade.calcFreight(..., provinceId, ...)
```

> 若 `biz_region` 暂无按 code 查询，可在 Mapper 增 `selectByCode` 或维护 **region_code 前 2 位 → 省 id** 映射表；实现细节见 `CheckoutSupport` 注释。

### 2.4 运费（单行 · 简化）

```text
CheckoutSupport.calcFreightSingle(goodsId, quantity, provinceId, goodsAmount)
    freight = BizGoodsFreightMapper.selectByGoodsId(goodsId)
    IF freight IS NULL → throw「商品运费未配置」
    IF freight.freight_type = '1':
        RETURN freight.fixed_amount
    ELSE:
        pieceCount = quantity
        weightKg   = quantity × DEFAULT_KG_PER_PIECE   // v1 默认 1kg/件
        RETURN IFreightTemplateFacade.calcFreight(
            freight.freight_template_id, shopId, provinceId,
            pieceCount, weightKg, goodsAmount)
```

| 规则 | 说明 |
|------|------|
| COS-F1 | 整单 **一个** `freight_amount` |
| COS-F2 | 固定运费 → 直接取 `fixed_amount` |
| COS-F3 | 模版 → 按 **件数 + 商品总价** 满免 |
| COS-F4 | **无** 多行模版合并逻辑 |
| 地址变更 | preview 重算 |

> 多商品路径仍走 `calcFreight(shopId, lines[], …)`；单行可 **内部委托** `calcFreightSingle` 或复用聚合函数传 **1 元素 lines**。

---

## 3. C 端接口 · `/api/checkout`（BUY_NOW）

**鉴权：** `MemberWebConfig` 追加 `/api/checkout/**`

### 3.1 预览 `POST /api/checkout/preview`

**Body：**

| 字段 | 必填 | 说明 |
|------|:----:|------|
| source | ✓ | **固定 `BUY_NOW`** |
| addressId | 否 | 不传则 **默认地址** 或 **唯一地址** |
| items | ✓ | **长度必须为 1**：`[{ goodsId, quantity, specKey?, specText? }]` |

**额外校验（相对多商品）：**

| # | 规则 |
|---|------|
| 1 | `items.length == 1`，否则 400「立即购买仅支持单商品」 |
| 2 | 禁止传 `cartItemIds` |
| 3 | 逐行：`canPurchase` + `quantity <= stock` |
| 4 | 无地址 → 400「请先添加收货地址」（`address` 字段可 null，但 submit 必填） |

**`data` · `CheckoutPreviewVO`：**

| 字段 | 说明 |
|------|------|
| shop | 单店信息 |
| address | 当前选用地址；无则 null |
| items | **1 条** `CheckoutPreviewItemVO` |
| goodsAmount | 单价 × 数量 |
| freightAmount | §2.4 |
| freightDesc | 展示摘要 |
| payAmount | goods + freight |
| payType | **`WECHAT`** |

**`items[0]` 字段：**

| 字段 | 说明 |
|------|------|
| cartItemId | **恒 null** |
| goodsId / goodsName / mainPic | 实时 |
| specText | 入参或 `CartAppServiceImpl.resolveSpecText` 同类逻辑生成 |
| serviceDesc | 服务快照拼接 |
| salePrice / quantity / subtotal / maxStock | |
| freightType / freightDesc | 读 `biz_goods_freight` |

### 3.2 提交 `POST /api/checkout/submit`

**Body：**

| 字段 | 必填 | 说明 |
|------|:----:|------|
| source | ✓ | **`BUY_NOW`** |
| addressId | ✓ | |
| payType | ✓ | **`1`** 微信 |
| items | ✓ | **1 条**：`{ goodsId, quantity, specKey?, specText?, buyerRemark? }` |

**事务：**

```text
1. BUY_NOW 单行校验 + preview 同级四条件
2. freightAmount = calcFreightSingle(...)
3. INSERT biz_order（status=0, pay_expire_time）
4. INSERT biz_order_item ×1（cart_item_id=NULL, buyer_remark, service_desc）
5. 返回 orderId / orderNo / payAmount / payExpireTime
6. 不扣库存；不操作购物车
```

**`CheckoutSubmitResultVO`：**

| 字段 | 说明 |
|------|------|
| orderId / orderNo | |
| payAmount | |
| payExpireTime | |
| wechatPayParams | v1 Mock；前端调 **pay 接口** |

### 3.3 错误 msg

| 场景 | msg |
|------|-----|
| items 非 1 行 | 立即购买仅支持单商品 |
| 未选地址（submit） | 请选择收货地址 |
| 四条件不满足 | `GoodsPurchaseVO.reason` |
| 运费未配置 | 商品运费未配置 |
| 库存不足 | 库存不足 |

---

## 4. C 端接口 · `/api/order`（微信支付 · 取消支付前）

与多商品 **共用** `OrderAppController` / `IWeChatPayService`；BUY_NOW 差异：**`cart_item_id=NULL`，pay 成功后不删购物车行**。

### 4.1 接口一览

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/order/{orderId}/pay` | 发起支付 → `WeChatPayParamsVO` |
| POST | `/api/order/{orderId}/pay/cancel` | 用户取消 → 已关闭 |
| GET | `/api/order/{orderId}` | 支付结果页 / 详情 |
| POST | `/api/member/wx/bind` | 生产绑 openid（Mock **不要求**） |
| POST | `/api/pay/wechat/notify` | 微信回调（Anonymous） |

### 4.2 支付 `POST /api/order/{orderId}/pay`

| 项 | 说明 |
|----|------|
| 归属 | `member_id` 校验 |
| 前置 | `order_status=0` 且 `now < pay_expire_time` |
| Mock | `wechat.pay.mock=true` → 写 `biz_pay_record` + **同步** `completeWeChatPay`；返回 `{ mock: true }`；**无需 openid** |
| 生产 | 须 `wx_openid` → JSAPI 统一下单 → 返回 **payParams**；**回调**落单 |
| 落单 | `OrderPaySuccessServiceImpl.completeWeChatPay`：单行 `deductStock` → `order_status=1` |
| 购物车 | `cart_item_id` 均为 null → **不调用** `ICartFacade.removeItems` |
| 库存失败 | 整单失败；订单 **保持待支付** |

**响应 `WeChatPayParamsVO`：** `orderId`、`orderNo`、`mock`、`payParams`（生产：`appId/timeStamp/nonceStr/package/signType/paySign`）。

> 详见《微信支付入驻与对接技术方案》v1.1、《支付流程.md》§8。

### 4.3 取消支付 `POST /api/order/{orderId}/pay/cancel`

| 项 | 说明 |
|----|------|
| 前置 | `order_status=0` |
| 动作 | `order_status=4, close_type=2` |
| 库存 | 不扣，无需回滚 |

### 4.4 订单详情 `GET /api/order/{orderId}`

支付结果页展示：`orderNo`、`payAmount`、`items[0]`、`payExpireTime`（待支付时剩余秒数）。

---

## 5. Service 分层

```text
CheckoutAppController
    → ICheckoutAppService.preview / submit
        → assertBuyNowSingleLine(items)          # 本册新增
        → CheckoutSupport.buildBuyNowLine(items[0])
        → CheckoutSupport.resolveAddress
        → CheckoutSupport.resolveProvinceId
        → CheckoutSupport.validateFourConditions(goodsId)
        → CheckoutSupport.calcFreightSingle(...)
        → BizOrderMapper.insert + BizOrderItemMapper.insert

OrderAppController
    → IOrderAppService.createPay / cancelPay
        → IWeChatPayService.createJsapiPay
        → [Mock] OrderPaySuccessServiceImpl.completeWeChatPay
WeChatPayNotifyController
    → IWeChatPayService.handleNotify → completeWeChatPay
```

| 要点 | 说明 |
|------|------|
| 单行扣库存 | `completeWeChatPay` 遍历 1 条 `biz_order_item` |
| 不删购物车 | BUY_NOW 明细 `cart_item_id=NULL`，`removeItems` 列表为空 |
| Mock 流水 | `WeChatPayServiceImpl` Mock 分支 **先** `ensurePayRecord` 再返回 |
| specText | BUY_NOW：入参优先；空则读 `biz_goods_attr` 拼接 |
| 服务快照 | `biz_goods_service_snapshot` → `service_desc` |
| 地址快照 | `consignee_address = regionName + " " + detailAddress` |
| 防重复提交 | submit 中 **禁用连点**（前端）+ 可选服务端 member 级短锁 |

---

## 6. 常量与枚举

**`CheckoutConstants.java`（建议）：**

```java
public static final String SOURCE_BUY_NOW = "BUY_NOW";
public static final String SOURCE_CART = "CART";
public static final String MSG_BUY_NOW_SINGLE_ONLY = "立即购买仅支持单商品";
public static final BigDecimal DEFAULT_KG_PER_PIECE = BigDecimal.ONE;
```

**状态枚举：** 与《确认订单页（多商品）技术方案》§2.5 一致。

---

## 7. 与平台后台联动

| 平台操作 | 影响 |
|----------|------|
| 商品下架/改价 | preview/submit **实时校验** |
| 店铺停业 | **阻断** submit |
| 会员禁用 | **403** |
| O8 超时 | Job 关单；C 端 **不可 pay** |
| 模版/发品运费变更 | **不追溯** 已生成订单 |

---

## 8. 业务规则映射

| 需求规则 | 实现 |
|----------|------|
| COS0 须登录 | 拦截器 + `isMemberEnabled` |
| COS-P2 单行 | `assertBuyNowSingleLine` |
| COS-A1 统一地址 | 单 `addressId` |
| COS-Q1/Q2 改量 | preview/submit `quantity` ≤ stock |
| COS-F1～F4 运费 | §2.4 `calcFreightSingle` |
| COS-R1 备注 | `buyer_remark` |
| COS-S2 提交不扣库存 | submit 无 stock UPDATE |
| COS-S3 不经购物车 | 无 cart 协作 |
| COS10 支付扣库存 | `completeWeChatPay` → deductStock |
| COS11 取消关闭 | pay/cancel → close_type=2 |
| COS12 超时 | `pay_expire_time` + Job |
| COS13 四条件 | preview + submit |
| GD12 立即购买 | `source=BUY_NOW` |
| COS8 微信支付 | `IWeChatPayService` + Mock/生产双模式 |

---

## 9. 测试要点

| 编号 | 场景 | 预期 |
|------|------|------|
| T1 | preview items=1 合法 | 200；items 长度 1；金额正确 |
| T2 | preview items=2 | 400 立即购买仅支持单商品 |
| T3 | 无地址 preview | address=null；submit 400 |
| T4 | 固定运费商品 | freightAmount=fixed_amount |
| T5 | 模版+换地址 | freightAmount 变化 |
| T6 | submit 成功 | order_status=0；item 1 条；stock **不变** |
| T7 | pay 成功 | order_status=1；stock **减 quantity** |
| T8 | pay/cancel | order_status=4；stock **不变** |
| T9 | 支付后库存不足并发 | pay 失败；订单仍待支付 |
| T10 | 停业/下架 submit | 400；无订单 |

**自动化测试（已实现）：**

| 类 | 覆盖 |
|----|------|
| `CheckoutBuyNowAppServiceImplTest` | preview/submit BUY_NOW 校验 |
| `BuyNowPayFlowTest` | Mock pay 闭环；无 openid；不删购物车 |
| `OrderAppServiceImplTest` | createPay BUY_NOW 跳过 cart |
| `WeChatPayServiceImplTest` | Mock/生产 openid、pay_record |
| `OrderPaySuccessServiceImplTest` | completeWeChatPay 幂等/扣库存 |

---

## 10. 实现分期

| 阶段 | 内容 | 状态 |
|------|------|------|
| **v1.0** | BUY_NOW preview/submit；order pay/cancel | **已实现** |
| **v1.0·运费** | `calcFreight` + `biz_goods_freight` | **已实现** |
| **v1.2·支付** | 微信 JSAPI + `biz_pay_record` + Mock/生产 + 回调 | **已实现** |
| **v1.1** | 详情联调；支付成功页 | **前端已实现**（Mock pay） |
| **v1.3** | H5 公众号 OAuth + 真实调起支付 | **待联调** |

---

## 11. 非本期

| 项 | 说明 |
|----|------|
| 购物车路径 | 《确认订单页（多商品）技术方案》 |
| 商户微信支付入驻 | 本期平台代收；见《支付流程.md》§0 |
| 我的订单续付列表 | 另册 |
| H5 外链 MWEB 支付 | 未实现 |
| 优惠券/满减 | 需求未要求 |
| 确认页 Redis 会话 | 可选；本期 **无** |

---

## 12. 修订记录

| 版本 | 说明 |
|------|------|
| **v1.2** | 接入微信支付：共用 pay 模块、Mock 写 pay_record、BuyNowPayFlowTest；对齐多商品 v1.2 |
| **v1.0** | 首版：BUY_NOW preview/submit/pay/cancel；单行运费 |

---

*文档版本：v1.2 · 关联《确认订单页（单商品）功能需求.md》v1.0、《确认订单页（多商品）技术方案.md》v1.3、《微信支付入驻与对接技术方案.md》v1.1 · DDL：[`sql/biz_order.sql`](../../../sql/biz_order.sql)、[`sql/biz_pay_record.sql`](../../../sql/biz_pay_record.sql)、[`sql/biz_member.sql`](../../../sql/biz_member.sql)*
