PAYATHON 2026

NodeJS、React、MongoDB 网站如何集成奖金支付

支付阿杰

结论

不需要为每个用户单独开发电子钱包。更合适的方案是接入支持平台分账或批量付款的支付服务,例如:

  • Stripe Connect:适合向已经完成开户注册的 Connected Account 转账。
  • PayPal Payouts:适合根据 PayPal 账户、邮箱等信息批量发放奖金。
  • Adyen for Platforms:适合业务规模较大,需要完整 KYC 和资金结算能力的平台。
  • 如果业务位于中国大陆,应向持牌支付机构咨询“商户付款到个人”“批量付款”等产品,不能直接用个人微信或支付宝账户自动转账。

“管理员一键发奖”只能作为业务入口。实际付款应由 Node.js 后端异步处理,同时做好身份验证、排行榜锁定、幂等控制、数据库账本和支付回调。React 前端不能直接调用支付机构的私密 API。

上线前还要确认,这种收费答题并按排名发放奖金的模式,在运营地区是否会被认定为有奖竞赛、博彩、抽奖或受监管的资金归集业务。支付机构也可能禁止或限制这类商户,因此应先请支付服务商和当地专业律师审核业务模式。

推荐的整体流程

完整流程可以这样设计:

用户报名并付款
    ↓
支付网关通过 webhook 确认付款成功
    ↓
用户参加答题
    ↓
系统计算并冻结最终排行榜
    ↓
管理员点击“发放奖金”
    ↓
Node.js 创建 5 条奖金记录
    ↓
后台任务调用支付机构 API
    ↓
支付机构通过 webhook 返回结果
    ↓
MongoDB 更新为 paid / failed / reversed

前五名必须由后端根据已经冻结的成绩计算,不能由 React 直接提交五个用户 ID。否则,一旦管理员账号或浏览器请求被篡改,奖金就可能转入错误账户。

数据模型

至少需要保存三类数据:

// Quiz
{
  _id: ObjectId,
  status: "draft" | "active" | "grading" | "finalized",
  winners: [
    {
      rank: 1,
      userId: ObjectId,
      amount: 5000 // 最小货币单位,例如 5000 表示 50.00
    }
  ],
  resultVersion: 3,
  finalizedAt: Date
}

// User
{
  _id: ObjectId,
  paymentAccount: {
    provider: "stripe",
    accountId: "acct_xxx",
    onboardingStatus: "complete"
  }
}

// PrizePayment
{
  _id: ObjectId,
  quizId: ObjectId,
  userId: ObjectId,
  rank: 1,
  amount: 5000,
  currency: "usd",
  status: "pending" | "processing" | "paid" | "failed" | "reversed",
  provider: "stripe",
  providerTransferId: null,
  idempotencyKey: "quizId:userId:resultVersion",
  error: null,
  createdAt: Date,
  paidAt: null
}

金额应始终以整数形式保存,并使用最小货币单位,不要用 JavaScript 浮点数存储 49.99。还要为 PrizePayment.idempotencyKey 建立唯一索引,避免重复点击导致二次付款:

prizePaymentSchema.index({ idempotencyKey: 1 }, { unique: true });

Node.js 发奖接口示例

下面以 Express、MongoDB/Mongoose 和 Stripe Connect 为例,展示发奖接口的核心结构。实际项目还需要接入现有的认证、权限系统和任务队列。

import express from "express";
import Stripe from "stripe";
import { Quiz, User, PrizePayment } from "./models.js";

const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

router.post(
  "/api/admin/quizzes/:quizId/distribute-prizes",
  requireAdmin,
  async (req, res, next) => {
    try {
      const quiz = await Quiz.findById(req.params.quizId);

      if (!quiz || quiz.status !== "finalized") {
        return res.status(409).json({
          error: "Quiz results have not been finalized"
        });
      }

      const winners = quiz.winners
        .filter((winner) => winner.rank <= 5)
        .sort((a, b) => a.rank - b.rank);

      if (winners.length !== 5) {
        return res.status(409).json({
          error: "Exactly five finalized winners are required"
        });
      }

      const jobs = [];

      for (const winner of winners) {
        const user = await User.findById(winner.userId);

        if (
          !user?.paymentAccount?.accountId ||
          user.paymentAccount.onboardingStatus !== "complete"
        ) {
          return res.status(409).json({
            error: `Winner ${winner.userId} has no verified payout account`
          });
        }

        const idempotencyKey =
          `${quiz.id}:${winner.userId}:${quiz.resultVersion}`;

        const payment = await PrizePayment.findOneAndUpdate(
          { idempotencyKey },
          {
            $setOnInsert: {
              quizId: quiz.id,
              userId: winner.userId,
              rank: winner.rank,
              amount: winner.amount,
              currency: "usd",
              provider: "stripe",
              status: "pending",
              idempotencyKey
            }
          },
          {
            upsert: true,
            new: true
          }
        );

        jobs.push(payment.id);
      }

      // 实际项目中应把这些 ID 投递到 BullMQ、SQS 等任务队列。
      await payoutQueue.addBulk(
        jobs.map((paymentId) => ({
          name: "send-prize",
          data: { paymentId },
          opts: { jobId: paymentId }
        }))
      );

      res.status(202).json({
        message: "Prize distribution has been queued",
        paymentIds: jobs
      });
    } catch (error) {
      next(error);
    }
  }
);

接口返回 202 Accepted,表示任务已经进入队列,并不代表奖金已经到账。管理员页面应分别显示每一笔奖金的处理状态。

Stripe Connect 转账任务

每个获奖用户都要先完成 Stripe Connect 的开户注册和身份验证。平台不能只收集银行卡号并自行保存。

