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 只能保存在后端的密钥管理系统或环境变量中。
- 为发奖接口增加管理员权限、二次确认和审计日志。
- 设置单笔、单场和每日奖金上限,异常金额交由人工复核。
- 分别记录每个获奖者的付款状态,不能把五笔付款当成不可拆分的事务。
- 失败重试时必须复用同一个幂等键。
- 每天核对报名费、平台手续费、奖金、退款和支付机构余额。
- 提前确定手续费和税务由谁承担,以及是否需要收集获奖者的税务资料。
应由支付机构完成身份验证和资金移动,平台自己的系统则负责比赛规则、获奖名单、幂等账本、任务调度和对账。这种方案比自行开发电子钱包更安全,也更符合支付合规要求。
备注:内容仅供参考。
