# FSD m07 · 支付与对账（payment）

LLMS 索引： [llms.txt](/llms.txt)

---

> 本册覆盖代码域 `internal/domain/payment`（两表：`payment_order` 状态表 + `payment_webhook_event` 技术表）。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §2（支付单状态机 §2.2）。

## m07 功能节目录

| ID | 名称 | 路由/入口 |
|---|---|---|
| F7.01 | 定金支付（Stripe Checkout） | `POST /pt/payments`、`GET /pt/payments` |
| F7.02 | webhook 验签与幂等 | `POST /webhooks/stripe` |
| F7.03 | 支付凭证 PDF | `GET /pt/payments/:id/receipt`、`GET /api/payments/:id/receipt` |
| F7.04 | 后台支付管理与对账 | `GET /api/payments`、`GET /api/payments/pending` |
| F7.05 | 残留单人工关单 | `POST /api/payments/:id/close` |

---

#### F7.01 定金支付（Stripe Checkout）

> 对应 SRS：F-PAY-001 ｜ 实现落点：`internal/domain/payment/service.go:103`（CreateForPatient）/`:493`（ListMine）/`pure.go`（DepositCents/AmountToCents 纯函数）/`main.go:297-298`（路由） ｜ 操作入口：app H5 `#/pages/pay` 支付页（确认方案后创建）

##### 1 功能定义

患者为最新工单创建定金支付单：前置谓词工单须 PENDING_PAYMENT → 定金计算（estimate_amount>0 按 `deposit_percent` 比例四舍五入；=0 回落 config 固定额——DIF-M6 ④）→ INSERT PENDING 单（**PENDING 复用谓词**：同工单已有 PENDING 单复用重新 CreateCheckout 保 URL 活性，防残留单堆积——⚠ DIF-M8 ⑦）→ 返回 Stripe Checkout 跳转 URL。

##### 2 触发条件与前置状态

pt JWT；工单 `status='PENDING_PAYMENT'`（否则 409 ErrNotPayable）；provider=config（`stripe` 真供应商 / `fake` 本地假 URL 供 dev/e2e——装配期校验非法值 fatal）。

##### 3 输入与校验

无业务入参（patient_id=claims.Sub 直查最新工单，越权不可能——DIF-M6 ⑤ 直查口径）。

##### 4 处理流程

```mermaid
sequenceDiagram
    participant A as app 支付页
    participant H as payment.Handler
    participant S as payment.Service
    participant DB as MySQL
    participant ST as Stripe/Fake Provider
    A->>H: POST /pt/payments
    H->>S: CreateForPatient
    S->>DB: SELECT ticket WHERE patient_id ORDER BY id DESC LIMIT 1
    alt 无工单 / 非 PENDING_PAYMENT
        S-->>A: 404 / 409 ErrNotPayable
    end
    S->>DB: WithTx: qPendingReuse 查同工单 PENDING 单
    alt 已有 PENDING 单
        S->>S: 复用（reused=true）
    else
        S->>DB: INSERT payment_order(PENDING, amount=DepositCents(...))
    end
    S->>ST: CreateCheckout(order, AmountToCents)
    S->>DB: UPDATE provider_ref=checkout session id（非状态列回填）
    S->>DB: COMMIT
    H-->>A: 200 {order_id, ticket_id, checkout_url, amount, currency}
    A->>ST: 跳转完成支付（回调走 F7.02）
```

##### 5 输出与结果状态

`{order_id, ticket_id, checkout_url, amount, currency}`；`payment_order` 行（PENDING，provider_ref=checkout session id 作 webhook 回单锚）。

##### 6 状态流转

`→ PENDING`（新单或复用单重新签 URL）；后续迁移见 F7.02；FAILED 单不阻塞重建（失败回退重谈语义——新单）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 无工单 | 404 ErrTicketNotFound |
| 工单不在 PENDING_PAYMENT | 409 ErrNotPayable |
| 已有 PENDING 残留单 | 复用+重新 CreateCheckout（W4 根因修复——多次进入支付页不再堆单） |
| estimate_amount=0（未填/存量单） | 定金=固定额（M6 兼容证明：单测断言 200.0 保持绿） |
| CreateCheckout 失败 | 事务回滚（复用单 URL 不变） |
| 金额精度 | `AmountToCents` 纯函数转 Stripe 分单位；比例计算禁 float64 直接乘（四舍五入纯函数） |

