/* Copyright 2025 linux.do Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package redenvelope import ( "errors" "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/db/idgen" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/service" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" "gorm.io/gorm" "gorm.io/gorm/clause" ) // CreateRequest 创建红包请求 type CreateRequest struct { Type model.RedEnvelopeType `json:"type" binding:"required,oneof=fixed random"` TotalAmount decimal.Decimal `json:"total_amount" binding:"required"` TotalCount int `json:"total_count" binding:"required,min=1"` Greeting string `json:"greeting" binding:"max=100"` PayKey string `json:"pay_key" binding:"required,max=10"` CoverUploadID *uint64 `json:"cover_upload_id,string" binding:"omitempty"` HeterotypicUploadID *uint64 `json:"heterotypic_upload_id,string" binding:"omitempty"` } // CreateResponse 创建红包响应 type CreateResponse struct { ID uint64 `json:"id,string"` } // ClaimRequest 领取红包请求 type ClaimRequest struct { ID uint64 `json:"id,string" binding:"required"` } // ClaimResponse 领取红包响应 type ClaimResponse struct { Amount decimal.Decimal `json:"amount"` RedEnvelope model.RedEnvelope `json:"red_envelope"` } // DetailResponse 红包详情响应 type DetailResponse struct { RedEnvelope model.RedEnvelope `json:"red_envelope"` Claims []model.RedEnvelopeClaim `json:"claims"` UserClaimed *model.RedEnvelopeClaim `json:"user_claimed,omitempty"` } // ListRequest 红包列表请求 type ListRequest struct { Page int `json:"page" binding:"required,min=1"` PageSize int `json:"page_size" binding:"required,min=1,max=100"` Type string `json:"type" binding:"omitempty,oneof=sent received"` } // ListResponse 红包列表响应 type ListResponse struct { Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` RedEnvelopes []model.RedEnvelope `json:"red_envelopes"` } // Create 创建红包 // @Tags redenvelope // @Accept json // @Produce json // @Param request body CreateRequest true "创建红包请求" // @Success 200 {object} util.ResponseAny // @Router /api/v1/redenvelope/create [post] func Create(c *gin.Context) { var req CreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } if err := util.ValidateAmount(req.TotalAmount); err != nil { c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } // 检查红包最低金额限制(1 LDC) if req.TotalAmount.LessThan(decimal.NewFromInt(1)) { c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeMinAmountRequired)) return } // 检查单个红包最大金额限制 maxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2) if err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } if req.TotalAmount.GreaterThan(maxAmount) { c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeAmountExceeded)) return } // 检查红包最大领取人数限制 maxRecipients, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxRecipients) if err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } if req.TotalCount > maxRecipients { c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeRecipientsExceeded)) return } // 检查每个红包平均金额不能小于0.01(避免前面领取者获得0 LDC) perAmount := req.TotalAmount.Div(decimal.NewFromInt(int64(req.TotalCount))) if perAmount.LessThan(decimal.NewFromFloat(0.01)) { c.JSON(http.StatusBadRequest, util.Err(AmountTooSmall)) return } currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) // 检查每日红包发送数量限制 dailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit) if err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } // 查询今日已发送的红包数量 var todayCount int64 today := time.Now().Truncate(24 * time.Hour) if err := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}). Where("creator_id = ? AND created_at >= ?", currentUser.ID, today). Count(&todayCount).Error; err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } if todayCount >= int64(dailyLimit) { c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeDailyLimitExceeded)) return } if !currentUser.VerifyPayKey(req.PayKey) { c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect)) return } // 获取红包手续费率并计算手续费 feeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2) if err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } // 计算手续费(红包金额 * 费率) feeAmount := req.TotalAmount.Mul(feeRate).Round(2) // 总扣款金额 = 红包金额 + 手续费 totalDeduction := req.TotalAmount.Add(feeAmount) // 提前检查余额,避免不必要的事务 if currentUser.AvailableBalance.LessThan(totalDeduction) { c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance)) return } var redEnvelope model.RedEnvelope if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error { var coverUploadID *uint64 var heterotypicUploadID *uint64 if req.CoverUploadID != nil { var coverUpload model.Upload if err := tx.Where("id = ? AND status IN (?, ?) AND user_id = ? AND type = ?", *req.CoverUploadID, model.UploadStatusPending, model.UploadStatusUsed, currentUser.ID, model.UploadTypeCover). First(&coverUpload).Error; err != nil { return errors.New(InvalidCoverImage) } if coverUpload.Status == model.UploadStatusPending { if err := tx.Model(&model.Upload{}). Where("id = ? AND status = ?", coverUpload.ID, model.UploadStatusPending). Update("status", model.UploadStatusUsed).Error; err != nil { return err } } coverUploadID = &coverUpload.ID } if req.HeterotypicUploadID != nil { var heterotypicUpload model.Upload if err := tx.Where("id = ? AND status IN (?, ?) AND user_id = ? AND type = ?", *req.HeterotypicUploadID, model.UploadStatusPending, model.UploadStatusUsed, currentUser.ID, model.UploadTypeHeterotypic). First(&heterotypicUpload).Error; err != nil { return errors.New(InvalidHeterotypicImage) } if heterotypicUpload.Status == model.UploadStatusPending { if err := tx.Model(&model.Upload{}). Where("id = ? AND status = ?", heterotypicUpload.ID, model.UploadStatusPending). Update("status", model.UploadStatusUsed).Error; err != nil { return err } } heterotypicUploadID = &heterotypicUpload.ID } // 扣减发送者余额并更新total_payment if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{ UserID: currentUser.ID, Amount: totalDeduction, Operation: service.BalanceDeduct, TotalField: "total_payment", CheckBalance: true, }); err != nil { return err } // 创建红包 redEnvelope = model.RedEnvelope{ ID: idgen.NextUint64ID(), CreatorID: currentUser.ID, Type: req.Type, TotalAmount: req.TotalAmount, RemainingAmount: req.TotalAmount, TotalCount: req.TotalCount, RemainingCount: req.TotalCount, Greeting: req.Greeting, Status: model.RedEnvelopeStatusActive, CoverUploadID: coverUploadID, HeterotypicUploadID: heterotypicUploadID, ExpiresAt: time.Now().Add(24 * time.Hour), } if err := tx.Create(&redEnvelope).Error; err != nil { return err } // 创建订单记录(红包支出) remarkMsg := fmt.Sprintf("创建红包,共%d个", req.TotalCount) if feeAmount.GreaterThan(decimal.Zero) { remarkMsg = fmt.Sprintf("%s,手续费: %s", remarkMsg, feeAmount.String()) } if req.Greeting != "" { remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, req.Greeting) } order := model.Order{ OrderName: "红包支出", PayerUserID: currentUser.ID, PayeeUserID: 0, Amount: totalDeduction, Status: model.OrderStatusSuccess, Type: model.OrderTypeRedEnvelopeSend, Remark: remarkMsg, TradeTime: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), } return tx.Create(&order).Error }); err != nil { if err.Error() == common.InsufficientBalance { c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance)) } else if err.Error() == InvalidCoverImage || err.Error() == InvalidHeterotypicImage { c.JSON(http.StatusBadRequest, util.Err(err.Error())) } else { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) } return } c.JSON(http.StatusOK, util.OK(CreateResponse{ ID: redEnvelope.ID, })) } // Claim 领取红包 // @Tags redenvelope // @Accept json // @Produce json // @Param request body ClaimRequest true "领取红包请求" // @Success 200 {object} util.ResponseAny // @Router /api/v1/redenvelope/claim [post] func Claim(c *gin.Context) { var req ClaimRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) var claimedAmount decimal.Decimal var redEnvelope model.RedEnvelope if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error { // 使用 FOR UPDATE 锁定红包记录,防止并发领取 if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}). Where("id = ?", req.ID).First(&redEnvelope).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New(RedEnvelopeNotFound) } // 捕获锁等待超时错误,返回友好提示 return errors.New(RedEnvelopeTooPopular) } // 检查红包状态 if redEnvelope.Status == model.RedEnvelopeStatusExpired || redEnvelope.ExpiresAt.Before(time.Now()) { return errors.New(RedEnvelopeExpired) } if redEnvelope.Status == model.RedEnvelopeStatusFinished || redEnvelope.RemainingCount <= 0 { return errors.New(RedEnvelopeFinished) } // 检查是否已领取 var existingClaim model.RedEnvelopeClaim if err := tx.Where("red_envelope_id = ? AND user_id = ?", redEnvelope.ID, currentUser.ID). First(&existingClaim).Error; err == nil { return errors.New(RedEnvelopeAlreadyClaimed) } // 计算领取金额 if redEnvelope.Type == model.RedEnvelopeTypeFixed { // 固定金额:如果是最后一个,给全部剩余金额(避免舍入误差) if redEnvelope.RemainingCount == 1 { claimedAmount = redEnvelope.RemainingAmount } else { claimedAmount = redEnvelope.TotalAmount.Div(decimal.NewFromInt(int64(redEnvelope.TotalCount))).Round(2) } } else { // 拼手气红包:使用二倍均值算法 claimedAmount = calculateRandomAmount(redEnvelope.RemainingAmount, redEnvelope.RemainingCount) } // 创建领取记录 claim := model.RedEnvelopeClaim{ ID: idgen.NextUint64ID(), RedEnvelopeID: redEnvelope.ID, UserID: currentUser.ID, Amount: claimedAmount, } if err := tx.Create(&claim).Error; err != nil { return err } // 更新红包状态 newRemainingCount := redEnvelope.RemainingCount - 1 newRemainingAmount := redEnvelope.RemainingAmount.Sub(claimedAmount) newStatus := redEnvelope.Status if newRemainingCount <= 0 { newStatus = model.RedEnvelopeStatusFinished } if err := tx.Model(&model.RedEnvelope{}).Where("id = ?", redEnvelope.ID). Updates(map[string]interface{}{ "remaining_count": newRemainingCount, "remaining_amount": newRemainingAmount, "status": newStatus, }).Error; err != nil { return err } // 更新红包对象用于返回 redEnvelope.RemainingCount = newRemainingCount redEnvelope.RemainingAmount = newRemainingAmount redEnvelope.Status = newStatus // 增加领取者余额并更新total_receive if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{ UserID: currentUser.ID, Amount: claimedAmount, Operation: service.BalanceAdd, TotalField: "total_receive", }); err != nil { return err } // 创建订单记录(红包收入) order := model.Order{ OrderName: "红包收入", PayerUserID: redEnvelope.CreatorID, PayeeUserID: currentUser.ID, Amount: claimedAmount, Status: model.OrderStatusSuccess, Type: model.OrderTypeRedEnvelopeReceive, Remark: fmt.Sprintf("祝福语: %s", redEnvelope.Greeting), TradeTime: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), } return tx.Create(&order).Error }); err != nil { errMsg := err.Error() switch errMsg { case RedEnvelopeNotFound: c.JSON(http.StatusNotFound, util.Err(errMsg)) case RedEnvelopeExpired, RedEnvelopeFinished, RedEnvelopeAlreadyClaimed, CannotClaimOwnRedEnvelope: c.JSON(http.StatusBadRequest, util.Err(errMsg)) default: c.JSON(http.StatusInternalServerError, util.Err(errMsg)) } return } var redEnvelopeView model.RedEnvelope if err := db.DB(c.Request.Context()). Model(&model.RedEnvelope{}). Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url"). Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id"). Where("red_envelopes.id = ?", redEnvelope.ID).First(&redEnvelopeView).Error; err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } c.JSON(http.StatusOK, util.OK(ClaimResponse{ Amount: claimedAmount, RedEnvelope: redEnvelopeView, })) } // GetDetail 获取红包详情 // @Tags redenvelope // @Produce json // @Param id path string true "红包ID" // @Success 200 {object} util.ResponseAny // @Router /api/v1/redenvelope/{id} [get] func GetDetail(c *gin.Context) { idStr := c.Param("id") redEnvelopeID, err := strconv.ParseUint(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, util.Err(InvalidRedEnvelopeID)) return } currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) var redEnvelope model.RedEnvelope if err := db.DB(c.Request.Context()). Model(&model.RedEnvelope{}). Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url"). Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id"). Where("red_envelopes.id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, util.Err(RedEnvelopeNotFound)) return } c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } var claims []model.RedEnvelopeClaim db.DB(c.Request.Context()). Select("red_envelope_claims.*, users.username, users.avatar_url"). Joins("LEFT JOIN users ON red_envelope_claims.user_id = users.id"). Where("red_envelope_claims.red_envelope_id = ?", redEnvelope.ID). Order("red_envelope_claims.claimed_at DESC"). Find(&claims) var userClaimed *model.RedEnvelopeClaim if currentUser != nil { for i := range claims { if claims[i].UserID == currentUser.ID { userClaimed = &claims[i] break } } } c.JSON(http.StatusOK, util.OK(DetailResponse{ RedEnvelope: redEnvelope, Claims: claims, UserClaimed: userClaimed, })) } // List 获取红包列表 // @Tags redenvelope // @Accept json // @Produce json // @Param request body ListRequest true "列表请求" // @Success 200 {object} util.ResponseAny // @Router /api/v1/redenvelope/list [post] func List(c *gin.Context) { var req ListRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) query := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}). Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url"). Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id") switch req.Type { case "sent": query = query.Where("red_envelopes.creator_id = ?", currentUser.ID) case "received": query = query.Joins("INNER JOIN red_envelope_claims ON red_envelopes.id = red_envelope_claims.red_envelope_id"). Where("red_envelope_claims.user_id = ?", currentUser.ID) default: query = query.Where("red_envelopes.creator_id = ?", currentUser.ID) } var total int64 query.Count(&total) var redEnvelopes []model.RedEnvelope query.Order("red_envelopes.created_at DESC"). Offset((req.Page - 1) * req.PageSize). Limit(req.PageSize). Find(&redEnvelopes) c.JSON(http.StatusOK, util.OK(ListResponse{ Total: total, Page: req.Page, PageSize: req.PageSize, RedEnvelopes: redEnvelopes, })) }