Stripe 支付系统文档
Stripe 支付系统文档
目录
系统概述
本系统是基于 Stripe 支付网关的支付解决方案,支持两种主要支付场景:
-
网上付款 (Online Payment) - 直接充值付款
-
商品付款 (Goods Payment) - 购买商品/订单付款
技术栈
-
框架: Fastify (Node.js)
-
支付方式: Stripe (卡、支付宝等)
-
货币: 港元 (HKD)
-
数据库: MongoDB
支持的支付方式
-
信用卡/借记卡 (Card)
-
支付宝 (Alipay)
配置环境
环境变量配置
# Stripe API 密钥
STRIPE_SK=sk_live_xxx # 生产环境密钥
STRIPE_SK_DEV=sk_test_xxx # 开发环境密钥
# Webhook 密钥
STRIPE_WEBHOOK=whsec_xxx # 生产环境 Webhook 密钥
STRIPE_WEBHOOK_DEV=whsec_test_xxx # 开发环境 Webhook 密钥
# 环境标识
ver=Beta # "Beta" 为开发环境,其他为生产环境
初始化代码
const stripeSk = process.env.ver === "Beta" ?
process.env.STRIPE_SK_DEV : process.env.STRIPE_SK;
const stripe = new Stripe(stripeSk || "");
const webhookSecret = process.env.ver === "Beta" ?
process.env.STRIPE_WEBHOOK_DEV : process.env.STRIPE_WEBHOOK;
关键点:根据环境自动选择不同的 API 密钥
支付流程
完整流程图
┌─────────────────────────────────────────────────────────────┐
│ 用户发起支付 │
└────────────────────┬────────────────────────────────────────┘
│
┌────────────┴────────────┐
│ │
┌───▼──────┐ ┌──────▼──┐
│ 网上付款 │ │ 商品付款 │
└───┬──────┘ └──────┬──┘
│ │
│ POST /stripe/oneline │ POST /stripe/goods
│ │
│ 创建 Stripe Checkout │ 创建 Stripe Checkout
│ Session │ Session
│ │
└────────────┬───────────┘
│
┌────────────▼────────────┐
│ 获取支付链接 │
│ 返回 session.url │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ 用户跳转到 Stripe │
│ 完成支付 │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Stripe 发送 Webhook │
│ POST /stripe/callback │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ 处理回调事件 │
│ 更新订单状态 │
│ 添加用户积分等 │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ 重定向到成功/失败页 │
│ 显示支付结果 │
└────────────────────────┘
API 端点
1. Webhook 回调端点
POST /stripe/callback
接收 Stripe 支付事件的 Webhook 回调
请求头
Content-Type: application/json
stripe-signature: <签名>
响应
{
"received": true
}
处理的事件
payment_intent.succeeded- 支付成功
元数据字段
metadata: {
order_no: string, // 订单号
custom_id: string // 支付来源: "online" | "goods"
}
2. 网上付款端点
POST /stripe/oneline
为网上充值付款创建支付会话
请求体
{
"amount": "100", // 金额(元),如 "100.50"
"orderNum": "2024-001", // 订单号
"paymentItemValue": "充值100元" // 支付项目名称
}
响应成功
{
"url": "https://checkout.stripe.com/pay/cs_xxx"
}
响应失败
{
"code": 400,
"msg": "参数错误"
}
金额转换
-
前端/客户端发送:元(如 100 = 100元)
-
Stripe 存储:分(调用
yuanToFen()转换)
3. 商品付款端点
POST /stripe/goods
为商品购买创建支付会话
请求体
{
"amount": "99.99", // 金额(元)
"orderNum": "ORD-2024-001", // 订单号
"sku_id": "SKU-123" // SKU ID
}
响应成功
{
"url": "https://checkout.stripe.com/pay/cs_xxx"
}
响应失败
{
"code": 400,
"msg": "参数错误"
}
4. 支付成功页面
GET /stripe-success
显示支付成功页面
查询参数
type=online // 支付类型: "online" | "goods" | "order"
orderNum=xxx // 订单号
页面显示信息
-
订单号
-
商品名称(多语言支持:英文、繁体、中文)
-
支付金额
-
支付时间
-
订单创建时间
-
返回链接
5. 支付失败页面
GET /stripe-cancel
显示支付取消/失败页面
查询参数
type=online // 支付类型: "online" | "goods" | "order"
orderNum=xxx // 订单号
数据库结构
1. web_online_pay (网上付款表)
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectId | 主键 |
| userId | ObjectId | 用户ID |
| customerId | string | 客户编号 |
| invoiceId | string | 发票编号 |
| amount | number | 缴费金额(分) |
| paymentItem | 1-7 | 缴费项目类型 |
| email | string | 电子邮箱 |
| pay_type | 0-7 | 支付方式(7=Stripe) |
| pay_ip | string | 支付IP |
| pay_time | number | 支付时间戳 |
| status | 1 | 2 | 1=未付款,2=已付款 |
| add_time | number | 创建时间戳 |
| orderNum | string | 订单编号 |
| stripe_payment_intent_id | string | Stripe 支付意图 ID |
2. web_order (订单表)
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectId | 主键 |
| orderNum | string | 订单编号 |
| userId | ObjectId | 用户ID |
| goodsId | ObjectId | 商品ID |
| sku_name | string | 规格名称(中文) |
| sku_name_en | string | 规格名称(英文) |
| sku_name_tw | string | 规格名称(繁体) |
| price_total | number | 总价(分) |
| status | 1-8 | 订单状态 |
| pay_type | 0-6 | 支付方式 |
| pay_time | number | 支付时间戳 |
| add_time | number | 创建时间戳 |
| stripe_payment_intent_id | string | Stripe 支付意图 ID |
订单状态:
-
1: 待付款
-
2: 待确认
-
3: 待注册
-
4: 已完成
-
5: 已取消
-
6: 已下载
-
7: 申请退款
-
8: 已退款
3. web_points (用户积分表)
| 字段 | 类型 | 说明 |
|------|------|------|
| _id | ObjectId | 主键 |
| userId | ObjectId | 用户ID |
| points | number | 积分数量 |
| source | 1-5 | 积分来源(4=商品消费) |
| status | 1 | 2 | 1=隐藏,2=正常 |
| out_trade_no | string | 关联订单号 |
| description | string | 描述(中文) |
| description_en | string | 描述(英文) |
| description_tw | string | 描述(繁体) |
| add_time | number | 创建时间戳 |
核心功能详解
1. 创建支付会话
网上付款示例
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card', 'alipay'], // 支付方式
line_items: [
{
price_data: {
currency: 'hkd', // 货币
product_data: { name: paymentItemValue },
unit_amount: yuanToFen(amount), // 转换为分
},
quantity: 1,
},
],
payment_intent_data: {
metadata: {
order_no: orderNum, // 订单号
custom_id: 'online' // 支付来源标识
}
},
success_url: 'https://www.dadate.com/stripe-success?type=online&orderNum=' + orderNum,
cancel_url: 'https://www.dadate.com/stripe-cancel?type=online&orderNum=' + orderNum,
});
return { url: session.url };
关键参数说明
| 参数 | 说明 |
|------|------|
| mode | 'payment' - 一次性支付模式 |
| payment_method_types | 支持的支付方式数组 |
| currency | 货币代码('hkd' = 港元) |
| unit_amount | 金额(分),需要从元转换 |
| metadata | 元数据,支付成功时返回 |
| custom_id | 用于区分支付来源 |
| success_url | 支付成功后重定向 URL |
| cancel_url | 支付取消后重定向 URL |
2. 金额转换函数
// 元 -> 分
yuanToFen(100) => 10000
// 分 -> 元
fenToYuan(10000) => 100
// 格式化显示
formatNumberWithCommas(100.5) => "100.50"
3. 支付事件处理
事件类型:payment_intent.succeeded
const event = stripe.webhooks.constructEvent(
request.body,
sig,
webhookSecret
);
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
// 提取关键信息
const orderNo = paymentIntent.metadata?.order_no;
const custom_id = paymentIntent.metadata?.custom_id;
const amount = paymentIntent.amount;
const payment_intent_id = paymentIntent.id;
}
回调处理
Webhook 验证
// 需要 Raw Body Buffer
app.addContentTypeParser('application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
done(null, body);
}
);
// 验证签名
const event = stripe.webhooks.constructEvent(
request.body, // Buffer
sig, // Stripe 签名
webhookSecret // Webhook 密钥
);
重要:
-
必须使用 raw body 进行验证
-
不能预先解析 JSON
-
签名验证失败会抛出异常
处理流程
A. 网上付款(custom_id === "online")
if (custom_id === "online") {
await collection("web_online_pay").updateMany({
orderNum: orderNo,
status: 1 // 未付款状态
}, {
$set: {
status: 2, // 已付款
pay_type: 1, // 支付方式 (1=微信,这里应该是Stripe)
pay_time: Date.now(),
stripe_payment_intent_id: payment_intent_id
}
});
}
更新内容:
-
状态从 1(未付款)→ 2(已付款)
-
记录支付时间
-
保存 Stripe 支付 ID
B. 商品付款(custom_id === "goods")
if (custom_id === "goods") {
goodsCallBack({
out_trade_no: orderNo,
amount,
stripe_payment_intent_id: payment_intent_id
});
}
调用 goodsCallBack() 函数处理商品支付逻辑
商品支付回调函数
async function goodsCallBack({
out_trade_no, // 订单号
amount, // 金额(分)
stripe_payment_intent_id // Stripe 支付ID
}) {
// 1. 更新订单状态
const orderInfo = await collection("web_order").findOneAndUpdate({
orderNum: out_trade_no,
status: 1 // 待付款
}, {
$set: {
status: 2, // 待确认
pay_type: 1, // Stripe
pay_time: Date.now(),
stripe_payment_intent_id
}
});
if (orderInfo) {
const userId = orderInfo.userId;
// 2. 增加用户积分
await collection("web_user").updateOne({
_id: userId
}, {
$inc: {
points: amount // 增加积分
}
});
// 3. 添加积分记录
await collection("web_points").insertOne({
userId: userId,
points: amount,
source: 4, // 商品消费
status: 2, // 正常
out_trade_no, // 关联订单
description: `购买【${orderInfo.title}】赠送【${amount}】积分`,
description_en: `purchase 【${orderInfo.title_en}】gift 【${amount}】points`,
description_tw: `購買【${orderInfo.title_tw}】贈送【${amount}】積分`,
add_time: Date.now()
});
}
}
执行步骤:
-
✅ 查询并更新订单(状态 1→2)
-
✅ 增加用户积分
-
✅ 创建积分记录(含多语言描述)
成功/失败页面
成功页面处理
app.get('/stripe-success', async (request, reply) => {
const language = request.language || 'en';
const { type, orderNum } = request.query;
let obj = {
title: "",
amount: "",
pay_time: "",
add_time: "",
rejectUrl: "",
orderNum: ""
};
// 商品/订单类型
if (['goods', 'order'].includes(type)) {
const info = await collection("web_order")
.findOne({ orderNum, status: 2 }); // 已付款
obj.orderNum = info?.orderNum || "";
obj.title = language === 'en'
? info?.sku_name_en
: language === 'tw'
? info?.sku_name_tw
: info?.sku_name;
obj.amount = formatNumberWithCommas(fenToYuan(info?.price_total || 0));
obj.pay_time = dayjs(info?.pay_time).format("YYYY-MM-DD HH:mm:ss");
obj.add_time = dayjs(info?.add_time).format("YYYY-MM-DD HH:mm:ss");
obj.rejectUrl = "https://user.dadate.com/user/order?orderNum=" + orderNum;
}
// 网上付款类型
if (type === "online") {
const info = await collection("web_online_pay")
.findOne({ orderNum, status: 2 }); // 已付款
obj.orderNum = info?.orderNum || "";
obj.amount = formatNumberWithCommas(fenToYuan(info?.amount || 0));
obj.pay_time = dayjs(info?.pay_time).format("YYYY-MM-DD HH:mm:ss");
obj.add_time = dayjs(info?.add_time).format("YYYY-MM-DD HH:mm:ss");
obj.rejectUrl = "https://user.dadate.com/user/onlinePay?orderNum=" + orderNum;
}
return reply.view('stripe_type.hbs', {
type,
payType: "success",
...obj
});
});
失败页面处理
app.get('/stripe-cancel', async (request, reply) => {
// 同成功页面逻辑,但查询条件改为 status: 1(未付款)
const info = await collection("web_order")
.findOne({ orderNum, status: 1 }); // 未付款
return reply.view('stripe_type.hbs', {
type,
payType: "cancel", // 标识为失败
...obj
});
});
多语言支持
| 语言 | 代码 |
|------|------|
| 英文 | en |
| 繁体中文 | tw |
| 中文 | 其他 |
const title = language === 'en'
? info?.sku_name_en
: language === 'tw'
? info?.sku_name_tw
: info?.sku_name;
集成检查清单
-
配置 Stripe API 密钥(开发/生产)
-
配置 Webhook 密钥
-
配置 success_url 和 cancel_url
-
配置 Stripe Webhook 端点为
/stripe/callback -
验证数据库表结构
-
配置多语言 i18n
-
测试支付流程
-
配置支付成功/失败页面模板
常见问题
Q1: 为什么要使用 Raw Body?
A: Stripe 需要原始请求体来验证 Webhook 签名。预先解析 JSON 会改变 body 内容,导致签名验证失败。
Q2: 金额为什么是分?
A: 国际支付标准使用最小货币单位(分、美分等)以避免浮点精度问题。
Q3: 支付失败后如何重试?
A: 用户可以在失败页面重新发起支付,或从用户中心重新支付。系统不会自动重试。
Q4: 如何处理重复 Webhook?
A: Stripe 会重试失败的 Webhook。数据库查询条件已过滤,幂等操作是安全的。
故障排查
| 问题 | 排查步骤 |
|------|---------|
| Webhook 验证失败 | 1. 检查 webhookSecret 是否正确 2. 确认使用 Raw Body 3. 查看错误日志 |
| 订单状态未更新 | 1. 检查 Webhook 是否收到 2. 验证数据库查询条件 3. 检查元数据是否正确 |
| 金额显示错误 | 检查是否正确调用 fenToYuan() 转换函数 |
| 支付页面出现错误 | 1. 验证参数是否正确 2. 检查模板文件 3. 查看服务器日志 |
相关文件
-
源代码:
src/routes/pay/stripe.ts -
数据库模型:
-
src/DB/web_online_pay.ts -
src/DB/web_order.ts -
src/DB/web_points.ts
-
-
工具函数:
src/utils/utils.ts -
模板文件:
views/stripe_type.hbs
更新日志
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2024-10 | 初始版本 |
最后更新: 2024-10-20
本文由萧兮的博客原创发布,欢迎转载,转载务必保留原文链接。
萧兮的博客:https://www.20010515.xyz · 原文:https://www.20010515.xyz/posts/308deee9-d76b-4ddd-bfda-028b7abdec8e