通用支付模块:边界与接口设计
日期:2026-10-03(2026-10-04 更新:约稿的建单方式改为双方确认;明确哪些业务需要放款;订单页改为通过通用嵌入组件以 iframe 嵌进论坛,只有跳转 Stripe 付款时在顶层打开) 范围:把“支付按钮”从约稿需求中抽出来,做成约稿、售票、摊位押金等功能共用的模块,重点讨论它的边界和输入输出 关联文档:
commission-payment-report.md(约稿)、discourse-extension-report.md(总体方案)、embed-adapter-report.md(站外服务嵌入论坛)
一、结论摘要
- 支付模块只回答一个问题:某人要为某件事付一笔确定的钱,如何把钱收进来,再按指示放出去或退回去。 价格怎么定、库存够不够、谁有资格买、验收是否合格,都由调用它的业务模块决定,支付模块不管。
- 支付模块和业务模块放在同一个服务里,共用一个数据库,彼此通过函数调用和回调交互,不单独拆成一个服务。这样,订单状态变更和业务上的后续处理(比如出票)可以放在同一个数据库事务里完成,不会出现“钱收到了、票没出”的半截状态。
- 金额只能由服务端代码传入。 支付模块没有任何 HTTP 接口能创建订单或修改金额。对外的 HTTP 接口只有:查看订单、发起付款、接收 Stripe webhook、运营操作。
- 支付按钮就是一个订单页链接
/pay/<订单 id>。同一个页面会根据当前查看的人和订单状态,显示不同的内容和按钮。各业务模块都用这一个页面,嵌进论坛时也只需要识别这一种链接。订单页可以通过通用嵌入组件以 iframe 显示在帖子里(服务部署在论坛主域名的子域下,cookie 不会被当成第三方 cookie 拦截);只有点“付款”跳转到 Stripe Checkout 时在顶层打开,因为 Checkout 不能放在 iframe 里。详见embed-adapter-report.md。
二、调用方需求对比
| 约稿 | 售票 | 摊位押金 | |
|---|---|---|---|
| 谁触发建单 | 双方都同意约稿单后,约稿模块建单 | 买家点“购买” | 摊位审核通过后由系统建单 |
| 金额来源 | 约稿单条款,任意一方填写、双方确认 | 活动配置的票价 × 数量 | 活动配置的押金额 |
| 付款人 | 创作者指定的委托人 | 当前登录的买家 | 申请摊位的创作者 |
| 收款方 | 创作者(Connect)或公司(自营单) | 公司 | 公司 |
| 付完之后 | 等验收,再放款 | 立即生效(出票) | 押着,活动结束后退还 |
| 订单有效期 | 长(比如 7 天) | 短(30 分钟,过期就释放名额) | 中(比如 3 天) |
| 退款 | 仲裁结果 | 退票规则 | 活动结束后退还;违约时扣下 |
共同点:付款人都在建单时就确定了;金额都在建单时就确定了;都需要“付款成功”“过期作废”“已退款”这几种通知。
差异点:收款方是谁;付款后要不要等一个“放款”动作;有效期长短。这几项都可以做成建单时的参数,不需要为每种业务写一套流程。
由此得出两点简化:
- 付款人一律在建单时指定,不做“任何人拿到链接都能付”的订单。售票是买家先点购买、系统再为他建单,同样满足这一点。
- 一个订单只有一个金额。 多张票、多个项目的明细由业务模块自己记录,传给支付模块的只有总额和一行说明。
三、边界
支付模块负责
- 订单的创建、存储、状态流转
- 订单页(支付按钮)以及付款权限的检查
- 创建 Stripe Checkout Session,处理 Stripe webhook
- 放款(转给 Connect 账户)、退款、作废
- 防止重复:同一笔订单不会被付两次、放两次款、退两次款
- 资金操作日志:谁在什么时候对哪笔订单做了什么
- 在状态变化时通知对应的业务模块
支付模块不负责
| 事项 | 归属 |
|---|---|
| 定价、折扣、票价配置 | 业务模块 |
| 库存和名额(锁定、释放) | 售票模块。根据支付模块的“作废”通知释放名额 |
| 谁有资格购买、谁能参与约稿 | 业务模块(比如检查用户是否在 creators 组) |
| 约稿条款的协商、双方同意、验收按钮 | 约稿模块。验收按钮的作用就是调用 release |
| 验收是否合格、纠纷谁对谁错 | 人工。结论通过 release / refund 执行 |
| 什么时候放款、什么时候退押金 | 业务模块或运营决定,支付模块只负责执行 |
| 发徽章、发通知、出票、生成二维码 | 业务模块,在收到通知后执行 |
| 创作者开通 Connect 账户 | 单独的“创作者入驻”流程。支付模块只读取结果(账户 id,以及能否收款) |
四、接口
下面用伪代码描述,和具体语言无关。
4.1 内部函数(只有服务端代码能调用)
create_order(
ref_kind, # 业务类型,比如 "commission" | "ticket" | "booth_deposit"
ref_id, # 业务对象 id,支付模块不解析它
payer_user_id, # 论坛用户 id,只有这个人能付款
payee, # "platform",或者某个 Connect 账户 id
amount, # 整数,单位是分(美分)
currency, # 初期只支持 "usd"
payee_amount, # 放款时转给收款方的金额;payee 为 platform 时不填
description, # 显示在订单页和 Stripe 支付页上的一行说明
expires_at, # 订单有效期
hold, # true = 付款后等 release 再放款;false = 付款即完成
created_by # 发起人的用户 id,写进日志
) -> order_id, pay_url
release(order_id, actor) -> ok | 错误原因
refund(order_id, actor, reason) -> ok | 错误原因 # 只做全额退款,见 6.5
void(order_id, actor) -> ok | 错误原因 # 作废一张还没付款的订单
get_order(order_id) -> 订单记录
create_order 的入参检查:
amount是正整数,不低于 Stripe 的最低收款额(美元为 0.50),也不超过一个可配置的上限。设上限是为了在初期控制单笔风险。payee_amount不能超过amount。差额就是平台的抽成,抽成本身要能覆盖 Stripe 手续费,这一条由业务模块保证。payee是 Connect 账户时,在建单的那一刻检查这个账户能不能收转账。不能的话直接报错,不要等到放款时才发现。payer_user_id必须存在。
4.2 回调(支付模块通知业务模块)
业务模块按 ref_kind 注册回调:
on_paid(order) # 付款成功:出票、把押金标为已付……
on_void(order) # 订单作废(过期或被取消):释放名额……
on_released(order) # 已放款:发徽章……
on_refunded(order) # 已全额退款:作废票……
on_disputed(order) # 出现拒付:冻结票、通知运营……
- 回调和状态变更在同一个数据库事务里执行。回调抛出异常时整个事务回滚,状态也不会变。
- 回调里不要调用外部服务,比如 Discourse API 或发邮件,因为事务回滚撤不回外部操作。需要调用外部服务的,在事务提交后执行;失败了记日志,由人工补做。
- 每个回调对同一笔订单最多触发一次,这由状态机保证(见第五节)。
4.3 HTTP 接口
| 接口 | 谁能用 | 作用 |
|---|---|---|
GET /pay/<id> |
已登录用户 | 订单页。付款人看到“付款”按钮;收款方和运营只能看,不能付;其他人看到“无权限” |
POST /pay/<id>/checkout |
仅付款人 | 检查订单状态和有效期,然后创建或复用 Checkout Session,跳转到 Stripe |
POST /stripe/webhook |
Stripe | 验签后处理事件 |
POST /admin/orders/<id>/release、/refund、/void |
运营 | 直接调用对应的内部函数 |
这里没有创建订单的接口。 约稿单属于约稿模块:双方都同意当前版本的条款后,它按条款调用 create_order,收款方就是约稿单里的创作者。售票的“购买”按钮属于售票模块:它先锁定名额、按票价算出总额,再调用 create_order。金额永远不会从浏览器直接进到支付模块。
登录用 DiscourseConnect,所以订单页里的“当前用户”就是论坛账号。
订单页被嵌在论坛里时,要遵守 embed-adapter-report.md 第五节的约定,其中和支付直接相关的两条:
POST /pay/<id>/checkout的表单加target="_top",Stripe 页面在顶层打开。Checkout 的success_url和cancel_url指回订单页(顶层),付款人从那里回到论坛。- 响应头带
Content-Security-Policy: frame-ancestors <论坛域名>,只允许论坛嵌入,防止别的网站把订单页框进去骗用户点击(点击劫持)。
五、订单状态机
付款成功 release
open ─────────────────▶ paid ──────────────▶ released
│ │ │ │
│ 过期 / void │ │ refund │ refund(需要撤回转账,见 6.5)
▼ │ ▼ ▼
void │ refunded ◀─────────────┘
│
└─ hold = false 时,paid 就是终点(仍然可以退款)
- 所有状态变更都用带前置条件的原子 UPDATE,比如
... WHERE id = ? AND status = 'paid'。影响行数为 0 就说明已经处理过了,直接返回,不再调用 Stripe,也不触发回调。 disputed不作为一个状态,而是订单上的一个标记。拒付可能发生在paid、released、refunded任何一个阶段之后,单列成状态会让状态机复杂很多。- 每次状态变更都往
order_log里写一行(订单、时间、操作人、动作、备注)。处理纠纷和拒付时,这就是证据。
数据
orders
id, ref_kind, ref_id,
payer_user_id, payee (null = platform), amount, payee_amount, currency,
description, hold, status, expires_at,
refunded_amount, disputed_at,
stripe_session_id, stripe_payment_intent_id, stripe_charge_id, stripe_transfer_id,
created_by, created_at, paid_at, released_at
order_log
order_id, at, actor, action, note
id 用随机、不可猜测的字符串,因为它会出现在链接里。
六、边角情况
6.1 重复付款
用户可能连点两次付款,也可能在两个标签页里各开一个支付页。
- 每笔订单同一时间只保留一个有效的 Checkout Session。
/checkout发现已有未过期的 Session,就复用它;需要新建时,先调用 Stripe 的 expire 接口让旧的作废。 - 兜底:如果 webhook 报告一笔已经是
paid的订单又收到了一次付款,就自动全额退掉多付的那笔,写日志并通知运营。
6.2 过期与名额
订单有效期和 Checkout Session 有效期是两回事。Stripe 规定 Session 的有效期只能在 30 分钟到 24 小时之间。
- 创建 Session 时,让它的有效期 ≤ 订单剩余的有效期。这样订单过期之后,Stripe 那边也不可能再付款成功。
- 定时任务(比如每分钟一次)扫描已经过期的
open订单。如果订单还有未关闭的 Session,就先调用 Stripe 的 expire 接口:返回“已完成”说明用户刚好付了款,跳过这笔,等 webhook 把它标成paid;否则把订单标成void,触发on_void,售票模块据此释放名额。 - 兜底:订单已经
void之后又收到付款成功的 webhook,就自动退款,写日志并通知运营。售票场景下,这意味着名额已经释放给别人了,不能再出票。
6.3 只接受即时到账的付款方式
ACH 这类付款方式要过好几天才知道是否成功,会让状态机多出一个“处理中”状态,售票的名额锁定也撑不了那么久。初期只开放卡和钱包(Apple Pay、Google Pay 等),在 Checkout 的付款方式配置里限定。
6.4 放款
- 只有
hold = true、payee是 Connect 账户、并且没有部分退款的订单,才能通过release放款。 - 调用 Stripe 转账时带上
source_transaction(这笔订单的 charge),保证这笔转账一定来自这笔付款的资金。同时带 Idempotency-Key(比如release-<订单 id>),重试也不会转两次。 - 先把状态改为
released,再调用 Stripe。调用失败就把状态改回paid,写日志并报警。 payee为 platform 时,release只改状态,不调用 Stripe。
6.5 退款
- 内部函数只支持全额退款。部分退款(比如仲裁结果是退一半)由运营在 Stripe 后台手工操作。支付模块通过
charge.refundedwebhook 把退款金额累加到refunded_amount,并写日志,但不改变状态。 - 对一笔
released的订单退款,要先撤回已经转给创作者的钱(transfer reversal),前提是创作者的 Stripe 余额里还有钱。撤回不了的话,refund返回错误,交给运营处理。 - Stripe 手续费退款时不退还。押金这类“先收后退”的业务,每一笔都要损失一次手续费。这笔钱由谁承担,是业务规则问题,不在支付模块里决定。
6.6 拒付
收到 charge.dispute.created 时:给订单打上 disputed_at 标记,触发 on_disputed,通知运营。应诉材料(order_log、私信记录)由运营在 Stripe 后台提交。
七、哪些业务需要放款
“验收”不是支付模块的概念,而是约稿这个业务触发 release 的方式。 支付模块只提供 release / refund / void 和第五节的状态机;什么时候调用、有没有按钮、谁来按,都由业务模块决定。
只有“钱最后要转给第三方”的业务才需要 hold = true 和 release。退还押金是原路退给付款人,属于 refund,不是放款。
| 业务 | 调用方式 | 付款之后 | 怎么结束 |
|---|---|---|---|
| 约稿 | create_order(ref_kind="commission", payee=创作者的 Connect 账户, hold=true, expires_at=7 天后) |
钱留在平台 | 委托人点“确认验收”,或运营仲裁后调用 release;仲裁退款调用 refund |
| 自营约稿 | 同上,但 payee="platform" |
同上 | release 只改状态。保留这一步是为了让约稿流程和普通单一致 |
| 售票 | create_order(ref_kind="ticket", payee="platform", hold=false, expires_at=30 分钟后) |
paid 就是终点,on_paid 出票 |
没有放款;on_void 释放名额;退票调用 refund |
| 摊位押金 | create_order(ref_kind="booth_deposit", payee="platform", hold=false) |
押在平台账户 | 活动结束后运营调用 refund 退还;违约扣押金就是不退或部分退 |
约稿和 commission-payment-report.md 的对应关系:
- 双方同意约稿单 = 约稿模块调用
create_order - 付款成功 = 约稿模块的
on_paid:事务提交后在论坛建任务帖 - 验收 = 委托人点按钮,或运营仲裁后点按钮,都调用
release - 仲裁退款 = 运营调用
refund - 发徽章 = 约稿模块的
on_released回调
八、暂时不做的事
| 不做 | 什么时候再加 |
|---|---|
| 一笔订单拆给多个收款方 | 出现合作稿、联名摊位这类需求时 |
| 部分放款、内部函数里的部分退款 | 运营在 Stripe 后台手工处理的次数多到嫌烦时 |
| 订阅、分期付款 | 有会员制需求时 |
| 美元以外的币种 | 加拿大用户比例高、并且 USD 收款明显影响转化时 |
| Stripe Tax 和每笔订单的税码 | 律师或会计确认需要代收销售税时。到时要给 create_order 加一个税码参数 |
| 未登录用户购票 | 售票需要对论坛以外的人开放时 |
| 在论坛里直接显示订单页 | 起步阶段只贴链接。通用嵌入组件做好后(见 embed-adapter-report.md),帖子里的订单链接会自动显示成 iframe。iframe 里就是订单页本身,状态是实时的,不需要另外做跨域读取 |
| 自动重试失败的 Stripe 调用 | 失败多到人工处理不过来时 |
九、待定事项
- 单笔金额上限,以及上限是否按业务类型分别设置
- 售票订单的有效期(30 分钟是暂定值)
- 押金退还时手续费由谁承担
- 押金能在平台账户里押多久:押好几个月后还能不能原路退回,要查 Stripe 和卡组织对退款时限的规定;超过时限就要换别的退还方式
- 未登录用户能否购票(影响售票模块的设计,对支付模块的影响只是“付款人可以为空”)
- 订单页的样式:自己做简单页面,还是只显示一行说明加一个按钮,然后直接跳转 Stripe
