Files

153 lines
4.6 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func hashRedeemCode(code string) string {
s := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
return hex.EncodeToString(s[:])
}
func generateRedeemCode() (string, error) {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", err
}
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 12)
for i := range out {
out[i] = alphabet[int(b[i%len(b)])%len(alphabet)]
}
return string(out[:4]) + "-" + string(out[4:8]) + "-" + string(out[8:]), nil
}
func (a *App) adminCreateRedeemBatch(c *gin.Context) {
var in struct {
Credits int64 `json:"credits"`
Count int `json:"count"`
ExpiresAt *time.Time `json:"expiresAt"`
Note string `json:"note"`
}
if c.BindJSON(&in) != nil || in.Credits <= 0 || in.Count < 1 || in.Count > 10000 {
c.JSON(400, gin.H{"error": "credits/count invalid"})
return
}
admin := c.MustGet("user").(*User)
batch := RedeemBatch{Code: fmt.Sprintf("B-%d", time.Now().UnixNano()), Credits: in.Credits, Count: in.Count, ExpiresAt: in.ExpiresAt, Note: in.Note, CreatedBy: admin.ID}
codes := make([]string, 0, in.Count)
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&batch).Error; err != nil {
return err
}
for i := 0; i < in.Count; i++ {
code, err := generateRedeemCode()
if err != nil {
return err
}
row := RedeemCode{BatchID: batch.ID, CodeHash: hashRedeemCode(code), Prefix: code[:4], Credits: in.Credits, Status: "unused", ExpiresAt: in.ExpiresAt}
if err := tx.Create(&row).Error; err != nil {
return err
}
codes = append(codes, code)
}
return nil
})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
a.audit(admin.ID, "redeem.batch.create", "redeem_batch", fmt.Sprint(batch.ID), fmt.Sprintf("credits=%d,count=%d", batch.Credits, batch.Count))
c.JSON(201, gin.H{"batch": batch, "codes": codes})
}
func (a *App) redeem(c *gin.Context) {
user := c.MustGet("user").(*User)
var in struct {
Code string `json:"code"`
}
if c.BindJSON(&in) != nil || strings.TrimSpace(in.Code) == "" {
c.JSON(400, gin.H{"error": "code required"})
return
}
now := time.Now()
var amount int64
err := a.db.Transaction(func(tx *gorm.DB) error {
var code RedeemCode
if err := tx.Where("code_hash = ?", hashRedeemCode(in.Code)).First(&code).Error; err != nil {
return err
}
if code.Status != "unused" {
return errors.New("code already used or unavailable")
}
if code.ExpiresAt != nil && code.ExpiresAt.Before(now) {
return errors.New("code expired")
}
var account CreditAccount
if err := tx.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
account = CreditAccount{UserID: user.ID}
if err := tx.Create(&account).Error; err != nil {
return err
}
}
account.Balance += code.Credits
if err := tx.Save(&account).Error; err != nil {
return err
}
code.Status, code.RedeemedBy, code.RedeemedAt = "used", &user.ID, &now
if err := tx.Save(&code).Error; err != nil {
return err
}
if err := tx.Create(&RedeemRecord{CodeID: code.ID, BatchID: code.BatchID, UserID: user.ID, Credits: code.Credits}).Error; err != nil {
return err
}
if err := tx.Create(&CreditTransaction{UserID: user.ID, Type: "redeem", Amount: code.Credits, BalanceAfter: account.Balance, ReferenceType: "redeem_code", ReferenceID: fmt.Sprint(code.ID), Reason: "redeem code"}).Error; err != nil {
return err
}
amount = code.Credits
return nil
})
if err != nil {
status := 500
if errors.Is(err, gorm.ErrRecordNotFound) {
status = 404
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
a.audit(user.ID, "redeem.use", "redeem_code", "hash:"+hashRedeemCode(in.Code)[:12], fmt.Sprintf("credits=%d", amount))
c.JSON(200, gin.H{"credits": amount})
}
func (a *App) adminRevokeRedeemBatch(c *gin.Context) {
id := c.Param("id")
if err := a.db.Model(&RedeemCode{}).Where("batch_id = ? AND status = ?", id, "unused").Update("status", "revoked").Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
admin := c.MustGet("user").(*User)
a.audit(admin.ID, "redeem.batch.revoke", "redeem_batch", id, "unused codes revoked")
c.JSON(200, gin.H{"ok": true})
}
func (a *App) adminListRedeemBatches(c *gin.Context) {
var batches []RedeemBatch
if err := a.db.Order("id desc").Limit(100).Find(&batches).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"data": batches})
}