# LINE Pay Data Model

## Relationship Overview

```text
pos_store 1 ── N pos_store_line_pay (immutable credential versions)
                       │
                       └── current_store_id UNIQUE: one current version/store

pos_order (`order_source=USER/MERCHANT`)
          1 ── N pos_order_line_payment (append-only attempts)
                       │
                       ├── credential_id -> exact credential version
                       ├── payment_mode -> ONLINE/OFFLINE gateway dispatch
                       ├── active_dd_id UNIQUE: one blocking attempt/order
                       └── 1 ── 0..1 pos_order_line_refund

payment_gateway_log ── optional payment_id/refund_id/credential_id references
```

不声明物理外键，保持与现有业务表部署习惯一致；Service 在事务内保证逻辑引用存在。

## 1. `pos_store_line_pay`

不可变门店凭证版本。Secret 按用户决定以明文保存。普通列表仅返回 `hasCredential`，平台详情接口可返回当前版本 Secret。

| Column | Type | Null | Notes |
|---|---|---:|---|
| `id` | BIGINT | no | auto increment |
| `store_id` | BIGINT | no | `pos_store.id` |
| `credential_version` | INT | no | store 内从 1 递增 |
| `channel_id` | VARCHAR(50) ASCII | no | LINE Channel ID；业务层按字符串处理 |
| `channel_secret` | VARCHAR(255) | no | 明文 |
| `environment` | VARCHAR(16) | no | `SANDBOX/PRODUCTION` |
| `credential_status` | VARCHAR(32) | no | `VERIFIED/VERIFY_FAILED`；仅 VERIFIED 可成为 current |
| `is_enabled` | TINYINT | no | current 版本的新支付开关 |
| `current_store_id` | BIGINT | yes | current 时等于 `store_id`，历史版本 NULL |
| `verify_return_code` | VARCHAR(8) | yes | 最后探测结果码 |
| `verify_return_message` | VARCHAR(255) | yes | 摘要 |
| `verified_time` | DATETIME | yes | 验证通过时间 |
| `create_time/update_time` | DATETIME | no | 审计时间 |
| `create_by/update_by` | VARCHAR(64) | yes | 管理员 |

Constraints and indexes:

- `UNIQUE(store_id, credential_version)`
- `UNIQUE(current_store_id)`；MySQL 允许多个 NULL。
- `INDEX(store_id, create_time)`
- 当前版本切换使用事务：锁定当前行/版本，插入新行，再把旧 `current_store_id` 置 NULL，新行置 storeId。
- 相同 Channel ID、Secret 和 environment 重复保存时直接返回现有当前版本，不创建重复行。

## 2. `pos_order_line_payment`

每次真正发起的 LINE Request 对应一行，历史永不覆盖。行内更新仅用于同一尝试的状态推进和补充网关标识。

| Column | Type | Null | Notes |
|---|---|---:|---|
| `id` | BIGINT | no | `paymentId` |
| `dd_id` | VARCHAR(64) | no | `pos_order.dd_id` |
| `line_order_id` | VARCHAR(100) ASCII BINARY | no | Request 前生成，永久唯一 |
| `transaction_id` | VARCHAR(32) ASCII BINARY | yes | Request 成功后获得，禁止 Long |
| `credential_id` | BIGINT | no | 发起时选中的不可变凭证版本 |
| `store_id` | BIGINT | no | 门店快照 |
| `amount` | INT | no | TWD 整数金额 |
| `captured_amount` | INT | yes | LINE 实际扣款金额；正常支付等于 `amount`，金额异常时作为全额退款金额与恢复证据 |
| `currency` | CHAR(3) ASCII | no | 首版 `TWD` |
| `payment_mode` | VARCHAR(16) ASCII | no | `ONLINE/OFFLINE`；默认 `ONLINE`，新尝试显式写入 |
| `payment_url_web` | VARCHAR(1000) | yes | Sandbox/浏览器跳转 URL |
| `payment_url_app` | VARCHAR(1000) | yes | 生产 LINE App URL，可空 |
| `payment_provider` | VARCHAR(16) | yes | 网关原值；进入 `PAID` 时必须非空，已知 `TSP/EPI` 但不封闭枚举 |
| `status` | VARCHAR(40) | no | 下方状态机 |
| `active_dd_id` | VARCHAR(64) | yes | 阻断新尝试时等于 ddId，明确终止后 NULL |
| `version` | BIGINT | no | CAS，从 0 递增 |
| `next_reconcile_at` | DATETIME | yes | 下次扫描 |
| `reconcile_deadline` | DATETIME | yes | 当前恢复阶段截止 |
| `reconcile_count` | INT | no | 默认 0 |
| `lease_owner` | VARCHAR(64) | yes | 行租约节点/批次 ID |
| `lease_until` | DATETIME | yes | 租约过期时间 |
| `status_changed_at` | DATETIME | no | 状态变化时间 |
| `auth_completed_time` | DATETIME | yes | Check 0110 时间 |
| `pay_time` | DATETIME | yes | Capture 时间 |
| `create_time/update_time` | DATETIME | no | 审计时间 |

