# LINE Pay 前端接入文档(客户 App)

> 适用对象:客户 App(uni-app)前端开发人员
> 对应后端:foodie_server `019-line-pay`,`LinePayController`(`/pay/line/**`)
> 后端契约权威:本目录 `contracts/api.md`、`spec.md`
> 最后更新:2026-08-13

本文档只覆盖**客户 App 需要对接的部分**:发起支付、跳转 LINE 收银台、接收 App Scheme 回跳、查询最终结果。门店凭证配置(平台管理端)详见 `contracts/api.md` 第 3 节,不在本文档范围。

---

## 1. 概述

LINE Pay 直连是项目餐饮订单的线上支付渠道之一,订单字段 `pos_order.pay_type = "3"` 即表示该笔订单使用 LINE Pay。

**本期约束(前端必须知道):**

- 仅支持**单门店**餐饮订单;多门店父单无法发起 LINE Pay(后端会拒绝)。
- 币种固定 **TWD**,金额为整数,全部由**服务端订单决定**,前端不传金额。
- 仅支持**全额退款**,且退款由后端在订单取消时自动处理,**前端没有独立的退款接口**。
- LINE Pay 不提供同步成功回调。**支付结果永远以 `/pay/line/query` 的返回为准**,绝不能信任 Scheme 参数或 LINE 收银台页面判断是否付款成功。

---

## 2. 整体流程

```mermaid
sequenceDiagram
    participant App as 客户 App
    participant Server as foodie_server
    participant Line as LINE 收银台

    App->>Server: ① 下单(订单 payType=3, LINE Pay)
    App->>Server: ② POST /pay/line/create {ddId}
    Server-->>App: 返回 paymentUrl + 状态 WAITING_AUTH
    App->>Line: ③ 在 WebView / 外部浏览器打开 paymentUrl
    Note over Line: 用户完成 LINE 登录授权
    Line->>Server: ④ LINE 回跳 /pay/line/confirm(后端处理)
    Server-->>Line: 返回中间页 HTML(快速返回,不同步 Confirm)
    Note over Server: 后端异步 Confirm + 请款
    Server->>App: ⑤ 中间页唤起 Scheme<br/>com.twanmsdyh.app://payment/result?orderId=ddId
    App->>Server: ⑥ POST /pay/line/query {ddId}(带 token)
    Server-->>App: 返回最终状态(PAID / 退款等)
    Note over App: 若仍处理中,轮询 query 直到终态
```

**一句话流程:** 下单选 LINE Pay → 调 `create` 拿收银台链接 → 打开让用户授权 → App 被 Scheme 唤回 → 调 `query` 拿最终结果。

---

## 3. 前置条件

1. **门店已开通 LINE Pay 并启用**:订单所属门店在平台管理端配置了有效 Channel 凭证且处于启用状态。否则 `create` 会返回业务错误。
2. **订单 `payType = "3"`**:下单时必须把支付方式指定为 LINE Pay。后端 `create` 会校验当前 `payType`,**不允许 create 时临时切换渠道**。
3. **订单可支付**:未支付、未取消、未完成。堂食订单(`state=1`)也可支付。
4. **用户已登录**:`create` / `query` 都需要请求头携带登录 `token`。
5. **App 已注册自定义 Scheme**:见第 5 节。

---

## 4. 接口说明

所有接口走项目统一 `AjaxResult` 外壳:`{ code, msg, data }`。`code === 200` 为成功,其余为业务错误,`msg` 为国际化错误文案(直接展示即可)。

> **重要:`transactionId` 是 19 位长数字,必须按字符串处理。** 用 JS `Number` / 算术运算会导致精度丢失。JSON 解析、存储、比较全程保持字符串。

### 4.1 创建支付 `POST /pay/line/create`

向 LINE 发起支付请求,返回 LINE 收银台地址。

**请求**

- Header:`token: <登录 token>`(必填)
- Body(JSON):

