Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
628 lines
18 KiB
Go
628 lines
18 KiB
Go
package repo
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"backend/internal/model"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type UserRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
var ErrAlreadyCheckedInToday = errors.New("already checked in today")
|
|
|
|
type InviteStats struct {
|
|
InviteCount int64 `json:"invite_count"`
|
|
InviteEarned int `json:"invite_earned"`
|
|
}
|
|
|
|
type InviteRecord struct {
|
|
Name string `json:"name,omitempty"`
|
|
Inviter string `json:"inviter,omitempty"`
|
|
Invitee string `json:"invitee,omitempty"`
|
|
Reward int `json:"reward"`
|
|
RegisteredAt time.Time `json:"registered_at"`
|
|
CompletedAt *time.Time `json:"completed_at"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type InviteLogStats struct {
|
|
Total int64 `json:"total"`
|
|
Completed int64 `json:"completed"`
|
|
Pending int64 `json:"pending"`
|
|
RewardPaid int64 `json:"reward_paid"`
|
|
}
|
|
|
|
type CheckinResult struct {
|
|
Already bool `json:"already"`
|
|
Awarded int `json:"awarded"`
|
|
Streak int `json:"streak"`
|
|
Credits float64 `json:"credits"`
|
|
}
|
|
|
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
|
return &UserRepository{db: db}
|
|
}
|
|
|
|
func (r *UserRepository) GetByID(ctx context.Context, userID string) (*model.User, error) {
|
|
var user model.User
|
|
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (r *UserRepository) GetByIdentifier(ctx context.Context, identifier string) (*model.User, error) {
|
|
ident := strings.TrimSpace(identifier)
|
|
if ident == "" {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
var user model.User
|
|
q := r.db.WithContext(ctx).Preload("APIKeys")
|
|
if strings.Contains(ident, "@") {
|
|
if err := q.First(&user, "email = ?", strings.ToLower(ident)).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
if err := q.First(&user, "LOWER(name) = ?", strings.ToLower(ident)).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*model.User, error) {
|
|
var user model.User
|
|
if err := r.db.WithContext(ctx).First(&user, "invite_code = ?", strings.ToUpper(strings.TrimSpace(code))).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (r *UserRepository) List(ctx context.Context) ([]model.User, error) {
|
|
var users []model.User
|
|
if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func (r *UserRepository) ExistsEmail(ctx context.Context, email, excludeUserID string) (bool, error) {
|
|
var count int64
|
|
q := r.db.WithContext(ctx).Model(&model.User{}).Where("email = ?", strings.ToLower(strings.TrimSpace(email)))
|
|
if strings.TrimSpace(excludeUserID) != "" {
|
|
q = q.Where("id <> ?", strings.TrimSpace(excludeUserID))
|
|
}
|
|
if err := q.Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (r *UserRepository) ExistsName(ctx context.Context, name, excludeUserID string) (bool, error) {
|
|
var count int64
|
|
q := r.db.WithContext(ctx).Model(&model.User{}).Where("LOWER(name) = ?", strings.ToLower(strings.TrimSpace(name)))
|
|
if strings.TrimSpace(excludeUserID) != "" {
|
|
q = q.Where("id <> ?", strings.TrimSpace(excludeUserID))
|
|
}
|
|
if err := q.Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (r *UserRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*model.User, error) {
|
|
var apiKey model.APIKey
|
|
if err := r.db.WithContext(ctx).First(&apiKey, "key_hash = ?", keyHash).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var user model.User
|
|
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", apiKey.UserID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (r *UserRepository) TouchLogin(ctx context.Context, userID, ip string) error {
|
|
now := time.Now()
|
|
return r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
Updates(map[string]any{
|
|
"last_login_at": now,
|
|
"last_login_ip": ip,
|
|
}).Error
|
|
}
|
|
|
|
func (r *UserRepository) HasAdmin(ctx context.Context) (bool, error) {
|
|
var count int64
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Where("role = ?", "admin").
|
|
Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (r *UserRepository) Stats(ctx context.Context) (map[string]any, error) {
|
|
var total, active, disabled, admins int64
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "active").Count(&active).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "disabled").Count(&disabled).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("role = ?", "admin").Count(&admins).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type sumRow struct {
|
|
Total *float64 `gorm:"column:total"`
|
|
}
|
|
var credits sumRow
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Select("SUM(credits) AS total").
|
|
Scan(&credits).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
dayCut := now.Add(-24 * time.Hour)
|
|
weekCut := now.Add(-7 * 24 * time.Hour)
|
|
var new24h, new7d, active24h int64
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", dayCut).Count(&new24h).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", weekCut).Count(&new7d).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("last_login_at >= ?", dayCut).Count(&active24h).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
creditsTotal := 0.0
|
|
if credits.Total != nil {
|
|
creditsTotal = *credits.Total
|
|
}
|
|
|
|
return map[string]any{
|
|
"total": total,
|
|
"active": active,
|
|
"disabled": disabled,
|
|
"admins": admins,
|
|
"credits_total": creditsTotal,
|
|
"new_24h": new24h,
|
|
"new_7d": new7d,
|
|
"active_24h": active24h,
|
|
}, nil
|
|
}
|
|
|
|
type CheckinStats struct {
|
|
TodayCount int64 `json:"today_count"`
|
|
MaxStreak int64 `json:"max_streak"`
|
|
}
|
|
|
|
// CheckinStats counts users who checked in today and the longest active streak —
|
|
// a single-query summary for the admin dashboard's 签到 card.
|
|
func (r *UserRepository) CheckinStats(ctx context.Context) (*CheckinStats, error) {
|
|
today := time.Now().Format("2006-01-02")
|
|
type row struct {
|
|
TodayCount int64 `gorm:"column:today_count"`
|
|
MaxStreak int64 `gorm:"column:max_streak"`
|
|
}
|
|
var out row
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Select("COUNT(*) FILTER (WHERE checkin_last = ?) AS today_count, COALESCE(MAX(checkin_streak), 0) AS max_streak", today).
|
|
Scan(&out).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &CheckinStats{TodayCount: out.TodayCount, MaxStreak: out.MaxStreak}, nil
|
|
}
|
|
|
|
type InviteSummary struct {
|
|
Total int64 `json:"total"`
|
|
Completed int64 `json:"completed"`
|
|
}
|
|
|
|
// InviteSummary is a lightweight count of invited users (and how many have had
|
|
// their reward granted). Cheaper than AllInvites — no JOIN, no record list —
|
|
// for the dashboard which polls frequently.
|
|
func (r *UserRepository) InviteSummary(ctx context.Context) (*InviteSummary, error) {
|
|
type row struct {
|
|
Total int64 `gorm:"column:total"`
|
|
Completed int64 `gorm:"column:completed"`
|
|
}
|
|
var out row
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Select("COUNT(*) AS total, COUNT(*) FILTER (WHERE invite_reward_done) AS completed").
|
|
Where("invited_by IS NOT NULL AND invited_by <> ''").
|
|
Scan(&out).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &InviteSummary{Total: out.Total, Completed: out.Completed}, nil
|
|
}
|
|
|
|
func (r *UserRepository) Create(ctx context.Context, user *model.User) error {
|
|
return r.db.WithContext(ctx).Create(user).Error
|
|
}
|
|
|
|
func (r *UserRepository) Update(ctx context.Context, userID string, patch map[string]any) (*model.User, error) {
|
|
patch["updated_at"] = time.Now()
|
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(patch).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return r.GetByID(ctx, userID)
|
|
}
|
|
|
|
func (r *UserRepository) Delete(ctx context.Context, userID string) (int64, error) {
|
|
res := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", userID)
|
|
return res.RowsAffected, res.Error
|
|
}
|
|
|
|
func (r *UserRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
res := r.db.WithContext(ctx).Delete(&model.User{}, "id IN ?", ids)
|
|
return res.RowsAffected, res.Error
|
|
}
|
|
|
|
func (r *UserRepository) SetPasswordByEmail(ctx context.Context, email, passwordHash string) (*model.User, error) {
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Where("email = ?", strings.ToLower(strings.TrimSpace(email))).
|
|
Updates(map[string]any{
|
|
"password_hash": passwordHash,
|
|
"updated_at": time.Now(),
|
|
}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var user model.User
|
|
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "email = ?", strings.ToLower(strings.TrimSpace(email))).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (r *UserRepository) TouchAPIKeyUsage(ctx context.Context, keyHash string) error {
|
|
now := time.Now()
|
|
return r.db.WithContext(ctx).
|
|
Model(&model.APIKey{}).
|
|
Where("key_hash = ?", keyHash).
|
|
Update("last_used_at", now).Error
|
|
}
|
|
|
|
func (r *UserRepository) InviteStats(ctx context.Context, userID string, reward int) (*InviteStats, error) {
|
|
var inviteCount int64
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Where("invited_by = ?", userID).
|
|
Count(&inviteCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rewardedCount int64
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Where("invited_by = ? AND invite_reward_done = ?", userID, true).
|
|
Count(&rewardedCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &InviteStats{
|
|
InviteCount: inviteCount,
|
|
InviteEarned: int(rewardedCount) * reward,
|
|
}, nil
|
|
}
|
|
|
|
func (r *UserRepository) InviteList(ctx context.Context, userID string, reward int) ([]InviteRecord, error) {
|
|
type row struct {
|
|
Name string
|
|
CreatedAt time.Time
|
|
InviteRewardDone bool
|
|
InviteRewardAt *time.Time
|
|
}
|
|
|
|
var rows []row
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Select("name, created_at, invite_reward_done, invite_reward_at").
|
|
Where("invited_by = ?", userID).
|
|
Order("created_at desc").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]InviteRecord, 0, len(rows))
|
|
for _, item := range rows {
|
|
status := "pending"
|
|
rewardValue := 0
|
|
if item.InviteRewardDone {
|
|
status = "completed"
|
|
rewardValue = reward
|
|
}
|
|
name := strings.TrimSpace(item.Name)
|
|
if name == "" {
|
|
name = "—"
|
|
}
|
|
out = append(out, InviteRecord{
|
|
Name: name,
|
|
Reward: rewardValue,
|
|
RegisteredAt: item.CreatedAt,
|
|
CompletedAt: item.InviteRewardAt,
|
|
Status: status,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *UserRepository) AllInvites(ctx context.Context, reward int) ([]InviteRecord, *InviteLogStats, error) {
|
|
type row struct {
|
|
InviterName string `gorm:"column:inviter_name"`
|
|
InviterEmail string `gorm:"column:inviter_email"`
|
|
InviteeName string `gorm:"column:invitee_name"`
|
|
InviteeEmail string `gorm:"column:invitee_email"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
InviteRewardDone bool `gorm:"column:invite_reward_done"`
|
|
InviteRewardAt *time.Time `gorm:"column:invite_reward_at"`
|
|
}
|
|
|
|
var rows []row
|
|
if err := r.db.WithContext(ctx).
|
|
Table("users AS invitee").
|
|
Select(`
|
|
inviter.name AS inviter_name,
|
|
inviter.email AS inviter_email,
|
|
invitee.name AS invitee_name,
|
|
invitee.email AS invitee_email,
|
|
invitee.created_at,
|
|
invitee.invite_reward_done,
|
|
invitee.invite_reward_at
|
|
`).
|
|
Joins("JOIN users AS inviter ON inviter.id = invitee.invited_by").
|
|
Order("invitee.created_at desc").
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
out := make([]InviteRecord, 0, len(rows))
|
|
stats := &InviteLogStats{}
|
|
for _, item := range rows {
|
|
stats.Total++
|
|
status := "pending"
|
|
rewardValue := 0
|
|
if item.InviteRewardDone {
|
|
status = "completed"
|
|
rewardValue = reward
|
|
stats.Completed++
|
|
stats.RewardPaid += int64(reward)
|
|
} else {
|
|
stats.Pending++
|
|
}
|
|
|
|
inviter := strings.TrimSpace(item.InviterName)
|
|
if inviter == "" {
|
|
inviter = strings.TrimSpace(item.InviterEmail)
|
|
}
|
|
invitee := strings.TrimSpace(item.InviteeName)
|
|
if invitee == "" {
|
|
invitee = strings.TrimSpace(item.InviteeEmail)
|
|
}
|
|
|
|
out = append(out, InviteRecord{
|
|
Inviter: inviter,
|
|
Invitee: invitee,
|
|
Reward: rewardValue,
|
|
RegisteredAt: item.CreatedAt,
|
|
CompletedAt: item.InviteRewardAt,
|
|
Status: status,
|
|
})
|
|
}
|
|
return out, stats, nil
|
|
}
|
|
|
|
func (r *UserRepository) DailyCheckin(ctx context.Context, userID string, reward int) (*CheckinResult, error) {
|
|
today := time.Now().Format("2006-01-02")
|
|
yesterday := time.Now().Add(-24 * time.Hour).Format("2006-01-02")
|
|
|
|
var result *CheckinResult
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
|
return err
|
|
}
|
|
if user.CheckinLast == today {
|
|
result = &CheckinResult{
|
|
Already: true,
|
|
Awarded: 0,
|
|
Streak: user.CheckinStreak,
|
|
Credits: user.Credits,
|
|
}
|
|
return ErrAlreadyCheckedInToday
|
|
}
|
|
|
|
streak := 1
|
|
if user.CheckinLast == yesterday {
|
|
streak = user.CheckinStreak + 1
|
|
}
|
|
credits := user.Credits + float64(reward)
|
|
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
Updates(map[string]any{
|
|
"credits": credits,
|
|
"checkin_last": today,
|
|
"checkin_streak": streak,
|
|
"updated_at": time.Now(),
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
result = &CheckinResult{
|
|
Already: false,
|
|
Awarded: reward,
|
|
Streak: streak,
|
|
Credits: credits,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ErrAlreadyCheckedInToday) {
|
|
return result, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta float64) (*model.User, error) {
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
|
return err
|
|
}
|
|
nextCredits := user.Credits + delta
|
|
if nextCredits < 0 {
|
|
nextCredits = 0
|
|
}
|
|
return tx.Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
Updates(map[string]any{
|
|
"credits": nextCredits,
|
|
"updated_at": time.Now(),
|
|
}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.GetByID(ctx, userID)
|
|
}
|
|
|
|
// SetCredits sets a user's credit balance to an absolute (non-negative) value.
|
|
// The row is locked for the duration of the transaction so it stays consistent
|
|
// with concurrent AdjustCredits/TryDebitCredits operations.
|
|
func (r *UserRepository) SetCredits(ctx context.Context, userID string, value float64) (*model.User, error) {
|
|
if value < 0 {
|
|
value = 0
|
|
}
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
Updates(map[string]any{
|
|
"credits": value,
|
|
"updated_at": time.Now(),
|
|
}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.GetByID(ctx, userID)
|
|
}
|
|
|
|
func (r *UserRepository) TryDebitCredits(ctx context.Context, userID string, amount float64) (*model.User, bool, error) {
|
|
if amount <= 0 {
|
|
user, err := r.GetByID(ctx, userID)
|
|
return user, user != nil, err
|
|
}
|
|
|
|
var result *model.User
|
|
debited := false
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil {
|
|
return err
|
|
}
|
|
if user.Credits < amount {
|
|
result = &user
|
|
return nil
|
|
}
|
|
nextCredits := user.Credits - amount
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
Updates(map[string]any{
|
|
"credits": nextCredits,
|
|
"updated_at": time.Now(),
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
user.Credits = nextCredits
|
|
user.UpdatedAt = time.Now()
|
|
result = &user
|
|
debited = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return result, debited, nil
|
|
}
|
|
|
|
func (r *UserRepository) GrantInviteReward(ctx context.Context, inviteeUserID string, reward int) (bool, error) {
|
|
if reward <= 0 {
|
|
return false, nil
|
|
}
|
|
|
|
granted := false
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var invitee model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&invitee, "id = ?", inviteeUserID).Error; err != nil {
|
|
return err
|
|
}
|
|
if invitee.InvitedBy == nil || *invitee.InvitedBy == "" || invitee.InviteRewardDone {
|
|
return nil
|
|
}
|
|
|
|
var inviter model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&inviter, "id = ?", *invitee.InvitedBy).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", invitee.ID).
|
|
Updates(map[string]any{
|
|
"invite_reward_done": true,
|
|
"invite_reward_at": now,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", inviter.ID).
|
|
Updates(map[string]any{
|
|
"credits": inviter.Credits + float64(reward),
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
granted = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return granted, nil
|
|
}
|