##### 8 权限与数据规则

pt JWT + patient_id 直查；定金比例 config 化（`payment.deposit_percent` 缺省 20——DIF-005 产品未定关联）；PayPal/支付宝国际版=provider 枚举预留未实现（MVP Stripe 单供应商，用户裁决——DIF-M6 ③）。

---

#### F7.02 webhook 验签与幂等

> 对应 SRS：F-PAY-001（状态更新） ｜ 实现落点：`internal/domain/payment/service.go:166`（HandleWebhook）/`:188`（applyWebhook）/`pure.go`（ClassifyEvent/ExtractOrderAnchor）/`handler_webhook.go` ｜ 操作入口：—（Stripe 服务器回调）

##### 1 功能定义

Stripe 回调全路径：验签（stripe-go `webhook.ConstructEvent` 离线 HMAC，300s 容差官方校验）→ 事件类型白名单分类（IGNORE/SUCCESS/FAILURE）→ **TryClaim**（INSERT `payment_webhook_event`，uk_provider_event 冲突=重复投递直接 200）→ 同事务业务（订单 CAS + 工单流转 + outbox 邮件 all-or-nothing——比「Claim 分离+5min 扫表」更简，无悬挂态，崩溃靠供应商重投——DIF-M6 ②）→ Commit 后 SSE + 凭证补偿。

##### 2 触发条件与前置状态

`POST /webhooks/stripe`（零 JWT——供应商验签即鉴权）；webhook_secret 空=一律 503（可选缺省降级可见）。

##### 3 输入与校验

| 输入 | 校验 |
|---|---|
| payload + Stripe-Signature 头 | 验签失败 400 ErrBadSignature |
| event.type | 白名单 3 类：checkout.session.completed→SUCCESS；失败类→FAILURE；白名单外→200 忽略零副作用（防供应商加事件类型打挂我们） |
| 订单锚 | ExtractOrderAnchor（session.id 或 metadata.order_id）；无锚/无对应单→200 忽略+Warn |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant ST as Stripe
    participant H as webhook.Handler
    participant S as payment.Service
    participant DB as MySQL（单一事务）
    participant T as ticket.Service
    ST->>H: POST /webhooks/stripe (event_id + signature)
    H->>S: HandleWebhook
    alt secret 未配置
        S-->>ST: 503
    else 验签失败
        S-->>ST: 400
    end
    S->>S: ClassifyEvent 白名单
    S->>DB: TryClaim: INSERT payment_webhook_event(uk_provider_event)
    alt 冲突（重复投递）
        S-->>ST: 200 幂等零副作用
    end
    S->>DB: 回单锚（provider_ref / id）→ 支付单
    alt 订单 CAS PENDING→SUCCEEDED/FAILED
        S->>DB: TransitionOrderInTx（双日志含回调报文摘要）
    else CAS 冲突（已处理）
        S-->>ST: 200 幂等忽略
    end
    alt 工单在 PENDING_PAYMENT
        S->>DB: ticket.TransitionInTx（成功→PENDING_DEPARTURE / 失败→回退边1 PLAN_CONFIRMING）
        S->>DB: outbox 患者邮件（payment_succeeded/payment_failed）
    else 工单已不在（重谈后旧单迟到）
        S->>S: 跳过工单轴+邮件，订单轴照常落（人工对账）
    end
    S->>DB: COMMIT
    S->>T: NotifyTransition 三 key SSE（仅工单轴移动时）
    S->>S: 成功→Commit 后补偿生成凭证 PDF（失败仅 Warn 不反压）
    S-->>ST: 200