async function processPrizePayment(paymentId) {
  const payment = await PrizePayment.findById(paymentId);

  if (!payment || payment.status === "paid") {
    return;
  }

  const user = await User.findById(payment.userId);

  await PrizePayment.updateOne(
    { _id: payment.id, status: "pending" },
    { $set: { status: "processing" } }
  );

  try {
    const transfer = await stripe.transfers.create(
      {
        amount: payment.amount,
        currency: payment.currency,
        destination: user.paymentAccount.accountId,
        transfer_group: `QUIZ_${payment.quizId}`,
        metadata: {
          prizePaymentId: payment.id,
          quizId: String(payment.quizId),
          userId: String(payment.userId),
          rank: String(payment.rank)
        }
      },
      {
        idempotencyKey: payment.idempotencyKey
      }
    );

    await PrizePayment.updateOne(
      { _id: payment.id },
      {
        $set: {
          status: "paid",
          providerTransferId: transfer.id,
          paidAt: new Date(),
          error: null
        }
      }
    );
  } catch (error) {
    await PrizePayment.updateOne(
      { _id: payment.id },
      {
        $set: {
          status: "failed",
          error: {
            code: error.code ?? "provider_error",
            message: error.message
          }
        }
      }
    );

    throw error;
  }
}

在 Stripe Connect 中,Transfer 通常表示平台把余额转移到 Connected Account。资金何时从该账户提现到获奖者的银行账户,还会受到账户类型、结算计划、地区、币种、可用余额和风控审核的影响。因此,界面最好区分“平台已转账”和“银行已到账”。

如果平台通过 Stripe 收取报名费,还要根据具体资金流选择 destination charges 或 separate charges and transfers,不能只凭这段示例确定资金模型。

Webhook 处理

支付结果不能只看 API 请求的即时响应。网络超时可能发生在支付机构已经受理请求之后。此时如果直接重试,又没有使用幂等键,就可能重复付款。

Webhook 处理至少要满足以下要求:

  • 验证签名,并使用原始请求体完成验证。
  • 保存支付机构的 event ID,并为其建立唯一索引。
  • 收到重复事件时直接返回成功,不再修改账本。
  • 记录退款、撤销、账户受限等后续状态。
  • 尽快返回 2xx,耗时操作交给队列处理。
  • 定期主动对账,不能把 webhook 作为唯一数据来源。

Stripe 在 Express 中的验签形式大致如下:

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch {
      return res.sendStatus(400);
    }

    const inserted = await WebhookEvent.updateOne(
      { providerEventId: event.id },
      {
        $setOnInsert: {
          providerEventId: event.id,
          type: event.type,
          receivedAt: new Date()
        }
      },
      { upsert: true }
    );

    if (inserted.upsertedCount === 0) {
      return res.sendStatus(200);
    }

    await webhookQueue.add("stripe-event", { event });
    res.sendStatus(200);
  }
);

要特别注意 Express 中间件的顺序。Stripe webhook 路由需要读取原始请求体,不能先经过全局 express.json() 转换。

是否需要内部钱包

只有当用户需要长期保存余额,并进行充值、消费、退款、奖金累积和提现时,才可能需要“钱包”。即便如此,也不应该从零开发真正托管资金的钱包。

平台可以维护一套内部账本,用于展示:

报名费       -1000
一等奖奖金   +5000
提现         -5000

但内部账本只是会计记录,并不会自动赋予平台保管用户资金的资格。如果用户余额可以长期存放、互相转账或随时提现,通常会涉及支付牌照、KYC、AML、资金隔离、税务和无人认领财产等要求。使用支付机构提供的平台账户或托管能力会更稳妥。

对于“比赛结束后直接向五名获奖者付款”这种简单场景,通常不需要开发钱包。

支付服务的选择

Stripe Connect

适合 Stripe 已支持地区的双边平台。它提供 Connected Account、账户开户注册、身份验证、Transfer 和 Payout 等能力。

是否支持平台所在国家、获奖者所在国家和相关业务类型,需要按照上线时的 Stripe 国家与业务限制确认。

PayPal Payouts

PayPal Payouts 支持在一个批次中提交多笔付款,比较符合“向前五名批量发奖”的需求。商户通常需要申请并取得 Payouts 权限,不能默认普通 PayPal 账户可以直接使用。

批次请求成功只说明 PayPal 已经接受任务。每一笔付款仍可能处于 pending、success、failed 等不同状态,系统必须逐笔跟踪。

Adyen for Platforms

适合需要完整的平台账户、余额和分账能力,并且能够承担较高集成成本的业务。

上线前必须处理的问题

  • 明确奖金规则,包括并列名次、弃权、作弊、退款和取消比赛的处理方式。
  • 成绩发布后冻结排行榜,并保存规则版本和计算依据。
  • 要求用户在参赛或领奖前完成必要的 KYC、年龄和地区验证。
  • 不要在 MongoDB 中存储银行卡号、CVV 或网银凭据。
  • Secret Key 只能保存在后端的密钥管理系统或环境变量中。
  • 为发奖接口增加管理员权限、二次确认和审计日志。
  • 设置单笔、单场和每日奖金上限,异常金额交由人工复核。
  • 分别记录每个获奖者的付款状态,不能把五笔付款当成不可拆分的事务。
  • 失败重试时必须复用同一个幂等键。
  • 每天核对报名费、平台手续费、奖金、退款和支付机构余额。
  • 提前确定手续费和税务由谁承担,以及是否需要收集获奖者的税务资料。

应由支付机构完成身份验证和资金移动,平台自己的系统则负责比赛规则、获奖名单、幂等账本、任务调度和对账。这种方案比自行开发电子钱包更安全,也更符合支付合规要求。

备注:内容仅供参考。