全部文章

实战 / 2026-09-24

怎么卖私有 GitHub 仓库的访问权:付款后自动邀请,退款后自动收回

付款进来,买家被加为只读协作者;全额退款,权限自动收回。这是 shipkit.sh 的完整发货流程,包括要调用的 GitHub API 和那些真正费时间的边界情况。

怎么卖私有 GitHub 仓库的访问权:付款后自动邀请,退款后自动收回

如果你卖的是源码,私有 GitHub 仓库几乎是最好的交付方式:买家直接 git clone,以后每次更新就是一次 git pull,Issue 和 Release 都是现成的,你也不用去托管一个 zip 包。缺的只是中间那一段:付款成功后自动发出仓库邀请,钱退回去的时候再自动把权限收回来。

shipkit.sh 就是这样交付 ShipKit 模板的。下面是完整流程、需要调用的四个 GitHub API,以及比主流程还花时间的那些边界情况。

整体流程

  1. 买家付款,支付平台(这里用的是 Creem,Stripe 的流程完全一样)发来 order.paid webhook。
  2. webhook 处理器写入一条授权记录:谁付的款、哪个订单、当前状态。
  3. 服务端确定要邀请哪个 GitHub 账号,把它加为仓库的只读协作者。
  4. GitHub 给买家发邀请邮件,买家接受后就能 clone。
  5. 如果全额退款或者拒付败诉,就把这个协作者移除。

真正有意思的都在第 3 步,以及每一步被执行两次时会发生什么。

写代码之前:先把仓库放进组织

这一条最容易让人白忙一天。个人账号名下的仓库,协作者一律拥有写权限,没有只读选项。你卖给谁,谁就能往你的主分支上推代码。

**组织(Organization)**名下的仓库可以给每个协作者单独设角色,其中就有 pull(只读)。免费组织就够用。建一个组织,把仓库转过去,然后从那里卖。

Token

服务端需要一个 token,只能管理这一个仓库的协作者,别的什么都做不了。创建一个 fine-grained personal access token:

  • Repository access:只选发布用的那个仓库
  • Permissions:Administration: Read and write

下面四个调用只需要这个权限。把它当作服务端密钥保存(我们叫 GITHUB_REPO_TOKEN),绝不能发到浏览器。

四个 API 调用

邀请:PUT /repos/{owner}/{repo}/collaborators/{username},请求体 {"permission": "pull"}。看返回的状态码:

  • 201:创建(或刷新)了一个邀请。
  • 204:对方已经是协作者。
async function addCollaborator(login: string) {
  const res = await gh(`/repos/${owner}/${repo}/collaborators/${login}`, {
    method: 'PUT',
    body: JSON.stringify({ permission: 'pull' }),
  })
  if (res.status === 201) return 'invited'
  if (res.status === 204) return 'active'
  throw new Error(`invite failed: ${res.status}`)
}

查询:GET /repos/{owner}/{repo}/collaborators/{username},已加入返回 204,没有返回 404。买家接受邀请时 GitHub 不会发 webhook,所以只能在需要的时候去问:我们的做法是,买家打开控制台、授权状态还是 invited 时查一次。

取消待接受的邀请:用 GET /repos/{owner}/{repo}/invitations 列出邀请,按用户名找到那一条,然后 DELETE。少了这一步,一个在接受邀请前就退了款的买家,退款后仍然可以去点接受。

移除:DELETE /repos/{owner}/{repo}/collaborators/{username}。404 也算成功,因为目标(对方没有访问权)已经达成了。

邀请哪个账号?别用输入框

最直观的设计是放一个"请输入你的 GitHub 用户名"的输入框。别这么做。输错一个字母就会邀请一个陌生人;有人会填朋友的名字,一份钱两个人用;而且 GitHub 用户名可以改,改完之后旧名字还可能被别人注册。

账号应该来自 GitHub OAuth:

  • 用 GitHub 登录的买家,付款后直接发邀请。
  • 用其他方式登录的买家(Google、邮箱验证码),控制台上会看到"连接 GitHub"按钮。它通过 OAuth 把 GitHub 账号绑定到现有账户,绑定回来时邀请就发出去了。

保存 GitHub 的数字用户 ID,不要只存用户名。ID 永远不变;每次调用 API 之前,再用 ID 去查一次当前的用户名。

每一步都要能安全地执行两次

webhook 的投递语义是"至少一次"。买家从收银台跳回来时,页面还会自己去确认订单,以防 webhook 来得慢。所以同一个订单一定会被处理不止一次,而且可能是同时处理。

靠这几点撑住:

  • 在 (支付平台, 订单号) 上建唯一索引。 webhook 用"冲突时忽略"插入,然后把这一行读回来。重复投递只会读到已有的记录,不会造成任何影响。
  • 用状态机,不用一堆布尔值。 状态是 needs_github、invited、active、failed、revoked。每个入口都只问一句:"这条记录现在需要什么?"已经是 invited 或 active 的不动;needs_github 或 failed 的再试一次。
  • 所有改动走同一个模块。 webhook、收银台回跳、"连接 GitHub"回调、重试按钮、退款处理,调用的都是同一组 grantAccess / revokeAccess。除此之外没有任何代码直接调 GitHub。
  • 重复邀请没有副作用。 那个 PUT 是幂等的,也正好用来重发过期的邀请。仓库邀请七天后失效,周五付款、一周后才打开邮件的人真的存在。

GitHub 调用失败时

GitHub 也会出错:token 失效、触发限流、服务故障。可钱已经收了,所以授权失败不能把异常抛回 webhook。token 失效靠重试是修不好的,支付平台只会一直重发。

正确做法是把授权记录标成 failed,错误信息只存在服务端。买家看到"出了点问题,请重试";后台管理里同一条记录旁边也有重试按钮。错误原文不给前端,因为里面可能带着 GitHub 返回的原始响应。

收回权限正好反过来:要抛异常。退款时如果移除失败,你希望支付平台一直重发 webhook,直到成功为止。

退款:全额退款收回,部分退款保留

部分退款算是善意补偿,买家保留授权。全额退款或者拒付败诉才收回权限,按这个顺序执行:

  1. 取消该用户名所有待接受的邀请。
  2. 移除协作者。
  3. 把记录标为 revoked,并写一条审计日志。

对这件事要有清醒的认识:收回权限能停掉后续更新,但收不回已经 clone 走的代码。任何卖源码的方式都是这样。仓库方式的好处是收回得干净:买家失去的是后续更新,而那本来就是他们付钱买的大头。

换一个 GitHub 账号

买家会换账号:工作号、组织账号、改了新名字。办法是让他们重新连接另一个 GitHub 账号,然后先移除旧账号,再邀请新账号。如果移除失败,就保留旧账号并报错。你要的失败结果是"还在旧账号上",而不是"一份钱两个席位"。

加起来有多少

在 shipkit.sh 上这些总共几百行:一张 licenses 表、一个小小的 GitHub 客户端、一个权限模块,外加两个支付事件处理器。能这么短,是因为底下的模板已经把 Stripe 和 Creem 的 webhook 转成了与支付平台无关的事件(order.paid、order.refunded):验签、去重之后,再分发给注册了这些事件的功能模块。授权模块从头到尾看不到支付平台的原始数据,只处理"这个订单付款了"和"这个订单全额退款了"两件事。

如果你也在做一个以代码形式售卖的产品,比如模板、UI 组件库或者课程仓库,这层事件处理才是最费工夫的部分,也是 ShipKit 第一天就交给你的部分。

更多文章