# 通用支付模块（初步测试完成）

**URL:** <https://guild.xenogenius.com/t/topic/37>\
**Category:** 功能和设计评估\
**Created:** [2026 年10 月 3 日 19:53 UTC](https://guild.xenogenius.com/t/topic/37 "2026-10-03T19:53:11Z")\
**Posts on this page:** 6\
**Page:** 1

<div class="post-metadata">

作者： ![admin](https://guild.xenogenius.com/letter_avatar_proxy/v4/letter/a/3ec8ea/32.png) [@admin](https://guild.xenogenius.com/u/admin)\
发布日期： [2026 年10 月 3 日 19:53 UTC](https://guild.xenogenius.com/t/topic/37/1 "2026-10-03T19:53:11Z")

</div>

# **通用支付模块：边界与接口设计**

> 日期：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 内部函数（只有服务端代码能调用）**

```auto
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` 注册回调：

```auto
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 <论坛域名>`，只允许论坛嵌入，防止别的网站把订单页框进去骗用户点击（点击劫持）。

* * *

## **五、订单状态机**

```auto
             付款成功 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` 里写一行（订单、时间、操作人、动作、备注）。处理纠纷和拒付时，这就是证据。

### **数据**

```auto
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

---

<div class="post-metadata">

作者： ![admin](https://guild.xenogenius.com/letter_avatar_proxy/v4/letter/a/3ec8ea/32.png) [@admin](https://guild.xenogenius.com/u/admin)\
发布日期： [2026 年10 月 3 日 19:54 UTC](https://guild.xenogenius.com/t/topic/37/2 "2026-10-03T19:54:55Z")

</div>

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

---

<div class="post-metadata">

作者： ![member5](https://guild.xenogenius.com/letter_avatar_proxy/v4/letter/m/839c29/32.png) [@member5](https://guild.xenogenius.com/u/member5)\
发布日期： [2026 年10 月 4 日 00:37 UTC](https://guild.xenogenius.com/t/topic/37/3 "2026-10-04T00:37:25Z")

</div>

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

---

<div class="post-metadata">

作者： ![member2](https://guild.xenogenius.com/user_avatar/guild.xenogenius.com/member2/32/22_2.png) [@member2](https://guild.xenogenius.com/u/member2)\
发布日期： [2026 年10 月 4 日 04:27 UTC](https://guild.xenogenius.com/t/topic/37/4 "2026-10-04T04:27:37Z")

</div>

赞同

---

<div class="post-metadata">

作者： ![admin](https://guild.xenogenius.com/letter_avatar_proxy/v4/letter/a/3ec8ea/32.png) [@admin](https://guild.xenogenius.com/u/admin)\
发布日期： [2026 年10 月 4 日 06:25 UTC](https://guild.xenogenius.com/t/topic/37/5 "2026-10-04T06:25:27Z")

</div>

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

---

<div class="post-metadata">

作者： ![admin](https://guild.xenogenius.com/letter_avatar_proxy/v4/letter/a/3ec8ea/32.png) [@admin](https://guild.xenogenius.com/u/admin)\
发布日期： [2026 年10 月 4 日 18:05 UTC](https://guild.xenogenius.com/t/topic/37/6 "2026-10-04T18:05:20Z")

</div>

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

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

 ![image](https://149-248-13-225.sslip.io/uploads/default/original/1X/ae3b439f185b7fb47ab90bac6201cdaa34bafade.png)

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

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