SSLCommerz 支付成功后无法重定向到目标页面
结论
不要把 SSLCommerz 的 success_url 直接设置为 React/Vite 页面。建议按下面的流程处理:
- 前端请求后端创建支付会话。
- 后端返回
GatewayPageURL。 - 浏览器跳转到 SSLCommerz 收银台。
- 支付完成后,SSLCommerz 回调后端。
- 后端验证交易并更新订单,然后通过 HTTP
302重定向到前端订单页。
apiResponse.status === "SUCCESS" 只表示支付会话创建成功,并不表示用户已经付款。因此,不能在这个阶段把订单标记为已支付。
为什么会出现这个问题
这个地址:
http://localhost:5173/order/$%7Border._id%7D
解码后包含:
${order._id}
这说明代码在某处把模板表达式当成了普通字符串。JavaScript 变量插值需要使用反引号:
`http://localhost:5173/order/${order._id}`
使用单引号或双引号都不会执行插值:
"http://localhost:5173/order/${order._id}"
'http://localhost:5173/order/${order._id}'
题目中的后端代码已经使用了反引号,因此还要检查:
- 实际运行的是否是修改后的代码;
- 服务器是否已经重启;
- 是否还有其他代码生成了
success_url; - SSLCommerz 是否仍在使用之前创建的支付会话;
- 日志中实际提交给 SSLCommerz 的
success_url是什么。
如果支付跳转失败,但复制地址后在另一个标签页中可以正常访问,也可能是请求方法不同造成的。支付网关可能使用 POST 请求访问 success_url,而在地址栏中直接打开链接使用的是 GET。Vite 前端页面通常只适合处理浏览器的 GET 导航,不适合作为支付网关的回调接口。
推荐的后端实现
创建支付会话时,把回调地址设置为后端接口:
export const createPayment = async (req, res) => {
try {
if (!req.body) {
return res.status(400).json({
message: "No request body",
success: false,
});
}
if (req.user.isAdmin) {
return res.status(403).json({
message: "Admins cannot make payments",
success: false,
});
}
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({
message: "Order not found",
success: false,
});
}
const tran_id = uuidv4();
// 创建支付会话时先保存交易号,但不要标记为已支付
order.transactionId = tran_id;
await order.save();
const backendUrl =
process.env.BACKEND_URL || "http://localhost:3030";
const data = {
total_amount: order.totalPrice,
currency: "BDT",
tran_id,
success_url: `${backendUrl}/api/payment/success`,
fail_url: `${backendUrl}/api/payment/fail`,
cancel_url: `${backendUrl}/api/payment/cancel`,
ipn_url: `${backendUrl}/api/payment/ipn`,
shipping_method: "Courier",
product_name: "something",
product_category: "something",
product_profile: order.orderItems
.map((item) => item.imagesUrls[0])
.join(", "),
cus_name: order.deliveryAddress.name,
cus_email: order.deliveryAddress.email,
cus_address: order.deliveryAddress.address,
cus_country: "Bangladesh",
cus_phone: order.deliveryAddress.phone,
ship_name: order.deliveryAddress.name,
ship_add1: order.deliveryAddress.address,
ship_city: "Dhaka",
ship_state: "Dhaka",
ship_postcode: 1000,
ship_country: "Bangladesh",
};
console.log("SSLCommerz callback URLs:", {
success_url: data.success_url,
fail_url: data.fail_url,
cancel_url: data.cancel_url,
});
const sslcz = new SSLCommerzPayment(
store_id,
store_password,
isLive
);
const apiResponse = await sslcz.init(data);
if (
apiResponse.status !== "SUCCESS" ||
!apiResponse.GatewayPageURL
) {
return res.status(502).json({
message: "Could not create payment session",
success: false,
});
}
return res.status(200).json({
success: true,
gatewayUrl: apiResponse.GatewayPageURL,
});
} catch (error) {
return res.status(500).json({
message: error.message,
success: false,
});
}
};
原代码在创建支付会话后立即执行了下面的逻辑,这部分应该删除:
if (apiResponse.status === "SUCCESS") {
order.isPaid = true;
}
这个判断只能证明 sslcz.init(data) 调用成功,无法证明用户已经完成支付。
处理支付成功回调
Express 需要启用表单请求体解析,因为支付网关的回调未必使用 JSON:
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
在成功回调中,先根据 tran_id 查找订单,再向 SSLCommerz 验证交易。不同 SDK 版本的验证方法和返回字段可能略有不同,下面使用常见的 validate() 接口:
export const paymentSuccess = async (req, res) => {
const frontendUrl =
process.env.FRONTEND_URL || "http://localhost:5173";
try {
const { tran_id } = req.body;
if (!tran_id) {
return res.redirect(
302,
`${frontendUrl}/payment/error?reason=missing_transaction`
);
}
const order = await Order.findOne({ transactionId: tran_id });
if (!order) {
return res.redirect(
302,
`${frontendUrl}/payment/error?reason=order_not_found`
);
}
const sslcz = new SSLCommerzPayment(
store_id,
store_password,
isLive
);
const validation = await sslcz.validate(req.body);
const isValid =
validation.status === "VALID" ||
validation.status === "VALIDATED";
if (!isValid) {
return res.redirect(
302,
`${frontendUrl}/order/${encodeURIComponent(
order._id.toString()
)}?payment=invalid`
);
}
// 建议同时核对交易号、金额、币种以及订单当前状态
const amountMatches =
Number(validation.amount) === Number(order.totalPrice);
const currencyMatches = validation.currency === "BDT";
if (!amountMatches || !currencyMatches) {
return res.redirect(
302,
`${frontendUrl}/order/${encodeURIComponent(
order._id.toString()
)}?payment=mismatch`
);
}
if (!order.isPaid) {
await Promise.all(
order.orderItems.map((item) =>
Book.updateOne(
{ _id: item.product },
{
$set: {
bookStatus: item.orderType,
isAvailable: false,
},
}
)
)
);
order.isPaid = true;
order.isAvailable = false;
order.paidAt = new Date();
await order.save();
}
return res.redirect(
302,
`${frontendUrl}/order/${encodeURIComponent(
order._id.toString()
)}?payment=success`
);
} catch (error) {
console.error("Payment callback error:", error);
return res.redirect(
302,
`${frontendUrl}/payment/error?reason=callback_failed`
);
}
};
注册对应的回调路由:
router.post("/api/payment/success", paymentSuccess);
router.post("/api/payment/fail", paymentFail);
router.post("/api/payment/cancel", paymentCancel);
router.post("/api/payment/ipn", paymentIpn);
支付失败或被取消时,也由后端重定向到前端页面:
export const paymentFail = async (req, res) => {
const frontendUrl =
process.env.FRONTEND_URL || "http://localhost:5173";
const order = await Order.findOne({
transactionId: req.body.tran_id,
});
if (!order) {
return res.redirect(
302,
`${frontendUrl}/payment/error?reason=order_not_found`
);
}
return res.redirect(
302,
`${frontendUrl}/order/${encodeURIComponent(
order._id.toString()
)}?payment=failed`
);
};
export const paymentCancel = async (req, res) => {
const frontendUrl =
process.env.FRONTEND_URL || "http://localhost:5173";
const order = await Order.findOne({
transactionId: req.body.tran_id,
});
if (!order) {
return res.redirect(
302,
`${frontendUrl}/payment/error?reason=order_not_found`
);
}
return res.redirect(
302,
`${frontendUrl}/order/${encodeURIComponent(
order._id.toString()
)}?payment=cancelled`
);
};
IPN 也要执行相同的服务端验证和幂等更新。可以把“验证交易并更新订单”的逻辑提取为公共函数,避免成功回调和 IPN 各自实现一遍。
修改前端跳转代码
后端改为返回结构化 JSON 后,前端从响应中读取 gatewayUrl:
const handlePay = async () => {
const result = await Swal.fire({
title: "Are you sure?",
icon: "success",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, continue it!",
});
if (!result.isConfirmed) {
return;
}
try {
const res = await paymentServer(order, orderId);
const gatewayUrl = res?.data?.gatewayUrl;
if (!gatewayUrl) {
throw new Error("Payment gateway URL is missing");
}
// 当前页面进入收银台,支付完成后再由后端重定向回来
window.location.assign(gatewayUrl);
} catch (error) {
console.error("Could not start payment:", error);
}
};
请求函数不应该把 Axios 错误对象当作正常响应返回:
const paymentServer = async (data, id) => {
setLoading(true);
try {
return await axiosPublic.put(
`/api/order/createPayment/${id}`,
data,
{
withCredentials: true,
}
);
} finally {
setLoading(false);
}
};
这里更适合使用:
window.location.assign(gatewayUrl);
而不是:
window.open(gatewayUrl, "_blank");
这样可以在同一个标签页中完成整个支付流程,也不会受到浏览器弹窗拦截策略的影响。
本地开发时的限制
localhost 指向的是访问者当前使用的设备。SSLCommerz 的服务器无法直接访问开发者电脑上的这个地址:
http://localhost:3030/api/payment/ipn
因此,在本地开发环境中:
- 用户浏览器发起的前端跳转可能正常;
- SSLCommerz 的服务端 IPN 通知通常无法访问本机地址;
- 某些回调流程可能还要求使用公网可访问的 HTTPS 地址。
要完整测试服务端回调,需要使用公开的测试域名或安全隧道,并通过环境变量配置地址:
FRONTEND_URL=https://frontend.example.com
BACKEND_URL=https://api.example.com
生产环境不要使用 localhost,并优先使用 HTTPS。
还需要注意
- 不能因为浏览器回到了成功页面,就直接认定支付成功。交易必须由后端验证。
- 不要信任回调中尚未验证的金额、状态或订单编号。
- 使用
tran_id关联 SSLCommerz 交易和订单。 - 更新订单时要保证幂等,避免成功回调和 IPN 同时到达后重复扣减库存。
- 如果地址中仍然出现
${order._id},请在调用sslcz.init(data)前打印最终的success_url。只要日志里还有${...},问题就出在 URL 生成代码,或者服务器仍在运行未重启的旧代码。
备注:内容仅供参考。
