FSD m07 · 支付与对账(payment)
本册覆盖代码域
internal/domain/payment(两表:payment_order状态表 +payment_webhook_event技术表)。全局规则见 00-总则 §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 处理流程
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 处理流程
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 处理流程
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 处理流程
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 处理流程
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 抹除/用户禁用同纪律(敏感操作审计)。