# LINE Pay HTTP Contracts

所有 JSON 业务响应沿用项目 `AjaxResult` 外壳。交易号字段始终是 JSON string。业务错误文本从 `MessageUtils.message(...)` 获取。

## 1. App API

### `POST /pay/line/create`

Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestBody LinePayOrderRequest`.

Request:

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

Success `data`:

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

Rules:

- token 用户必须拥有 ddId 对应订单。
- 只接受订单当前 `payType="3"`，单门店、未支付、合法非终态、门店当前 LINE 版本已验证并启用。
- 同 ddId 有阻断性活跃尝试时返回该行，`reusedAttempt=true`，不再 Request。
- 只有最新尝试已明确 `CANCELLED_OR_EXPIRED/FAILED` 才新增行。
- 父单跨多门店时返回业务错误，不拆分多次支付。

### `POST /pay/line/query`

Annotations 与 create 相同。

Request:

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

Success `data`:

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

该接口只读本地状态，不在 App 请求线程中调用 LINE。

## 2. Merchant Offline API

### Existing `POST /system/orderShOprate/createOrder`

Request 继续使用现有 `OrderCreateInput`，其中 `paymentMethod="3"` 写入子订单 `pay_type="3"`。本增量不接受新的来源字段：

- token 必须对应有效商家用户。
- `items` 必须非空且恰好包含一个门店包，所有商品和金额属于同一门店。
- `userType=1/3` 同时校验 `PosOrder.shId=userId` 和目标 `PosStore.userId=userId`；其他摊位商家按 `PosOrder.mdId=InfoUser.storeId` 校验。
- 服务端为创建出的每个 `PosOrder` 显式写 `order_source="MERCHANT"`；客户端不能传入或覆盖该值。

### `POST /system/orderShOprate/linePay/offline/pay`

Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestBody LinePayOfflinePayRequest`。

Request:

```json
{
  "ddId": "202608180001",
  "oneTimeKey": "123456789012345678"
}
```

Success `data`:

```json
{
  "ddId": "202608180001",
  "paymentId": 84,
  "lineOrderId": "LPOFF20260818...",
  "transactionId": "2026081800000000001",
  "status": "AUTH_REQUIRED",
  "reusedAttempt": false,
  "updatedAt": "2026-08-18T12:34:56+08:00"
}
```

Rules:

- `oneTimeKey` 必须是 18 位数字，只在本次内存调用链使用；响应、异常、数据库和任何日志不得返回原值。
- 订单必须为当前商家拥有的单门店 `MERCHANT` 新订单，且 `payType="3"`、未支付、未取消、未完成、金额大于零、门店凭证已启用。
- 已有任一 Online/Offline 阻断性尝试时不得创建新尝试；已有 Offline 活跃尝试时忽略新 `oneTimeKey`，返回已有本地状态并令 `reusedAttempt=true`。
- 若阻断性活跃尝试属于 Online，付款接口返回业务错误 `line.pay.offline.attempt.blocked`，不得返回 Online 跳转地址、交易上下文或发送 My Code。
- 金额、TWD、商品摘要、storeId、credentialId 和 LINE orderId 全部取服务端事实。
- 首次进入认证等待使用 30 分钟固定截止；未知 Pay/Check 返回码不会按失败释放订单。CAS 冲突重读数据库状态，唯一键冲突复用已有尝试且不调用第二次 Pay。

