PAYATHON 2026

SSLCommerz 支付成功后无法重定向到目标页面

支付老李

结论

不要把 SSLCommerz 的 success_url 直接设置为 React/Vite 页面。建议按下面的流程处理:

  1. 前端请求后端创建支付会话。
  2. 后端返回 GatewayPageURL。
  3. 浏览器跳转到 SSLCommerz 收银台。
  4. 支付完成后,SSLCommerz 回调后端。
  5. 后端验证交易并更新订单,然后通过 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 生成代码,或者服务器仍在运行未重启的旧代码。

备注:内容仅供参考。