Constraints and indexes:

- `UNIQUE(line_order_id)`
- `UNIQUE(transaction_id)`；允许多个 NULL。
- `UNIQUE(active_dd_id)`；允许历史多 NULL。
- `INDEX(dd_id, create_time, id)`
- `INDEX(status, next_reconcile_at, id)`
- `INDEX(credential_id, create_time)`
- `INDEX(payment_mode, status, next_reconcile_at, id)`
- 所有状态更新条件至少包含 `id + expected status + version`，领取租约还需 `lease_until IS NULL OR lease_until < NOW()`。
- 既有支付行通过 DDL 默认值保持 `ONLINE`；新建 Online/Offline 尝试必须显式写模式，禁止根据 URL、交易号或状态猜测。

Payment states:

```text
REQUESTING
  ├─ WAITING_AUTH
  ├─ REQUEST_UNKNOWN
  └─ FAILED (only explicit no-side-effect failure; releases active key)

WAITING_AUTH / REQUEST_UNKNOWN
  ├─ READY_CONFIRM
  ├─ PAID
  ├─ CANCELLED_OR_EXPIRED (releases active key)
  ├─ FAILED (releases active key)
  └─ MANUAL_REVIEW

READY_CONFIRM
  ├─ CONFIRMING
  └─ AUTH_DONE_ORDER_CANCELLED

CONFIRMING
  ├─ PAID
  └─ CONFIRM_UNKNOWN

CONFIRM_UNKNOWN
  ├─ PAID
  └─ MANUAL_REVIEW

OFFLINE REQUESTING
  ├─ WAITING_AUTH (1145/1169；AUTH_READY)
  ├─ PAID (0000 且资金事实匹配)
  ├─ REQUEST_UNKNOWN (超时/响应丢失/重复待核实)
  ├─ AMOUNT_MISMATCH (真实付款但金额不一致)
  └─ FAILED (明确无副作用失败；释放 active key)

OFFLINE WAITING_AUTH / REQUEST_UNKNOWN
  ├─ PAID (Check COMPLETE 且事实匹配)
  ├─ CANCELLED_OR_EXPIRED (Check CANCEL；释放 active key)
  ├─ FAILED (Check FAIL；释放 active key)
  ├─ AMOUNT_MISMATCH
  └─ MANUAL_REVIEW (截止仍未知或必要事实缺失)
```

`PAID`、UNKNOWN、`AMOUNT_MISMATCH`、`MANUAL_REVIEW` 不释放 `active_dd_id`。Offline 金额不一致退款成功后原订单仍关闭支付，商家必须创建新订单。旧尝试迟到证实 `PAID` 时照实记录，并阻断/退款，不覆盖当前尝试。

## 3. `pos_order_line_refund`

首版只支持每笔已付款交易一次全额退款。

| Column | Type | Null | Notes |
|---|---|---:|---|
| `id` | BIGINT | no | refund id |
| `payment_id` | BIGINT | no | payment，唯一 |
| `dd_id` | VARCHAR(64) | no | 查询快照 |
| `credential_id` | BIGINT | no | 默认 payment 的凭证版本 |
| `transaction_id` | VARCHAR(32) ASCII BINARY | no | 原支付交易号 |
| `refund_transaction_id` | VARCHAR(32) ASCII BINARY | yes | LINE refund transaction |
| `amount` | INT | no | 本地全额审计；调用时不传 refundAmount |
| `status` | VARCHAR(32) | no | `CREATED/PROCESSING/UNKNOWN/RETRY_WAIT/REFUNDED/FAILED/MANUAL_REVIEW` |
| `source` | VARCHAR(32) | no | `USER_CANCEL/STORE_CANCEL/ADMIN/TASK` |
| `version` | BIGINT | no | CAS |
| `next_reconcile_at/reconcile_deadline` | DATETIME | yes | 恢复调度 |
| `reconcile_count` | INT | no | 默认 0 |
| `lease_owner/lease_until` | VARCHAR(64)/DATETIME | yes | 行租约 |
| `refund_time` | DATETIME | yes | 确证全退时间 |
| `create_time/update_time` | DATETIME | no | 审计时间 |

Constraints and indexes:

- `UNIQUE(payment_id)`
- `UNIQUE(refund_transaction_id)`；允许多个 NULL。
- `INDEX(status, next_reconcile_at, id)`
- `INDEX(dd_id, create_time, id)`
- 退款端点不在退款行重复保存模式；通过不可变 `payment_id` 读取支付行 `payment_mode`，Online 使用 `transaction_id`，Offline 使用 `line_order_id`。

Refund transitions:

```text
CREATED / RETRY_WAIT --CAS claim--> PROCESSING
PROCESSING ├─ REFUNDED
           ├─ UNKNOWN
           ├─ RETRY_WAIT (only documented explicit retryable result)
           └─ FAILED (explicit terminal failure)
UNKNOWN    ├─ REFUNDED (Retrieve refundList proves it)
           └─ MANUAL_REVIEW (deadline)
```

