ساخت سیستم امتیازدهی در Node.js
سیستم امتیازدهی (Points/Scoring) یکی از المانهای کلیدی در محصولات گیمیفیکیشن، اپهای آموزشی، فروشگاهی و شبکههای اجتماعی است. در این مقاله بهصورت عملی طراحی، پیادهسازی و بهینهسازی یک سیستم امتیازدهی با Node.js را بررسی میکنیم. مثالها با استفاده از Express، MongoDB (Mongoose) و Redis ارائه میشوند تا هم دوام داده و هم مقیاسپذیری پوشش داده شود.
نیازمندیها و نکات طراحی
- قابلیت افزایش/کاهش امتیاز برای کاربران
- لیست لیدربورد (top N) با بازدهی بالا
- اتمیسیته و جلوگیری از تقلب (rate limiting، شناسایی چند اکانت)
- مقیاسپذیری: امکان شارد، کش و نگهداری تاریخی
- قابلیت محاسبات پیچیده: decay، رتبهبندی پویا، فرمول امتیاز
مدل دادهای پیشنهادی
دو رویکرد معمول:
- MongoDB برای نگهداری منبع حقیقت (user points, history)
- Redis برای محاسبه و ارائه سریع لیدربورد (zset)
نمونه مدل Mongoose
const mongoose = require('mongoose');
const PointSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, required: true, index: true, unique: true },
points: { type: Number, default: 0 },
history: [
{
change: Number,
reason: String,
createdAt: { type: Date, default: Date.now }
}
],
updatedAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('Point', PointSchema);
این اسکیمای ساده شامل مجموع امتیاز، آرایه تاریخچه تغییرات و فیلدهای زمانبندی است. history میتواند برای تحلیل و بازبینی تقلب مفید باشد؛ اما در سیستمهای بزرگ تاریخچه را معمولاً جدا و بهصورت لاگ ذخیره میکنیم.
پیادهسازی API پایه با Redis و Mongo
روش پیشنهادی: هر تغییر امتیاز ابتدا در Mongo ثبت شده و سپس Redis برای لیدربورد بهروزرسانی میشود (eventual consistency).
const express = require('express');
const mongoose = require('mongoose');
const Redis = require('ioredis');
const Point = require('./models/Point');
const app = express();
app.use(express.json());
const redis = new Redis(); // default config
app.post('/api/points', async (req, res) => {
const { userId, delta, reason } = req.body;
if (!userId || typeof delta !== 'number') return res.status(400).send('Invalid');
// Update Mongo
const updated = await Point.findOneAndUpdate(
{ userId },
{
$inc: { points: delta },
$push: { history: { change: delta, reason } },
$set: { updatedAt: new Date() }
},
{ upsert: true, new: true }
);
// Update Redis leaderboard
await redis.zincrby('leaderboard', delta, userId.toString());
res.json({ userId, points: updated.points });
});
app.get('/api/leaderboard', async (req, res) => {
const top = await redis.zrevrange('leaderboard', 0, 49, 'WITHSCORES');
// top is [userId1, score1, userId2, score2, ...]
const result = [];
for (let i = 0; i < top.length; i += 2) {
result.push({ userId: top[i], points: Number(top[i+1]) });
}
res.json(result);
});
app.listen(3000);
این کد یک نقطهٔ شروع ساده است: endpoint برای اضافه/کسر امتیاز و یک endpoint برای دریافت ۵۰ نفر برتر. از Redis ZINCRBY استفاده میکنیم که از نظر عملکرد ایدهآل برای لیدربورد است.
ملاحظات اتمی و ثبات داده
بهدلیل دو منبع داده (Mongo و Redis)، حفظ اتمیتهٔ کامل سخت است. گزینهها:
- استفاده از transaction در Mongo برای تغییرات و سپس تلاش برای بروزرسانی Redis؛ قبول eventual consistency
- استفاده از Redis بهعنوان منبع اصلی و دورهای سنکرون با Mongo (snapshot یا stream)
- استفاده از صف پیام (Kafka / RabbitMQ) برای اعمال تغییرات در هر دو سیستم بهصورت قابل بازپخش
نمونه transaction در Mongo (Replica Set لازم است)
const session = await mongoose.startSession();
try {
session.startTransaction();
const updated = await Point.findOneAndUpdate(
{ userId },
{ $inc: { points: delta }, $push: { history: { change: delta, reason } } },
{ upsert: true, new: true, session }
);
await session.commitTransaction();
// سپس update Redis
await redis.zincrby('leaderboard', delta, userId.toString());
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
session.endSession();
}
این الگو تضمین میکند که تغییر در Mongo با transaction ثبت شود؛ اما هماهنگسازی با Redis همچنان eventual است. اگر از هر دو سمت باید اتمیک باشد، باید از مکانیسمهای توزیعشده یا استفاده از Redis بهعنوان source-of-truth بهره ببرید.
بهینهسازی و نکات عملیاتی
- برای لیدربوردهای بسیار بزرگ از sharding و partitioning در Redis استفاده کنید یا نتایج را دورهای snapshot بگیرید.
- برای جلوگیری از تقلب: rate limit برای درخواستهای امتیاز، ثبت IP/UA و تحلیل رفتار مشکوک.
- حفظ تاریخچه در یک جدول/کالکشن جدا برای تحلیلهای بینهایت و اطلاعرسانی قضایا.
- استفاده از TTL در لاگهای کماهمیت و ذخیرهسازی فشرده برای تاریخچههای قدیمی.
- برای محاسبات پیچیده (مانند decay): طراحی cron job یا stream processing که امتیازها را بر اساس فرمول تنظیم میکند.
نمونه ساختار جدول رابطهای (اگر از SQL استفاده کنید)
| name | type | description |
|---|---|---|
| users | table | اطلاعات کاربر |
| user_points | table | user_id, points (indexed) |
| points_history | table | id, user_id, change, reason, created_at |
در SQL میتوانید از تراکنش و قفل ردیفی برای اطمینان از یکپارچگی استفاده کنید اما نیاز به ایندکسبندی مناسب دارید تا لیدربورد سریع بماند.
نتیجهگیری و بهترین شیوهها
یک سیستم امتیازدهی خوب باید بین مقیاسپذیری و دقت تعادل برقرار کند. توصیهها:
- از Redis برای لیدربورد و Mongo/SQL برای source-of-truth استفاده کنید.
- تاریخچه را جدا کنید و از logging مناسب برای مسائل تقلب استفاده کنید.
- از تراکنشها، صفها و طراحی eventual consistency آگاهانه بهره ببرید.
- نظارت و متریکهای عملکرد (latency, cache hit, error rate) را فعال کنید.
با این الگوها میتوانید یک سیستم امتیازدهی قابل اعتماد و مقیاسپذیر با Node.js بسازید و بر اساس نیاز کسبوکار (الگوریتم امتیازدهی، decay، جوایز) آن را گسترش دهید.
آیا این مطلب برای شما مفید بود ؟




