跳转到主要内容

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.02webhook 验签与幂等POST /webhooks/stripe
F7.03支付凭证 PDFGET /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_PAYMENT409 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 边界与异常
场景行为
订单非 SUCCEEDED409 ErrReceiptNotReady(409 快路径零多余查询)
webhook 补偿生成失败仅 Warn 不反压 200——下载侧惰性自愈兜底(零重试端点/零扫表 worker)
receiptIO 未装配409(降级可见,沿 webhookSecret 缺省模式)
惰性自愈并发双写回填幂等谓词只留先到者
中文 PDFMVP 不做(需求出现再引 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/FAILED409(白名单无出边)
并发(回调与关单竞态)CAS 先到者赢,后到 409
reason 空400
8 权限与数据规则

ops:manage 仅 ADMIN;审计 reason 必填与 GDPR 抹除/用户禁用同纪律(敏感操作审计)。