## 4. `payment_gateway_log`

仅记录 LINE Pay；不迁移 OMG。日志采用追加写，不用于决定当前支付状态。

| Column | Type | Null | Notes |
|---|---|---:|---|
| `id` | BIGINT | no | auto increment |
| `correlation_id` | VARCHAR(64) ASCII | no | 同次 request/response 关联 |
| `payment_id/refund_id/credential_id` | BIGINT | yes | 结构化关联 |
| `store_id` | BIGINT | yes | 凭证验证也可关联门店 |
| `dd_id` | VARCHAR(64) | yes | 业务订单 |
| `gateway_order_id` | VARCHAR(100) ASCII BINARY | yes | line_order_id |
| `transaction_id` | VARCHAR(32) ASCII BINARY | yes | 网关交易号 |
| `action` | VARCHAR(32) | no | `CREDENTIAL_VERIFY/REQUEST/CHECK/CONFIRM/RETRIEVE/REFUND/REDIRECT` |
| `direction` | VARCHAR(8) | no | `REQUEST/RESPONSE/EVENT` |
| `source` | VARCHAR(32) | no | `APP/CALLBACK/TASK/ADMIN/CANCEL` |
| `http_status` | INT | yes | HTTP status |
| `return_code` | VARCHAR(8) | yes | LINE code |
| `return_message` | VARCHAR(255) | yes | LINE message |
| `success` | TINYINT | no | transport/business action summary |
| `duration_ms` | BIGINT | yes | elapsed |
| `payload` | LONGTEXT | yes | 脱敏 JSON/body/error summary；Offline REQUEST 不得包含原始 `oneTimeKey` |
| `create_time` | DATETIME | no | append time |

Indexes:

- `(payment_id, create_time)`
- `(refund_id, create_time)`
- `(gateway_order_id, create_time)`
- `(transaction_id, create_time)`
- `(correlation_id)`
- `(store_id, create_time)`

## 5. Order integration

新增字段：

| Column | Type | Null | Notes |
|---|---|---:|---|
| `order_source` | VARCHAR(16) ASCII | no | `USER/MERCHANT`；默认 `USER`，仅商家创建入口显式写 `MERCHANT` |

兼容与写入规则：

- 现有行和用户端新订单使用数据库默认 `USER`，不得根据 userId、创建时间、payType 或入口日志推断/回填。
- `/system/orderShOprate/createOrder` 创建的每个子订单显式写 `MERCHANT`；父订单不作为 Offline 支付归属事实。
- Offline 扫码必须同时满足 `order_source=MERCHANT`、当前 token 商家归属、单门店、未支付、未取消、未完成、金额大于零和 `pay_type="3"`。普通/夜市商家除校验 `sh_id=userId` 外，还必须校验订单 `md_id` 对应 `pos_store.user_id=userId`，禁止组合本人 `shId` 与其他商家的 `mdId` 使用他店凭证。

继续使用现有字段：

- `pay_type="3"`: 新 LINE 订单选择；历史同值不自动视为 LINE，必须存在匹配 Line payment。
- `pay_status=0/1/2`: 未付/已付/已确认全额退款。
- `pay_url`: 当前活跃尝试的 web URL，可作为兼容展示；支付选择以 Line payment 为准。
- `state=4`: 已取消；仍允许记录迟到 `PAID`，随后创建退款 intent。

## 6. Query selection invariants

1. App 以 `ddId` 查询时先读取订单 `pay_status`。
2. 未付款优先唯一 `active_dd_id=ddId`；没有则返回最新终态尝试。
3. 已付款选唯一尚未证实全退的 `PAID`；已退款选带 `REFUNDED` 的付款/退款组合。
4. 出现多条未全退 `PAID` 不自动任选，返回 `MANUAL_REVIEW`。
5. 回跳只按唯一 `line_order_id` 定位，transactionId 存在时必须匹配。
6. 定时/平台动作只按领取到的 `paymentId/refundId` 操作。
7. 商家 Offline 状态查询先验证 `order_source=MERCHANT` 和当前商家归属，再按唯一活跃尝试或最近终态选择 `payment_mode=OFFLINE` 的记录；不得返回其他商家或 Online 尝试的敏感上下文。

## 7. Migration and compatibility

- `updatesql/sql.md` 追加 `pos_order.order_source VARCHAR(16) NOT NULL DEFAULT 'USER'`。
- `updatesql/sql.md` 追加 `pos_order_line_payment.payment_mode VARCHAR(16) NOT NULL DEFAULT 'ONLINE'`、可空 `captured_amount` 及模式扫描索引。
- Entity、MyBatis `paymentColumns`、所有显式 select、insert、update 和 Service 构造参数同步新增字段；不直接执行 DDL。
- 默认值只提供历史兼容，业务代码创建订单/支付尝试时仍必须显式写入来源和模式，防止调用入口变化后静默误分类。