### `GET /system/orderShOprate/linePay/offline/status?ddId={ddId}`

Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestParam String ddId`。

该接口验证 `MERCHANT` 来源和当前商家归属后只读本地状态，不在请求线程调用 LINE。Success `data` 与付款接口相同，但 `reusedAttempt` 可省略。规范状态：

若订单尚无 Offline 尝试（包括只有 Online 历史/活跃尝试），返回业务错误 `line.pay.offline.attempt.not.found`，不得回传 Online 支付上下文。

```text
AUTH_REQUIRED  1145/1169/AUTH_READY/WAITING_AUTH，客户仍需在 LINE Pay 操作
PROCESSING     Pay 结果未知或重复待核实，后端只读查单中
PAID           已严格核实订单号、交易号、金额和 paymentProvider
CANCELLED      客户取消或支付过期，可使用新的 My Code 重试
FAILED         明确无副作用失败，可使用新的 My Code 重试
MANUAL_REVIEW  恢复截止仍未知或支付事实字段不完整
```

`AUTH_REQUIRED/PROCESSING/PAID/MANUAL_REVIEW` 均阻止再次扫码。金额不一致内部状态 `AMOUNT_MISMATCH` 对外返回 `MANUAL_REVIEW`，持久化 LINE 实际扣款额，并以该金额自动创建唯一全额退款；原订单不再开放支付。

## 3. LINE redirect endpoints

### `GET /pay/line/confirm?orderId={lineOrderId}&transactionId={transactionId}`

- `orderId` 是 LINE Request 的 `line_order_id`，不是 ddId。
- 两个参数都用显式 `@RequestParam`；按 lineOrderId 定位并校验 transactionId。
- 只登记回跳/触发可恢复处理，不同步等待 Confirm。
- 返回 `text/html;charset=UTF-8` 和：
  - `Cache-Control: no-store, no-cache, must-revalidate`
  - `Pragma: no-cache`
  - `Referrer-Policy: no-referrer`
  - `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
- 页面尝试 `com.twanmsdyh.app://payment/result?orderId=<urlEncodedDdId>`，并显示手动按钮和“正在确认支付，请回 App 查询”的安全提示。

### `GET /pay/line/cancel`

Optional explicit query parameters: `orderId`, `transactionId`.

- 只记录浏览器事件并触发只读核对；不能把到达 cancelUrl 当成取消终态。
- 返回与 confirm 相同安全页面/响应头。

## 4. Platform credential API

Base path: `/system/storeLinePay`

### `GET /list`

Permission: `chanting:storeLinePay:list`.

Query uses explicit optional `@RequestParam`: `posName`, `isStall`, `credentialStatus`, `isEnabled`.

Rows include store identity/current version/status/channelId/hasCredential/verifiedTime, never include `channelSecret`.

### `GET /{storeId}`

Permission: `chanting:storeLinePay:query`.

Returns current credential detail. Per approved product decision this privileged response may contain plaintext `channelSecret`.

### `PUT /saveCredentials`

Permission: `chanting:storeLinePay:saveCredentials`.

```json
{
  "storeId": 123,
  "channelId": "2000000000",
  "channelSecret": "plain-secret"
}
```

Business validation is in Controller/Service without `@Valid`. Candidate is verified before an immutable version becomes current and enabled. Failed/unknown probe leaves existing current version untouched.

### `PUT /toggleEnable`

Permission: `chanting:storeLinePay:toggleEnable`.

```json
{
  "storeId": 123,
  "enabled": false
}
```

Only changes whether the current verified version may serve new payments; old transactions remain queryable/refundable through their `credential_id`.

## 5. Platform order API

### `GET /system/order/{id}/status-context`

Extend existing response with:

```json
{
  "linePaymentId": 42,
  "linePaymentStatus": "PAID",
  "lineRefundStatus": null,
  "lineTransactionId": "2026081200000000001"
}
```

Only populate LINE fields when a matching `pos_order_line_payment` exists. Never infer LINE solely from `payType="3"`.

### `POST /system/order/{id}/line-payment/reconcile`

Permission: `system:order:linePaymentReconcile`. Uses the selected paymentId from stable server-side rules, takes a row lease, then performs Retrieve/Check recovery.

### `POST /system/order/{id}/line-refund`

Permission: `system:order:lineRefund`. Creates/uses the unique full-refund row for the selected `PAID` payment. UNKNOWN only retrieves; it never directly repeats Refund.

## 6. LINE v4 outbound contracts

### Request

`POST /v4/payments/request`, 10 second timeout.

