通用支付模块(初步测试完成)

通用支付模块:边界与接口设计

日期:2026-10-03(2026-10-04 更新:约稿的建单方式改为双方确认;明确哪些业务需要放款;订单页改为通过通用嵌入组件以 iframe 嵌进论坛,只有跳转 Stripe 付款时在顶层打开) 范围:把“支付按钮”从约稿需求中抽出来,做成约稿、售票、摊位押金等功能共用的模块,重点讨论它的边界和输入输出 关联文档:commission-payment-report.md(约稿)、discourse-extension-report.md(总体方案)、embed-adapter-report.md(站外服务嵌入论坛)


一、结论摘要

  1. 支付模块只回答一个问题:某人要为某件事付一笔确定的钱,如何把钱收进来,再按指示放出去或退回去。 价格怎么定、库存够不够、谁有资格买、验收是否合格,都由调用它的业务模块决定,支付模块不管。
  2. 支付模块和业务模块放在同一个服务里,共用一个数据库,彼此通过函数调用和回调交互,不单独拆成一个服务。这样,订单状态变更和业务上的后续处理(比如出票)可以放在同一个数据库事务里完成,不会出现“钱收到了、票没出”的半截状态。
  3. 金额只能由服务端代码传入。 支付模块没有任何 HTTP 接口能创建订单或修改金额。对外的 HTTP 接口只有:查看订单、发起付款、接收 Stripe webhook、运营操作。
  4. 支付按钮就是一个订单页链接 /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.refunded webhook 把退款金额累加到 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

应该有系统检测单笔交易超过某个值以后就触发系统通知,暂时锁定交易,平台运营介入交易。

你的文档怎么都是一股Ai味而且完全看不懂,该人写还是要人写,大家都要看明白:expressionless_face:

赞同

我只是让 AI 把它说得话记下来,之后用到的时候我能知道可以有哪个方向。

测试了一个论坛插件,可以把 iframe 插入到帖子内,从而实现将来自子域的服务嵌进帖子内的效果。

claude 做了一个测试付费的页面,从我这里看根本看不出这是一个被嵌进来的外部服务:

中间那段 Stripe test payment 到 test card 那一段就是被嵌入的外部服务。

我也通过这个支付页面跳转到了 stripe 测试支付页面完成支付操作,之后也能正常跳转会论坛,评价为基本上不会察觉这是一个外部服务。