```

##### 5 输出与结果状态

HTTP 200（幂等路径与成功路径同码）；订单/工单状态迁移+双日志；患者邮件 outbox；凭证对象落桶+`receipt_object_key` 回填（幂等谓词 `AND receipt_object_key=''`）。

##### 6 状态流转

订单 `PENDING → SUCCEEDED / FAILED`；工单 `PENDING_PAYMENT → PENDING_DEPARTURE`（成功）/ `→ PLAN_CONFIRMING`（失败=回退边 1）；paid_at 非状态列回填（`AND paid_at IS NULL` 谓词幂等）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 重复投递（同 event_id） | 200 零副作用（TryClaim uk 冲突） |
| 订单 CAS 冲突（已前进） | 200 幂等忽略 |
| 工单已不在 PENDING_PAYMENT | 跳过工单轴与邮件、订单轴照常（Warn 留痕，人工对账归 F7.04） |
| 无订单锚/锚无对应单 | 200 忽略 + Warn |
| 崩溃窗口 | 事务 all-or-nothing，靠 Stripe 重投（无扫表 worker——DIF-M6 ② 裁决） |
| 白名单外事件类型 | 200 忽略零副作用（不落库） |

##### 8 权限与数据规则

供应商验签即鉴权；op_log reason 含回调报文截断 512 字符（tech-design §5.2「日志含回调报文」落点）；actor=0/SYSTEM。

---

#### F7.03 支付凭证 PDF

> 对应 SRS：F-PAY-001（支付凭证） ｜ 实现落点：`internal/domain/payment/service.go:360`（Receipt）/`:317`（generateReceipt）/`receipt.go`（BuildReceiptPDF 手写最小 PDF 生成器）/`main.go:299-301`（路由） ｜ 操作入口：app `#/pages/progress` 凭证下载按钮；web 工单详情支付节点凭证按钮

##### 1 功能定义

支付成功后生成一页纯英文凭证 PDF（手写最小生成器：Helvetica 标准基字体零嵌入、纯函数+golden 字节断言+xref 自洽校验——零外部依赖，DIF-M10 ①）。生成时机双轨：webhook Commit 后补偿 + 下载侧惰性自愈（key 空且 SUCCEEDED 现场生成回填，覆盖补偿失败窗口）。

##### 2 触发条件与前置状态

- 患者：`GET /pt/payments/:id/receipt`（归属谓词 JOIN ticket.patient_id——DIF-M6 ⑤ 直查口径）。
- B 端：`GET /api/payments/:id/receipt`（payment:read 对账/纠纷查证面）。
- 非-SUCCEEDED 或能力未装配（receiptIO nil）→ 409 ErrReceiptNotReady。

##### 3 输入与校验

`:orderId`；归属不符与不存在同语义 404（不泄露存在性）。数据源 `GET /pt/payments` 列表（本人全部支付单，凭证按钮取最新 SUCCEEDED 单）。

##### 4 处理流程

```mermaid
flowchart LR
    A["GET .../receipt"] --> B{归属谓词通过?}
    B -->|否| E404[404]
    B -->|是| C{SUCCEEDED 且 key 或能力齐?}
    C -->|否| E409[409 ErrReceiptNotReady]
    C -->|key 空| D["惰性自愈：现场 BuildReceiptPDF → Put → 回填(幂等谓词)"]
    C -->|key 有| G[store.Get]
    D --> G
    G --> H["200 application/pdf（文件名 receipt-ticketNo-orderId.pdf）"]
```

##### 5 输出与结果状态

PDF blob（application/pdf）；`receipt_object_key` 已回填（惰性路径）；文件名 `ReceiptFilename(ticketNo, orderID)`。

##### 6 状态流转

无状态变更（凭证键非状态列回填，AST 射程外——provider_ref/paid_at 先例）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 订单非 SUCCEEDED | 409 ErrReceiptNotReady（409 快路径零多余查询） |
| webhook 补偿生成失败 | 仅 Warn 不反压 200——下载侧惰性自愈兜底（零重试端点/零扫表 worker） |
| receiptIO 未装配 | 409（降级可见，沿 webhookSecret 缺省模式） |
| 惰性自愈并发双写 | 回填幂等谓词只留先到者 |
| 中文 PDF | MVP 不做（需求出现再引 gopdf——receipt.go 单文件隔离零外溢） |

##### 8 权限与数据规则

患者本人（JOIN 谓词）/ payment:read（B 端）；PDF 内容=订单号/工单号/供应商/金额/交易 ref/时间（纯英文）。

