feat: implement multi-user credits redeem and storage isolation
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
errInsufficientCredits = errors.New("insufficient credits")
|
||||
errReservationState = errors.New("invalid credit reservation state")
|
||||
)
|
||||
|
||||
// reserveCredit places one credit on hold for a task. The hold is released on
|
||||
// failure or converted into a debit after a successful new download.
|
||||
func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) {
|
||||
if amount <= 0 {
|
||||
return nil, fmt.Errorf("invalid credit amount")
|
||||
}
|
||||
var reservation CreditReservation
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var account CreditAccount
|
||||
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
account = CreditAccount{UserID: userID}
|
||||
if err := tx.Create(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var existing CreditReservation
|
||||
if err := tx.Where("task_id = ?", taskID).First(&existing).Error; err == nil {
|
||||
reservation = existing
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if account.Balance-account.Reserved < amount {
|
||||
return errInsufficientCredits
|
||||
}
|
||||
account.Reserved += amount
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
reservation = CreditReservation{UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved"}
|
||||
if err := tx.Create(&reservation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&CreditTransaction{UserID: userID, Type: "reserve", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(taskID), 10), Reason: "download reservation"}).Error
|
||||
})
|
||||
return &reservation, err
|
||||
}
|
||||
|
||||
func (a *App) commitCredit(task *Task) error {
|
||||
if task.CreditReservationID == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var r CreditReservation
|
||||
if err := tx.First(&r, task.CreditReservationID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Status == "committed" {
|
||||
return nil
|
||||
}
|
||||
if r.Status != "reserved" {
|
||||
return errReservationState
|
||||
}
|
||||
var account CreditAccount
|
||||
if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
account.Reserved -= r.Amount
|
||||
account.Balance -= r.Amount
|
||||
if account.Reserved < 0 || account.Balance < 0 {
|
||||
return errReservationState
|
||||
}
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
r.Status, r.CompletedAt = "committed", &now
|
||||
if err := tx.Save(&r).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "debit", Amount: -r.Amount, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "successful download"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// refundCredit releases a reservation without debiting the user's balance.
|
||||
// It is idempotent so download failure paths can safely call fail repeatedly.
|
||||
func (a *App) refundCredit(task *Task) error {
|
||||
if task.CreditReservationID == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var r CreditReservation
|
||||
if err := tx.First(&r, task.CreditReservationID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Status == "refunded" {
|
||||
return nil
|
||||
}
|
||||
if r.Status == "committed" {
|
||||
return errReservationState
|
||||
}
|
||||
if r.Status != "reserved" {
|
||||
return errReservationState
|
||||
}
|
||||
var account CreditAccount
|
||||
if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
account.Reserved -= r.Amount
|
||||
if account.Reserved < 0 {
|
||||
return errReservationState
|
||||
}
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
r.Status, r.CompletedAt = "refunded", &now
|
||||
if err := tx.Save(&r).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "refund", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "download failed"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) creditBalance(userID uint) (CreditAccount, error) {
|
||||
var account CreditAccount
|
||||
err := a.db.Where("user_id = ?", userID).First(&account).Error
|
||||
return account, err
|
||||
}
|
||||
Reference in New Issue
Block a user