不写网站,用 Stripe Payment Links 做一个线下活动的收费报名:后台生成一个付款链接和二维码,报名表里收集姓名、手机号和场次,报名截止后用一个 Node.js 脚本把所有成功报名导出成 Excel 能直接打开的 CSV 名单。
先看清成品
成品是一个付款链接(可生成二维码贴在海报上)加一个导出脚本。参与者扫码、填写信息、付款;你随时运行
npm run export得到signups-日期.csv,列为时间、姓名、手机号、场次、邮箱、金额、币种、付款状态、订单号。本文脚本已实际运行 3 个测试全部通过:自定义字段正确映射到中文列,CSV 转义正确,并通过官方模拟服务 stripe-mock 走通了分页导出。在 Stripe 后台创建链接的操作没有在本文环境中进行。
注意:Stripe 账号必须用你自己公司或个人的真实资料,在 Stripe 支持的国家或地区注册,不要借用或租用他人账号收款。销售前确认你所在地区对线上销售、发票和税务的要求。
注意:手机号、姓名属于个人信息。报名页面要写明收集目的,名单只用于本次活动,不要外传,活动结束后按你承诺的期限删除。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 付款链接 | 一个网址 + 二维码,手机电脑都能付 |
| 报名表 | 最多 3 个自定义字段 |
| 名额控制 | 可设置付款次数上限 |
| 导出脚本 | 自动翻页,输出带 BOM 的 CSV |
| 受限密钥 | 脚本只拿到读取权限 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Stripe 账号 | 在 Stripe 支持的国家或地区注册 | 先在测试模式完成全部流程 |
| Node.js | 20.6 或更高 | 只有导出脚本需要 |
| stripe(Node SDK) | 23.x | 本文使用 23.0.0 |
第一步:怎样创建付款链接?
1. 创建活动商品
测试模式下进入 商品目录 → 添加产品,名称例如“周末烘焙体验课”,价格选一次性。
2. 创建 Payment Link
进入 付款链接 → 新建,选择刚才的商品,然后设置:
| 设置 | 建议 |
|---|---|
| 自定义字段 1 | 文本,键 name,标签“姓名” |
| 自定义字段 2 | 数字,键 phone,标签“手机号” |
| 自定义字段 3 | 下拉,键 session,选项如 sat_am(周六上午)、sat_pm(周六下午) |
| 限制付款次数 | 按名额设置,满额后链接自动停止收款 |
| 付款后 | 显示确认信息,写上活动时间、地点和联系方式 |
字段的“键”要和脚本里的 name、phone、session 一致。
3. 拿到链接和二维码
保存后复制链接,详情页可以下载二维码,贴到海报或群公告里。同时记下链接 ID(plink_ 开头)。
4. 检查结果
用手机扫码,确认页面显示活动名称、价格和三个字段;用测试卡 4242 4242 4242 4242 付一次款,后台 付款 列表里出现这笔记录。
第二步:怎样创建只读的受限密钥?
导出名单只需要读取数据,不要用权限最大的私钥。
进入 开发者 → API 密钥 → 创建受限密钥,只给 Checkout Sessions 读取权限,其余保持“无”。受限密钥以 rk_ 开头。
注意:即使是受限密钥也不要发给别人或贴在群里。不再需要时在后台删除。
第三步:导出脚本怎么写?
1. 项目结构
event-signup/
├── export.js
├── export.test.js
├── package.json
└── .env
2. package.json 和 .env.example
{
"name": "event-signup-export",
"private": true,
"type": "commonjs",
"scripts": {
"export": "node --env-file=.env export.js",
"test": "node --test"
},
"dependencies": {
"stripe": "^23.0.0"
}
}
# 建议新建一个“受限密钥”,只给 Checkout Sessions 读取权限
STRIPE_SECRET_KEY=rk_test_替换成受限密钥
PAYMENT_LINK_ID=plink_替换成支付链接ID
3. export.js
// 把某个 Payment Link 的全部成功报名导出为 CSV(Excel 可直接打开)
const fs = require("node:fs");
const Stripe = require("stripe");
const csvCell = (v) => {
const s = v == null ? "" : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
function toRow(session) {
const fields = Object.fromEntries((session.custom_fields ?? []).map((f) => [f.key, f.text?.value ?? f.dropdown?.value ?? f.numeric?.value ?? ""]));
return {
时间: new Date(session.created * 1000).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }),
姓名: fields.name ?? session.customer_details?.name ?? "",
手机号: fields.phone ?? "",
场次: fields.session ?? "",
邮箱: session.customer_details?.email ?? "",
金额: session.amount_total != null ? (session.amount_total / 100).toFixed(2) : "",
币种: (session.currency ?? "").toUpperCase(),
付款状态: session.payment_status,
订单号: session.id,
};
}
async function exportSignups(stripe, paymentLink, out) {
const rows = [];
// 自动翻页,取出该链接下所有已完成的 Checkout Session
for await (const s of stripe.checkout.sessions.list({ payment_link: paymentLink, status: "complete", limit: 100 })) {
if (s.payment_status === "paid" || s.payment_status === "no_payment_required") rows.push(toRow(s));
}
const header = Object.keys(toRow({ created: 0 }));
const lines = [header, ...rows.map((r) => header.map((h) => r[h]))].map((r) => r.map(csvCell).join(","));
fs.writeFileSync(out, "\uFEFF" + lines.join("\n")); // 加 BOM,Excel 打开中文不乱码
return rows.length;
}
if (require.main === module) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const out = `signups-${new Date().toISOString().slice(0, 10)}.csv`;
exportSignups(stripe, process.env.PAYMENT_LINK_ID, out).then((n) => console.log(`导出 ${n} 条报名到 ${out}`));
}
module.exports = { exportSignups, toRow, csvCell };
| 要点 | 说明 |
|---|---|
payment_link + status: "complete" |
只取这个链接下已完成的报名 |
for await |
SDK 自动翻页,报名超过 100 条也能全部取出 |
只保留 paid |
排除尚未到账的延迟付款 |
\uFEFF |
文件开头加 BOM,Excel 打开中文不乱码 |
Asia/Shanghai |
付款时间按北京时间显示,可按需修改 |
4. 运行
npm install
cp .env.example .env # 填入受限密钥和链接 ID
npm run export
期望输出:
导出 N 条报名到 signups-2026-10-21.csv
N 是你的实际报名人数,日期为运行当天。
5. 检查结果
| 检查项 | 通过标准 |
|---|---|
| 行数 | 和后台该链接的成功付款数一致 |
| 中文 | Excel 打开不乱码 |
| 字段 | 姓名、手机号、场次都有值 |
第四步:怎样验证脚本逻辑?
1. export.test.js
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Stripe = require("stripe");
const { exportSignups, toRow, csvCell } = require("./export");
test("自定义字段映射到中文列", () => {
const r = toRow({
id: "cs_1", created: 1792540800, amount_total: 9900, currency: "cny", payment_status: "paid",
customer_details: { email: "a@example.com", name: "Card Name" },
custom_fields: [
{ key: "name", type: "text", text: { value: "张三" } },
{ key: "phone", type: "numeric", numeric: { value: "13800000000" } },
{ key: "session", type: "dropdown", dropdown: { value: "sat_am" } },
],
});
assert.equal(r.姓名, "张三");
assert.equal(r.手机号, "13800000000");
assert.equal(r.场次, "sat_am");
assert.equal(r.金额, "99.00");
assert.equal(r.币种, "CNY");
});
test("CSV 单元格转义逗号和引号", () => {
assert.equal(csvCell('a,"b"'), '"a,""b"""');
});
test("用 stripe-mock 走一遍分页导出", { skip: !process.env.STRIPE_MOCK && "需要 stripe-mock" }, async () => {
const stripe = new Stripe("sk_test_123", { host: "localhost", port: 12111, protocol: "http" });
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "exp-")), "a.csv");
const n = await exportSignups(stripe, "plink_123", out);
const text = fs.readFileSync(out, "utf8");
assert.ok(text.startsWith("\uFEFF时间,姓名"));
assert.equal(text.trim().split("\n").length, n + 1);
});
2. 运行
docker run -d --rm -p 12111:12111 stripe/stripe-mock:latest
STRIPE_MOCK=1 npm test
本文实际输出:
ok 1 - 自定义字段映射到中文列
ok 2 - CSV 单元格转义逗号和引号
ok 3 - 用 stripe-mock 走一遍分页导出
# pass 3
# fail 0
stripe-mock 返回的样例数据是未付款状态,所以第 3 个测试只验证请求参数和文件格式,不验证行数。
出问题时该怎么调?
1. 导出 0 条
| 原因 | 检查 |
|---|---|
| 密钥和链接不在同一模式 | 测试密钥只能读测试数据 |
| 链接 ID 错了 | 后台链接详情页复制 plink_ |
| 付款尚未完成 | 后台确认付款状态为成功 |
2. 报 permission 错误
The provided key 'rk_test_...' does not have the required permissions for this endpoint
受限密钥没给 Checkout Sessions 读取权限,回后台编辑密钥。
3. 某一列为空
脚本按字段的“键”取值。后台字段键写成了别的名字时,修改 toRow 里的 fields.name、fields.phone、fields.session。
4. 需要退款
在后台 付款 里找到对应订单号,点击退款。导出的“订单号”列就是为了方便你查找。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| Excel 打开乱码 | 文件没有 BOM | 使用本文脚本,或用“数据 → 从文本导入” |
| 手机号前面的 0 丢失 | Excel 当成数字 | 导入时把该列设为文本 |
| 名额满了还想加人 | 达到付款次数上限 | 在链接设置里调高上限 |
| 想停止报名 | 活动已截止 | 在后台停用该付款链接 |
| 场次显示英文键 | 下拉选项存的是键 | 在脚本里加一个键到中文的对照表 |
Cannot find module 'stripe' |
没安装依赖 | npm install |
项目资源
| 用途 | 地址 |
|---|---|
| Payment Links 文档 | https://docs.stripe.com/payment-links |
| 自定义字段 | https://docs.stripe.com/payments/checkout/custom-fields |
| 受限 API 密钥 | https://docs.stripe.com/keys/restricted-api-keys |
| Checkout Session 列表接口 | https://docs.stripe.com/api/checkout/sessions/list |
| 自动翻页 | https://docs.stripe.com/api/pagination/auto |
| 测试卡号 | https://docs.stripe.com/testing |
| stripe-mock | https://github.com/stripe/stripe-mock |
| 支持的国家和地区 | https://stripe.com/global |
建议先在测试模式创建一个付款链接,自己扫码付两次款,再运行导出脚本,确认 CSV 里有这两条记录。
流程没问题后再切换到正式模式,重新创建链接和受限密钥,然后才发布海报。