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}) }