برامج الولاء تفشل في الإنتاج عندما ينجرف سجل النقاط، أو تكون حسابات المستويات غير متسقة، أو تُسبب سباقات الاسترداد أخطاء في الرصيد. إليك كيف نبني هذا بشكل صحيح في Go.
بناء نظام نقاط الولاء والمكافآت في Go لمنصات SaaS
برامج الولاء تفشل في الإنتاج عندما ينجرف سجل النقاط، أو تكون حسابات مستويات الأعضاء غير متسقة، أو تُسبب سباقات الاسترداد المتزامن أخطاء في الرصيد. السطح يبدو بسيطاً: العملاء يكسبون نقاطاً على المشتريات، ويستردونها للحصول على خصومات، ويتقدمون عبر المستويات. لكن التنفيذ يتطلب نفس متطلبات الصحة كالنظام المالي.
نموذج بيانات السجل
لا تُخزَّن النقاط كعمود رصيد على سجل العميل. عمود الرصيد القابل للتغيير يدعو إلى حالات السباق ويجعل التدقيق مستحيلاً. بدلاً من ذلك، النقاط مُخزَّنة كسجل لا يُلحق إليه سوى المعاملات:
CREATE TABLE loyalty_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
customer_id UUID NOT NULL,
tier TEXT NOT NULL DEFAULT 'bronze',
lifetime_pts BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE points_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
account_id UUID NOT NULL REFERENCES loyalty_accounts(id),
event_type TEXT NOT NULL,
points BIGINT NOT NULL,
reference_id UUID,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_nonzero CHECK (points != 0)
);
قيم points الإيجابية هي قيود دائنة؛ والقيم السالبة هي قيود مدينة (عمليات الاسترداد، تعديلات انتهاء الصلاحية). reference_id يربط إدخال السجل بالحدث المُشغِّل.
حساب الرصيد الحالي
الرصيد في أي لحظة هو مجموع القيود الدائنة غير منتهية الصلاحية ناقص جميع القيود المدينة:
SELECT COALESCE(SUM(points), 0)
FROM points_ledger
WHERE account_id = $1
AND (
expires_at IS NULL
OR expires_at > now()
OR points < 0
)
القيود الدائنة المنتهية الصلاحية مستبعدة من الرصيد لكنها تبقى في السجل للتدقيق. القيود المدينة مُدرجة دائماً بصرف النظر عن انتهاء الصلاحية.
كسب النقاط: محرك قائم على القواعد
قواعد الكسب تحدد عدد النقاط التي يكسبها العميل عن حدث ما. القواعد لكل مستأجر وقابلة للتكوين:
func (r EarnRule) Calculate(amount int, tier string) int {
base := float64(amount/100) * float64(r.PointsPerUnit)
multiplier := r.Multiplier
if bonus, ok := r.TierBonus[tier]; ok {
multiplier *= bonus
}
return int(base * multiplier)
}
معاملة الكسب يجب أن تكون متكافئة. إذا اتصلت خدمة الطلبات بخدمة الولاء مرتين لنفس الطلب، يجب ألا يُضاعف السجل القيد الدائن:
// فحص التكافؤ: لا إدخال مكرر لنفس المرجع
var existing int
err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM points_ledger
WHERE account_id = $1 AND reference_id = $2 AND event_type = $3`,
accountID, referenceID, eventType,
).Scan(&existing)
if existing > 0 {
return nil // تم الإضافة بالفعل لهذا المرجع
}
الاسترداد مع حماية الرصيد
يجب أن يتحقق الاسترداد من وجود رصيد كافٍ ويسجل القيد المدين بشكل ذري. الاستردادات المتزامنة يجب ألا تتجاوز فحص الرصيد معاً:
func (s *LoyaltyService) RedeemPoints(
ctx context.Context,
tenantID, accountID uuid.UUID,
points int,
) error {
return s.db.WithTx(ctx, func(tx *sql.Tx) error {
// قفل صف الحساب
var currentBalance int
err := tx.QueryRowContext(ctx,
`SELECT COALESCE(SUM(p.points), 0)
FROM points_ledger p
JOIN loyalty_accounts a ON a.id = p.account_id
WHERE a.id = $1 AND a.tenant_id = $2
FOR UPDATE OF a`,
accountID, tenantID,
).Scan(¤tBalance)
if currentBalance < points {
return ErrInsufficientPoints
}
// إدراج قيد مدين
_, err = tx.ExecContext(ctx,
`INSERT INTO points_ledger
(tenant_id, account_id, event_type, points)
VALUES ($1, $2, 'redemption', $3)`,
tenantID, accountID, -points,
)
return err
})
}
منطق تقدم المستويات
تُحسب المستويات من lifetime_pts التي لا تتراجع أبداً:
var defaultTiers = []TierConfig{
{Name: "bronze", MinPoints: 0},
{Name: "silver", MinPoints: 1000},
{Name: "gold", MinPoints: 5000},
{Name: "platinum", MinPoints: 20000},
}
func TierForPoints(lifetimePts int) string {
current := "bronze"
for _, t := range defaultTiers {
if lifetimePts >= t.MinPoints {
current = t.Name
}
}
return current
}
انتهاء صلاحية النقاط
انتهاء الصلاحية يُعالَج بواسطة مهمة مجدولة تعمل ليلاً وتُدرج قيداً مدياناً للنقاط المنتهية الصلاحية. إدخالات انتهاء الصلاحية تستخدم event_type = 'expiry' لتمييزها في السجل وفي تاريخ المعاملات المواجه للعملاء.
دروس من بيئات الإنتاج
- عامل سجل النقاط كسجل مالي: إلحاق فقط، لا أعمدة رصيد قابلة للتغيير.
- استخدم النقاط مدى الحياة لحساب المستويات.
- مفاتيح التكافؤ على معاملات الكسب تمنع الإضافة المزدوجة عند إعادة المحاولة.
- الاسترداد يجب أن يستخدم قفل الصف لمنع الاسترداد الزائد المتزامن.
- انتهاء الصلاحية هو قيد مدين في السجل، وليس تحديثاً للعمود.
نبني أنظمة خلفية لـ SaaS للشركات في لبنان وعبر الشرق الأوسط. إذا كنت تحتاج وحدة ولاء أو مكافآت مبنية بشكل صحيح من الصفر، تواصل معنا عبر https://voxire.com/get-a-quote/



