实战 / 2026-09-24
怎么卖私有 GitHub 仓库的访问权:付款后自动邀请,退款后自动收回
付款进来,买家被加为只读协作者;全额退款,权限自动收回。这是 shipkit.sh 的完整发货流程,包括要调用的 GitHub API 和那些真正费时间的边界情况。

如果你卖的是源码,私有 GitHub 仓库几乎是最好的交付方式:买家直接 git clone,以后每次更新就是一次 git pull,Issue 和 Release 都是现成的,你也不用去托管一个 zip 包。缺的只是中间那一段:付款成功后自动发出仓库邀请,钱退回去的时候再自动把权限收回来。
shipkit.sh 就是这样交付 ShipKit 模板的。下面是完整流程、需要调用的四个 GitHub API,以及比主流程还花时间的那些边界情况。
整体流程
- 买家付款,支付平台(这里用的是 Creem,Stripe 的流程完全一样)发来
order.paidwebhook。 - webhook 处理器写入一条授权记录:谁付的款、哪个订单、当前状态。
- 服务端确定要邀请哪个 GitHub 账号,把它加为仓库的只读协作者。
- GitHub 给买家发邀请邮件,买家接受后就能 clone。
- 如果全额退款或者拒付败诉,就把这个协作者移除。
真正有意思的都在第 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,直到成功为止。
退款:全额退款收回,部分退款保留
部分退款算是善意补偿,买家保留授权。全额退款或者拒付败诉才收回权限,按这个顺序执行:
- 取消该用户名所有待接受的邀请。
- 移除协作者。
- 把记录标为
revoked,并写一条审计日志。
对这件事要有清醒的认识:收回权限能停掉后续更新,但收不回已经 clone 走的代码。任何卖源码的方式都是这样。仓库方式的好处是收回得干净:买家失去的是后续更新,而那本来就是他们付钱买的大头。
换一个 GitHub 账号
买家会换账号:工作号、组织账号、改了新名字。办法是让他们重新连接另一个 GitHub 账号,然后先移除旧账号,再邀请新账号。如果移除失败,就保留旧账号并报错。你要的失败结果是"还在旧账号上",而不是"一份钱两个席位"。
加起来有多少
在 shipkit.sh 上这些总共几百行:一张 licenses 表、一个小小的 GitHub 客户端、一个权限模块,外加两个支付事件处理器。能这么短,是因为底下的模板已经把 Stripe 和 Creem 的 webhook 转成了与支付平台无关的事件(order.paid、order.refunded):验签、去重之后,再分发给注册了这些事件的功能模块。授权模块从头到尾看不到支付平台的原始数据,只处理"这个订单付款了"和"这个订单全额退款了"两件事。
如果你也在做一个以代码形式售卖的产品,比如模板、UI 组件库或者课程仓库,这层事件处理才是最费工夫的部分,也是 ShipKit 第一天就交给你的部分。