```json
{
  "amount": 100,
  "currency": "TWD",
  "orderId": "LP20260812...",
  "packages": [{
    "id": "202608120001",
    "amount": 100,
    "products": [{"name": "Order 202608120001", "quantity": 1, "price": 100}]
  }],
  "redirectUrls": {
    "confirmUrl": "https://api.example/pay/line/confirm",
    "cancelUrl": "https://api.example/pay/line/cancel"
  },
  "options": {
    "payment": {"capture": true}
  }
}
```

The total amount must equal package amount and product `price * quantity` sum.

### Check

`GET /v4/payments/requests/{transactionId}/check`, 20 second timeout.

### Confirm

`POST /v4/payments/{transactionId}/confirm`, 40 second read timeout or greater.

```json
{"amount": 100, "currency": "TWD"}
```

Confirm is sent only after local CAS claim and amount/currency/order/store/credential/active checks.

### Retrieve

`GET /v4/payments?orderId={encodedLineOrderId}` or `?transactionId={encodedTransactionId}`, 20 second timeout. The exact ordered query string is included in the HMAC input.

### Refund

`POST /v4/payments/{transactionId}/refund`, 20 second timeout, body `{}`. `refundAmount` is intentionally omitted for full refund; there is no currency request field.

### Offline Pay

`POST /v4/payments/oneTimeKeys/pay`, read timeout 40 seconds or greater.

```json
{
  "amount": 100,
  "currency": "TWD",
  "orderId": "LPOFF20260818...",
  "oneTimeKey": "<仅发送给 LINE，不记录>",
  "packages": [{
    "id": "202608180001",
    "amount": 100,
    "products": [{"name": "Order 202608180001", "quantity": 1, "price": 100}]
  }]
}
```

- 不发送 redirect URL、设备请求头、Capture 或 Void 字段。
- `0000` 必须核对响应 `orderId`、字符串 `transactionId`、`payInfo[].amount` 合计和非空 `paymentProvider` 后才能落账。
- `1145/1169` 进入 `WAITING_AUTH`；超时、不可解析响应和不明确结果进入 `REQUEST_UNKNOWN`，后续不得再次调用 Pay。

### Offline Check

`GET /v4/payments/orders/{encodedLineOrderId}/check`, 20 second timeout. Path 中使用永久 `lineOrderId`，签名 URI 使用编码后的最终路径。

```text
AUTH_READY -> WAITING_AUTH / AUTH_REQUIRED
COMPLETE   -> strict fact validation -> PAID or AMOUNT_MISMATCH/MANUAL_REVIEW
CANCEL     -> CANCELLED_OR_EXPIRED / CANCELLED
FAIL       -> FAILED
```

### Offline Refund

`POST /v4/payments/orders/{encodedLineOrderId}/refund`, 20 second timeout, body `{}`。`refundAmount` 省略；退款响应未知时沿用 Retrieve/refundList 只读恢复，禁止直接重发。

Online 与 Offline Refund 只能根据持久化的 `payment_mode` 分派，不能根据 URL、transactionId 是否存在或调用者参数判断。

### Required headers

```text
X-LINE-ChannelId: <channel id>
X-LINE-Authorization-Nonce: <unique nonce>
X-LINE-Authorization: Base64(HMAC-SHA256(channelSecret, channelSecret + URI + bodyOrQuery + nonce))
Content-Type: application/json
```

Offline 请求不得添加 `X-LINE-MerchantDeviceProfileId` 或 `X-LINE-MerchantDeviceType`。网关审计保存 Offline REQUEST 时必须删除 `oneTimeKey` 或固定替换为 `<redacted>`；捕获异常时不得拼接请求 DTO/body。

## 7. App deep-link contract

The mobile application must register and handle:

```text
com.twanmsdyh.app://payment/result?orderId=<ddId>
```

On open, the App reads its normal login token and calls `/pay/line/query`. It must not trust Scheme parameters as payment success. Auto-open and fallback button require production-device acceptance testing; Sandbox web payment cannot prove LINE App handoff behavior.