---

#### F7.04 后台支付管理与对账

> 对应 SRS：F-PAY-001（状态更新）/F-ADMIN-003（对账 tab） ｜ 实现落点：`internal/domain/payment/service.go:339`（ListByTicket）/`:456`（ListPendingOrders）/`main.go:300-303`（路由） ｜ 操作入口：web 工单详情支付节点；`/admin/board` 对账 tab

##### 1 功能定义

B 端查询工单支付单列表（web 详情内嵌支付节点）与残留单对账列表（PENDING 单 JOIN 工单状态+账龄小时数，倒序 LIMIT 100）——识别「进了支付页没付/回调丢失」的孤儿单。

##### 2 触发条件与前置状态

`payment:read`（CONSULTANT+ADMIN）；对账查询 `ops:read`。

##### 3 输入与校验

`:ticketId` / 无参（pending 列表）；账龄为 service 侧整数小时截断（展示语义，纯计算不进 SQL）。

##### 4 处理流程

```mermaid
flowchart LR
    A["GET /api/payments?ticket_id"] --> B["SELECT * WHERE ticket_id ORDER BY id DESC"]
    C["GET /api/payments/pending"] --> D["PENDING 单 JOIN ticket.status → {…, age_hours}"]
```

##### 5 输出与结果状态

订单数组（json tag snake_case 整形后——DIF-M8 ⑧）；对账行含 ticket_status 交叉视角。

##### 6 状态流转

只读；关单写路径在 F7.05。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 工单无支付单 | 空数组 |
| 残留单 >100 | 只出最新 100（量级 MVP） |
| 旧单迟到（重谈后） | 对账列表可见其 FAILED/SUCCEEDED 终态，工单轴已跳过——人工核对场景 |

##### 8 权限与数据规则

payment:read / ops:read 双码分层；webhook 路径 actor=0/"SYSTEM" 与人工操作区分（TransitionOrderInTx 扩 actor 入参——DIF-M8 ⑦）。

---

#### F7.05 残留单人工关单

> 对应 SRS：—（对账兜底，SRS 无对应） ｜ 实现落点：`internal/domain/payment/service.go:476`（ClosePendingOrder）/`main.go:304`（路由） ｜ 操作入口：web `/admin/board` 对账 tab「关单」按钮

##### 1 功能定义

ADMIN 对确认作废的 PENDING 残留单人工关单：`PENDING → FAILED`（orderTransitions 既有边，零白名单改动），TransitionOrderInTx 双日志同事务，actor=操作管理员。

##### 2 触发条件与前置状态

`ops:manage`（仅 ADMIN——读写分离双码，DIF-M8 ④ 裁决否决 payment:read 兼职写操作）；reason 必填。

##### 3 输入与校验

| 字段 | 校验 |
|---|---|
| :orderId | 单须存在且 PENDING（CAS 命中 0 行 409） |
| reason | 必填（400 ErrReasonRequired） |

##### 4 处理流程

```mermaid
flowchart LR
    A["POST /api/payments/:id/close {reason}"] --> B{reason 非空?}
    B -->|否| E400[400]
    B -->|是| C["WithTx: TransitionOrderInTx(PENDING→FAILED)<br/>双日志同事务"]
    C -->|CAS 命中 0| E409[409]
    C -->|成功| OK[204]
```

##### 5 输出与结果状态

204；订单 FAILED（终态不复活——重谈走新建单）；lifecycle+op_log 记录操作者与原因。

##### 6 状态流转

`PENDING → FAILED`（白名单既有边）；FAILED 不复活（pure.go:15 注释申报）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 单已 SUCCEEDED/FAILED | 409（白名单无出边） |
| 并发（回调与关单竞态） | CAS 先到者赢，后到 409 |
| reason 空 | 400 |

##### 8 权限与数据规则

`ops:manage` 仅 ADMIN；审计 reason 必填与 GDPR 抹除/用户禁用同纪律（敏感操作审计）。

---

反链：

- [功能规格说明书(FSD)](/prd/fsd-medilink/)
- [MediLink Global 功能规格说明书（FSD）v1.0 · 总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/)
