Files
OpenFlare/internal/apps/redenvelope/routers.go
T
2026-06-08 20:34:27 +08:00

544 lines
18 KiB
Go

/*
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,
}))
}