```json
{
  "ddId": "202608120001"
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `ddId` | string | 是 | 业务订单号 `pos_order.dd_id` |

**成功响应 `data`**

```json
{
  "ddId": "202608120001",
  "paymentId": 42,
  "lineOrderId": "LP2026081200010001",
  "transactionId": "2026081200000000001",
  "paymentUrl": "https://sandbox-web-pay.line.me/...",
  "status": "WAITING_AUTH",
  "reusedAttempt": false
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `ddId` | string | 回显订单号 |
| `paymentId` | number | 后端支付流水主键,排查问题时提供 |
| `lineOrderId` | string | 后端生成的 LINE 订单号(**不是 ddId**),前端一般不用 |
| `transactionId` | string \| null | LINE 19 位交易号,**字符串**,可能为空(尚未生成时) |
| `paymentUrl` | string | **LINE 收银台地址,前端需在浏览器/WebView 打开它** |
| `status` | string | 支付状态(见第 6 节),正常为 `WAITING_AUTH` |
| `reusedAttempt` | boolean | `true` 表示本次复用了已有支付尝试(见下方说明) |

**关于 `reusedAttempt`(重复点击处理):**

后端保证同一订单**任意时刻最多一条活跃 LINE 支付尝试**。前端重复点击「去支付」时:

- 若上一笔仍在等待授权 / 处理中,后端**复用原尝试**,返回**同一个 `paymentUrl`**,`reusedAttempt = true` —— 前端直接用返回的 `paymentUrl` 跳转即可,**不要当成错误**。
- 若上一笔已被 LINE 明确判为取消 / 过期 / 失败,后端才会新建尝试,`reusedAttempt = false`。
- 若已支付,返回的 `status` 为 `PAID`。

**业务错误(`code !== 200`)**

`msg` 已国际化,典型场景:订单不存在 / 非本人订单 / `payType` 不是 3 / 多门店父单 / 订单不可支付 / 门店未开通或未启用 / 已有处理中尝试需稍后重试。前端直接展示 `msg`,无需自己映射。

---

### 4.2 查询支付 `POST /pay/line/query`

查询订单的支付/退款最终状态。**该接口只读本地数据库,不会同步去调 LINE,响应很快。**

**请求**

- Header:`token: <登录 token>`(必填)
- Body(JSON):

```json
{
  "ddId": "202608120001"
}
```

**成功响应 `data`**

```json
{
  "ddId": "202608120001",
  "paymentId": 42,
  "orderPayStatus": 1,
  "paymentStatus": "PAID",
  "refundStatus": null,
  "transactionId": "2026081200000000001",
  "updatedAt": "2026-08-12T12:34:56+08:00"
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `ddId` | string | 回显订单号 |
| `paymentId` | number | 当前选中的支付流水主键 |
| `orderPayStatus` | number | **订单支付状态**,见下表 |
| `paymentStatus` | string | LINE 支付流水状态,见第 6 节 |
| `refundStatus` | string \| null | 退款状态,未退款为 `null`,见第 6 节 |
| `transactionId` | string \| null | LINE 交易号,**字符串** |
| `updatedAt` | string | 最近更新时间(ISO-8601) |

**`orderPayStatus` 枚举(前端主要依据这个判定结果)**

| 值 | 含义 | 前端处理 |
|---|---|---|
| `0` | 未支付 | 支付尚未完成(看 `paymentStatus` 判断是否还在处理中) |
| `1` | 已支付 | 支付成功,展示成功页 |
| `2` | 已退款 | 已全额退款(取消订单后触发) |

---

## 5. App Scheme 回跳

LINE 收银台授权完成后,LINE 会回跳后端 `/pay/line/confirm`,后端返回一个**中间页**。该中间页会尝试唤起 App,并显示「支付结果确认中,请回 App 查看」的提示和「打开 App」按钮。

**前端必须注册并处理以下固定 Scheme:**

```text
com.twanmsdyh.app://payment/result?orderId=<URL编码后的 ddId>
```

**处理规则(强制):**

1. App 被 Scheme 唤起后,**读取本地登录 token**,带上 `ddId = orderId 参数` 调用 `POST /pay/line/query`。
2. **绝不能把 Scheme 到达当作支付成功。** Scheme 参数可被伪造,`orderId` 只是用于回查的订单号,不含任何支付结果。
3. `query` 返回 `orderPayStatus === 1` 才算真正成功。
4. 若 `query` 返回仍在处理中(见第 7 节轮询),进入轮询,直到拿到终态或超时提示。

> **Scheme 注册 + 自动唤起 + 兜底按钮,需在 iOS / Android 真机验收**(Sandbox 无法模拟 LINE App 内置浏览器和 App 自动唤起行为)。在真机接入前,中间页本身已能安全显示提示并保留「打开 App」按钮。

---

## 6. 状态枚举

### 6.1 `paymentStatus`(LINE 支付流水状态)

| 状态 | 含义 | 是否终态 |
|---|---|---|
| `WAITING_AUTH` | 等待用户在 LINE 授权 | 否 |
| `READY_CONFIRM` | 已授权,待后端确认 | 否 |
| `CONFIRMING` | 后端确认中 | 否 |
| `PAID` | **已支付** | 是 |
| `CANCELLED_OR_EXPIRED` | 用户取消或授权过期 | 是 |
| `FAILED` | 支付失败 | 是 |
| `MANUAL_REVIEW` | 进入人工核对(未知结果超时) | 是(需联系平台处理) |
| `REQUESTING` / `REQUEST_UNKNOWN` | 请求中 / 请求结果未知 | 否(后端会恢复) |
| `CONFIRM_UNKNOWN` | 确认结果未知 | 否(后端会恢复) |

> 前端通常**只需重点识别 `PAID` 和非终态**。复杂的中间态由后端定时任务自动恢复,前端遇到非终态按第 7 节轮询即可。

### 6.2 `refundStatus`(退款状态)

| 状态 | 含义 |
|---|---|
| `null` | 无退款 |
| `CREATED` / `PROCESSING` | 退款处理中 |
| `REFUNDED` | **已全额退款** |
| `FAILED` | 退款失败 |
| `MANUAL_REVIEW` | 退款进入人工核对 |

---

## 7. 接入步骤清单

按顺序完成即可:

1. **下单接口**:确保下单时能把订单 `payType` 设为 `"3"`(LINE Pay)。
2. **支付按钮**:用户点击「LINE Pay 支付」时,调用 `POST /pay/line/create`,拿到 `paymentUrl`。
3. **打开收银台**:用 WebView(或外部浏览器)打开 `paymentUrl`。
   - 若返回 `reusedAttempt === true`,正常使用同一个 `paymentUrl`,无需提示。
   - 若返回 `status === "PAID"`,直接进成功流程,不必再跳转。
4. **注册 Scheme**:在 App 配置中注册 `com.twanmsdyh.app://payment/result`,回调里取出 `orderId`。
5. **回跳查询**:Scheme 回调触发后,带 token 调 `POST /pay/line/query`。
6. **结果判定**:
   - `orderPayStatus === 1` → 支付成功。
   - `orderPayStatus === 2` → 已退款(取消订单场景)。
   - `paymentStatus` 为 `PAID` 但订单处理中 → 走轮询。
7. **轮询**(见下):若结果尚未终态,按固定间隔重试 `query`。
8. **真机验收**:上线前在 iOS / Android 真机完成端到端支付 + Scheme 唤回验证。

### 轮询策略建议

`/pay/line/confirm` 是**异步确认 + 请款**的,最长可能需要数十秒。Scheme 唤回后第一次 `query` 可能仍是 `WAITING_AUTH` / `CONFIRMING`,需轮询:

```text
首次 query 后,若未终态:
  - 每隔 3 秒查一次
  - 最多查 10 次(约 30 秒)
  - 仍未终态 → 提示「支付结果确认中,请稍后在订单列表查看」,不要报错
```

> 即使 App 被杀或 Scheme 丢失也没关系:后端有独立定时任务会主动向 LINE 确认/查询并落账,用户下次打开订单时调 `query` 仍能拿到最终结果。

---

## 8. 代码示例(参考)

```javascript
// 1. 创建支付
async function startLinePay(ddId, token) {
  const res = await request({
    url: '/pay/line/create',
    method: 'POST',
    header: { token },
    data: { ddId }
  });
  if (res.code !== 200) {
    // res.msg 已是国际化文案,直接提示
    uni.showToast({ title: res.msg, icon: 'none' });
    return;
  }
  const { paymentUrl, status, reusedAttempt } = res.data;

  // 已支付,无需跳转
  if (status === 'PAID') {
    goOrderResult(ddId);
    return;
  }
  // 打开 LINE 收银台(reusedAttempt=true 也用同一个 url)
  // #ifdef APP-PLUS
  plus.runtime.openURL(paymentUrl); // 或用 webview
  // #endif
}

// 2. Scheme 回调里(query 的入口)
//     App scheme: com.twanmsdyh.app://payment/result?orderId=<ddId>
function onPaymentScheme(orderId) {
  pollLinePayQuery(orderId, getToken());
}

// 3. 轮询查询最终结果
async function pollLinePayQuery(ddId, token) {
  const MAX = 10, INTERVAL = 3000;
  for (let i = 0; i < MAX; i++) {
    const res = await request({
      url: '/pay/line/query',
      method: 'POST',
      header: { token },
      data: { ddId }
    });
    if (res.code !== 200) {
      uni.showToast({ title: res.msg, icon: 'none' });
      return;
    }
    const { orderPayStatus, paymentStatus } = res.data;

    if (orderPayStatus === 1) {        // 已支付
      goOrderResult(ddId);
      return;
    }
    if (orderPayStatus === 2) {        // 已退款
      goOrderResult(ddId);
      return;
    }
    // 终态失败(CANCELLED_OR_EXPIRED / FAILED)也结束
    if (['CANCELLED_OR_EXPIRED', 'FAILED'].includes(paymentStatus)) {
      uni.showToast({ title: '支付未完成', icon: 'none' });
      return;
    }
    // 否则继续等待
    await sleep(INTERVAL);
  }
  // 超时:不报错,引导用户稍后回订单列表查看
  uni.showToast({ title: '支付结果确认中,请稍后查看订单', icon: 'none' });
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
```

---

## 9. 注意事项 / FAQ

**Q1:能不能用 Scheme 到达判断支付成功?**
不能。Scheme 只是「回到 App」的信号,参数可伪造且不含结果。**一律以 `query` 的 `orderPayStatus === 1` 为准。**

**Q2:`transactionId` 为什么是字符串?**
它是 LINE 的 19 位交易号,超过 JS 安全整数范围(`2^53`),用 `Number` 会丢精度。解析 JSON、传参、比较全部保持字符串。

**Q3:用户重复点击「去支付」会重复扣款吗?**
不会。后端保证同订单最多一条活跃尝试,重复点击返回同一 `paymentUrl`(`reusedAttempt=true`)。

**Q4:用户授权完关了页面、或没装 App、或 Scheme 没唤起怎么办?**
不影响结果。后端定时任务会主动向 LINE 确认并落账。用户重新打开订单页调 `query` 即可拿到最终状态。前端轮询超时后**不要报错**,提示「结果确认中」即可。

**Q5:为什么 `create` 报错「订单支付方式不符」?**
`create` 要求订单当前 `payType` 必须是 `"3"`。请检查下单接口是否正确设置了 LINE Pay。

**Q6:需要前端处理退款吗?**
不需要。退款是全额退款,由后端在用户/商家**取消订单**时自动发起。前端通过 `query` 的 `orderPayStatus === 2` 或 `refundStatus === "REFUNDED"` 感知即可。

**Q7:金额前端传吗?**
不传。金额、币种(TWD)全部由服务端订单决定,前端只传 `ddId`。

**Q8:`MANUAL_REVIEW` 怎么处理?**
支付/退款结果未知且超过后端追踪期限会进入人工核对。前端展示「支付结果确认中,请联系客服」即可,不要允许用户重新发起(此时仍占用订单支付位)。

---

## 10. 接口速查

| 接口 | 方法 | 路径 | Header | Body |
|---|---|---|---|---|
| 创建支付 | POST | `/pay/line/create` | `token` | `{ "ddId": "..." }` |
| 查询支付 | POST | `/pay/line/query` | `token` | `{ "ddId": "..."` } |
| LINE 回跳(后端) | GET | `/pay/line/confirm` | — | LINE 调用,前端不直接对接 |
| App Scheme | — | `com.twanmsdyh.app://payment/result?orderId=<ddId>` | — | 中间页唤起 |

> 域名以实际部署为准(生产公网回跳域名如 `https://foodieapi.waimai-paotui.com`),由后端配置,前端只需用相对路径 + 网关统一前缀。
