Stripe支付 在国内的相关资料非常少, 包括了B站,CSDN中的示例大多数都是比较老旧,也都不系统。这个demo项目是一个前后端都带的示例。基本看明白过程, 拿过来就能用。
首先,来个项目介绍:
本项目基于前端vue+vite构建,后端使用springboot构建的web接口项目
完成的功能如下:
1. 支持简单的用户登录和注册
2. 绑定信用卡
3. 使用信用卡支付
4. 使用微信或支付宝支付
1. 官方文档地址
Stripe 文档https://stripe.com/docs
2. Stripe 的官方示例github地址
Stripe Samples · GitHubhttps://github.com/stripe-samples
3. 实现首页、用户注册、登录
本项目的前端我是基于官方示例
GitHub - stripe-samples/accept-a-payment: Learn how to accept a payment from customers around the world with a variety of payment methods.https://github.com/stripe-samples/accept-a-payment的路径 (accept-a-payment/payment-element/client/vue-cva )的一个vue项目来构建的。自己创建一个也可以, 都无所谓了
首页
首先是要写一个Index.vue
HTML部分, 很简单就是要展示几个功能 退出登录,绑定银行卡,使用支付宝或微信支付,去支付(是指的用以绑定的信用卡支付)
<template>
<main>
<h1>Well Come Stripe Demo</h1>
<ul>
<li @click="handleExit">
退出登录
</li>
<li>
<a href="/bindCard">绑定银行卡</a>
</li>
<li>
<a href="/useAlipayOrWechat">使用支付宝/微信支付</a>
</li>
<li>
<a href="/pay">去支付</a>
</li>
</ul>
</main>
</template>
js部分
<script setup>
import { onMounted } from "vue";
onMounted(async () => {
const name = sessionStorage.getItem('name')
// 判断 token 是否存在
if (!name) {
// 如果 token 不存在,跳转到登录页面
window.location.href = '/login'; // 你需要替换成你的登录页面的实际路径
}
});
const handleExit = async () => {
console.log('exit')
sessionStorage.clear()
window.location.href = '/login';
}
</script>
首先onMounted部分当页面加载完成后, 检查本地存储是否有name属性, 如果没有就跳转到登录
基于退出登录@click="handleExit" 实现一个函数用于清空登录信息,并跳转到登录页面。
登录
创建一个login.vue,仍然是两部分 html和js部分
<template>
<main>
<h1>登录页面</h1>
<form id="payment-form" @submit.prevent="handleSubmit">
<div>
<input id="name">
</div>
<button
id="submit"
:disabled="isLoading"
>
登录
</button>
<sr-messages :messages="messages" />
</form>
<div>
<a href="/registry">没有账号去注册</a>
</div>
</main>
</template>
这里就是定义了一个表单,并且有一个登录按钮, 表单监听了提交,并使用一个函数来处理
<script setup>
import { ref } from "vue";
import SrMessages from "./SrMessages.vue";
const isLoading = ref(false);
const messages = ref([]);
const handleSubmit = async () => {
if (isLoading.value) {
return;
}
isLoading.value = true;
const nameInput = document.querySelector('#name');
console.log(nameInput.value)
await fetch(
'/api/user/login',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: nameInput.value,
}),
}
).then((r) => {
console.log('第一个then', r)
return r.json()
}).then((r) => {
console.log('第二个then', r)
if (r.code == 200) {
sessionStorage.setItem('id', r.data.id)
sessionStorage.setItem('name', r.data.name)
sessionStorage.setItem('email', r.data.email)
sessionStorage.setItem('stripeCustomerId', r.data.stripeCustomerId)
window.location.href = '/';
} else {
messages.value.push('登录信息错误, 请重新登录');
}
});
isLoading.value = false;
}
</script>
js部分:主要逻辑是登录完成后将用户信息存放到本地存储中。然后跳回到首页。
其中调用接口 /api/user/login
接口的实现也很简单核心代码如下,别告诉我你看不懂
@PostMapping("login")
public String login(@RequestBody UserLoginBO userLoginBO) {
User user = userService.lambdaQuery().eq(User::getName, userLoginBO.getName()).one();
if (Objects.isNull(user)) {
ResponseR r = ResponseR.error();
return JSONUtil.toJsonStr(r);
}
ResponseR<User> r = ResponseR.ok().data(user);
return JSONUtil.toJsonStr(r);
}
注册
新建一个Registry.vue的页面
<template>
<main>
<h1>注册页面</h1>
<form id="payment-form" @submit.prevent="handleSubmit">
<div>
<label for="name">名字</label>
<input id="name">
</div>
<div>
<label for="email">邮箱</label>
<input id="email">
</div>
<button
id="submit"
:disabled="isLoading"
>
注册
</button>
<sr-messages :messages="messages" />
</form>
<div>
<a href="/login">有账号去登录</a>
</div>
</main>
</template>
<script setup>
import { ref } from "vue";
const isLoading = ref(false);
const handleSubmit = async () => {
if (isLoading.value) {
return;
}
isLoading.value = true;
const nameInput = document.querySelector('#name');
const emailInput = document.querySelector('#email');
console.log(nameInput.value, '***', emailInput.value)
await fetch(
'/api/user/registry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: nameInput.value,
email: emailInput.value,
}),
}
).then((r) => {
console.log('第一个then', r)
return r.json()
}).then((r) => {
//{"code":200,"data":{"name":"an","id":3,"stripeCustomerId":"cus_PTbTKK6AOYHRn7","email":"qwe@163.com"},"message":"SUCCESS"}
console.log('第二个then', r)
if (r.code == 200) {
sessionStorage.setItem('id', r.data.id)
sessionStorage.setItem('name', r.data.name)
sessionStorage.setItem('email', r.data.email)
sessionStorage.setItem('stripeCustomerId', r.data.stripeCustomerId)
window.location.href = '/';
}
});
isLoading.value = false;
}
</script>
java 注册接口
@PostMapping("registry")
public String registry(@RequestBody UserRegistryBO userRegistryBO) throws StripeException {
User user = userService.lambdaQuery().eq(User::getName, userRegistryBO.getName()).one();
if (Objects.isNull(user)) {
user = new User();
user.setName(userRegistryBO.getName());
user.setEmail(userRegistryBO.getEmail());
Customer customer = stripeService.create(userRegistryBO.getName(), userRegistryBO.getEmail());
user.setStripeCustomerId(customer.getId());
userService.save(user);
}
ResponseR r = ResponseR.ok().data(user);
return JSONUtil.toJsonStr(r);
}
@Override
public Customer create(String name, String email) throws StripeException {
CustomerCreateParams params =
CustomerCreateParams.builder()
.setName(name)
.setEmail(email)
.build();
return Customer.create(params);
}
这里注册接口分两部分, 一部分是入口函数 stripeService.create是用于将用户在stripe上去创建一个用户, 这也是本demo用到的第一个Stripe java api
这一步很重要, 因为他会返回一个用户在stripe管理平台的customer的id。
好了, 注册完返回就行了。
绑定信用卡
新建一个页面 BindCard.vue
<template>
<main>
<h1>绑定新的银行卡</h1>
<form id="payment-form" @submit.prevent="handleBind">
<div id="card-element"></div>
<button id="submit-button">去绑定</button>
</form>
<h1>已经绑定的银行卡</h1>
<ul v-for="(item, index) in objectArray" :key="index">
<li>序号:{{ index + 1 }}</li>
<li>方式ID:{{ item.paymentMethodId }}</li>
<li>卡类型:{{ item.cardBrand }}</li>
<li>后四位:{{ item.last4Digits }}</li>
</ul>
<sr-messages :messages="messages" />
</main>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { loadStripe } from "@stripe/stripe-js";
import SrMessages from "./SrMessages.vue";
const messages = ref([]);
let stripe;
let elements;
let cardElement;
let objectArray = ref([]);
onMounted(async () => {
//需要从服务器获取到公钥的key。其实这个公钥的key直接写给前端好像也行。
//因为官方的示例是这么用的, 所以我也就这么用了
//是.env中的 STRIPE_PUBLISHABLE_KEY
const { publishableKey } = await fetch("/api/stripe/config").then((res) => res.json());
// 使用公钥来初始化 Stripe js对象
stripe = await loadStripe(publishableKey);
//获取元素
elements = stripe.elements();
//创建card类型的表单, 这里card类型就是指的信用卡类型
cardElement = elements.create('card');
// 将这个表单挂在到页面的id上 就是html部分<div id="card-element"></div>
cardElement.mount('#card-element');
//这里是调用一个接口, 获取用户已绑定的信用卡信息
objectArray.value = await fetch(
'/api/stripe/bindCardList',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerId: sessionStorage.getItem('stripeCustomerId'),
}),
}
).then((r) => {
console.log('第一个then', r)
return r.json()
}).then((r) => {
console.log('第二个then', r)
return r
});
return objectArray;
});
//绑定新的卡片
const handleBind = async () => {
// 调用前端方法跟stripe进行交互(stripe 绑定卡的表单是一个iframe。
// billing_details 字段有很多, 详细的可以看官方文档
stripe.createPaymentMethod({
type: 'card',
card: cardElement,
billing_details: {
name: sessionStorage.getItem('name'),
},
})
.then(function(result) {
console.log(result)
// Handle result.error or result.paymentMethod
if (result.error) {
// 处理错误
console.error(result.error.message);
} else {
// PaymentMethod 创建成功,将其传递给服务器进行进一步处理
//paymentMethodId = result.paymentMethod.id;
// 将 paymentMethodId 发送到服务器端
// 这里你可以使用 AJAX 或其他方法将 paymentMethodId 发送到服务器
fetch(
'/api/user/addPaymentMethod',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: sessionStorage.getItem('id'),
paymentMethodId: result.paymentMethod.id,
}),
}).then((r) => {
console.log('第一个then', r)
return r.json()
}).then((r) => {
console.log('第二个then', r)
if (r.code == 200) {
//刷新已绑定银行卡列表
} else {
messages.value.push('服务错误,本地添加银行卡错误');
}
});
}
});
}
</script>
js部分相信 能看到这里的同学一定也能看得懂了。无非就是一个查询已绑定卡的接口。获取并渲染到页面上。另一个是去新绑定卡片提交的地址。
handleBind 方法中,第一步stripe.createPaymentMethod 是完全由前端和stripe进行交互。成功后会返回一个支付方式的id。将支付方式id(paymentMethodId发送到后端, 后端根据当前登录用户去获取当前登录用户在stripe的customerId。 将两个id进行关联, 关联后, 支付卡片和用户就进行绑定了。
user/addPaymentMethod接口java代码
@PostMapping("addPaymentMethod")
public String addPaymentMethod(@RequestBody UserAddPaymentMethodBO userAddPaymentMethodBO) throws StripeException {
UserPaymentMethod userPaymentMethod = userPaymentMethodService.lambdaQuery()
.eq(UserPaymentMethod::getPaymentMethodId, userAddPaymentMethodBO.getPaymentMethodId())
.one();
if (Objects.nonNull(userPaymentMethod)) {
ResponseR r = ResponseR.error().message(StrUtil.format("已经存在当前支付方式id{}", userAddPaymentMethodBO.getPaymentMethodId()));
return JSONUtil.toJsonStr(r);
}
userPaymentMethod = new UserPaymentMethod();
userPaymentMethod.setUserId(userAddPaymentMethodBO.getUserId());
userPaymentMethod.setPaymentMethodId(userAddPaymentMethodBO.getPaymentMethodId());
userPaymentMethodService.save(userPaymentMethod);
User user = userService.getById(userAddPaymentMethodBO.getUserId());
//创建一个 PaymentMethodAttachParams 对象
PaymentMethodAttachParams params = PaymentMethodAttachParams.builder()
.setCustomer(user.getStripeCustomerId())
.build();
// 使用 paymentMethodId 和 params 进行关联
PaymentMethod paymentMethodRes = PaymentMethod.retrieve(userAddPaymentMethodBO.getPaymentMethodId());
paymentMethodRes.attach(params);
System.out.println("PaymentMethod successfully attached to Customer.");
return JSONUtil.toJsonStr(ResponseR.ok().data(paymentMethodRes));
}
支付
支付的大概流程是 由前端发起一个支付的请求,将要付款的元素(例如付款金额)当然真正的生产环境中是不会从前端来获取真实的交易金额的。要使用的信用卡,这里是当前登录用户在stripe绑定的信用卡(对于应用系统来说是stripe的支付方式id), 因为应用是不允许持有用户信用卡信息的, 如果应用要持有这些信息那么需要应用的开发者(公司)提供合规证明。
前端指定一个支付方式和价格信息给到后端。
后端根据信用卡(也就是paymentMethodId)和加个信息到stripe创建一个支付意向信息(相当于是一个等级 某个paymentMethodId有意向 支付xx钱。然后stripe的远程服务会返回一个paymentIntent,其中的paymentIntentId 返回给前端
前端根据paymentIntentId 调用stripe.js 来确认支付,stripe给前端返回res里面包含了成功或失败的信息,如果成功paymentIntent.status的值为successed。如果失败则res.error中去获取详细的失败信息。
这时, 如果失败, 则给后端一个paymentIntentId告知后端支付失败, 后端去处理失败的逻辑
如果成功, 也是给paymentIntentId到后端, 处理成功的逻辑
前端页面
<template>
<main>
<a href="/">home</a>
<form id="payment-form" @submit.prevent="handleSubmit">
<div>
<label for="price"><input name="price" id="price">美元</label>
</div>
<h1>请选择付款卡</h1>
<ul v-for="(item, index) in objectArray" :key="index">
<input type="radio" name="paymentMethodId" :id="`option${index}`" v-model="selectedOption" :value="item.paymentMethodId" @change="handleChange">
<li>序号:{{ index + 1 }}</li>
<li>方式ID:{{ item.paymentMethodId }}</li>
<li>卡类型:{{ item.cardBrand }}</li>
<li>后四位:{{ item.last4Digits }}</li>
</ul>
<button id="submit-button">支付</button>
</form>
<sr-messages :messages="messages" />
</main>
</template>
前端js 分几个部分, 都在注释里面了。
<script setup>
import { ref, onMounted } from "vue";
import { loadStripe } from "@stripe/stripe-js";
import SrMessages from "./SrMessages.vue";
const messages = ref([]);
let stripe;
let objectArray = ref([]);
let selectedOption = ""
onMounted(async () => {
const { publishableKey } = await fetch("/api/stripe/config").then((res) => res.json());
stripe = await loadStripe(publishableKey);
// part1 获取已绑定卡
objectArray.value = await fetch(
'/api/stripe/bindCardList',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerId: sessionStorage.getItem('stripeCustomerId'),
}),
}
).then((r) => {
console.log('第一个then', r)
return r.json()
}).then((r) => {
console.log('第二个then', r)
return r
});
return {
objectArray
}
});
//打印一个选择卡片事件, 这个其实没什么用。只是在demo中打个日志
const handleChange = async () => {
messages.value.push(selectedOption)
console.log(selectedOption)
}
// part2 提交支付, stripe/paymentIntent 接口创建支付意向信息, 并获取支付意向id
const handleSubmit = async () => {
const priceInput = document.querySelector('#price');
messages.value.push(priceInput.value)
await fetch('/api/stripe/paymentIntent',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: sessionStorage.getItem('id'),
price: priceInput.value
}),
})
.then(r => r.json())
.then(data => {
messages.value.push('支付前')
// part3 确认支付。这里可以增加确认按钮再去调用也行。 根据产品的流程进行即可
confirmPayment(data.clientSecret, selectedOption)
});
}
// part3
const confirmPayment = async(clientSecret, selectedOption) => {
messages.value.push('支付中')
stripe.confirmCardPayment(clientSecret, {
payment_method: selectedOption, // 使用已绑定的支付方式
})
.then(result => {
// 获取支付确认返回
console.log(result)
if (result.error) {
// 处理错误, 我这里错误就没给后端了。真实的业务逻辑中其实也应该给后端
// 其实不给也是可以的。 stripe还有一个webhook。 你的系统通过这个钩子来异步的处理这个信息也是没有问题的
console.error(result);
} else {
// 支付成功, 同步成功最好能直接给后端一个请求去验证支付是否成功了。 如果成功了, 直接让前端调转到下一个流程
console.log(result.paymentIntent.id)
messages.value.push("去确认结果")
fetch('/api/stripe/checkPayRes',{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: sessionStorage.getItem('id'),
paymentIntentId: result.paymentIntent.id
}),
})
.then(r => {
console.log('第一个then', r)
return r.json()
})
.then(res => {
console.log(res)
console.log(res.message)
messages.value.push(res.message)
})
}
});
}
</script>
java 接口核心
/**
* 创建预付- 前端拿到客户端动态密钥后 去确认支付
* @param stripePaymentIntentBO
* @return
* @throws StripeException
*/
@PostMapping("paymentIntent")
public String paymentIntent(@RequestBody StripePaymentIntentBO stripePaymentIntentBO) throws StripeException {
User user = userService.getById(stripePaymentIntentBO.getUserId());
Long price = stripePaymentIntentBO.getPrice().multiply(new BigDecimal(1000)).longValue();
// 创建 PaymentIntent
PaymentIntent paymentIntent = PaymentIntent.create(
new PaymentIntentCreateParams.Builder()
.setCustomer(user.getStripeCustomerId())
.setCurrency("cad")
.setAmount(price) // 金额以分为单位
.build()
);
return JSONUtil.toJsonStr(paymentIntent);
}
/**
* 检查支付结果
* @param stripeCheckPayResBO
* @return
* @throws StripeException
*/
@PostMapping("checkPayRes")
public String checkPayRes(@RequestBody StripeCheckPayResBO stripeCheckPayResBO) throws StripeException {
ResponseR r = ResponseR.ok().message("经服务端验证,支付成功");
// 使用 PaymentIntent.retrieve 方法检查支付状态
PaymentIntent paymentIntent = PaymentIntent.retrieve(stripeCheckPayResBO.getPaymentIntentId());
System.out.println(JSONUtil.toJsonStr(paymentIntent));
if (!StringUtils.equals(paymentIntent.getStatus(), "succeeded")) {
r = ResponseR.error().message("经服务端验证,支付失败");
}
return JSONUtil.toJsonStr(r.message("充值成功"));
}
微信或支付宝支付
关于微信和支付宝支付, 具体不写了。看demo。能看到这里的应该不难了。
其中支付宝支付我是能测试成功的。 但是微信支付总是失败, 不知道是不是跟网络有关系。
附录中的demo有相关代码。请自行查阅
附录
表结构:
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`stripe_customer_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户在stripe注册得客户id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS `user_payment_method`;
CREATE TABLE `user_payment_method` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NULL DEFAULT NULL,
`payment_method_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
开发时服务器配置
vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://localhost:9900/",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
})
使用到的java api
Customer.create(params);
相关配置类和配置文件
.env
# Stripe API keys - see https://stripe.com/docs/development/quickstart#api-keys
STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
STRIPE_SECRET_KEY=sk_test_xxxx
DotenvConf.java
@Configuration
public class DotenvConf {
@Bean("dotenv")
public Dotenv dotenv() {
return Dotenv.load();
}
}
AppStartAndStopNotify.java
这里其实就是干了一个事情, 把密钥赋值给Stripe对象的一个静态变量apiKey中。不用我这个方式, 用其他方式都行。
@Component
@Slf4j
public class AppStartAndStopNotify implements CommandLineRunner {
@Autowired
private Dotenv dotenv;
@Override
public void run(String... args) throws Exception {
Stripe.apiKey = dotenv.get("STRIPE_SECRET_KEY");
//这里是实验环境, 在控制台打印了key, 实际开发中没必要打印他
log.info(StrUtil.format("设置Stripe.apiKey成功:{}", Stripe.apiKey));
log.info("应用启动成功");
}
}
示例代码
stripepay-bindcard: stripe支付的一个 从用户创建 绑卡 消费的一个全流程示例项目https://gitee.com/azhw/stripepay-bindcard.git