mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
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
|
||||
|
||||
const (
|
||||
RedEnvelopeNotFound = "红包不存在"
|
||||
RedEnvelopeExpired = "红包已过期"
|
||||
RedEnvelopeFinished = "红包已领完"
|
||||
RedEnvelopeAlreadyClaimed = "您已领取过该红包"
|
||||
CannotClaimOwnRedEnvelope = "不能领取自己的红包"
|
||||
InvalidRedEnvelopeType = "无效的红包类型"
|
||||
InvalidRedEnvelopeCount = "红包个数必须大于0"
|
||||
InvalidRedEnvelopeAmount = "红包金额必须大于0"
|
||||
AmountTooSmall = "每个红包金额不能小于0.01"
|
||||
RedEnvelopeTooPopular = "太火爆啦,稍后再试试吧~"
|
||||
InvalidRedEnvelopeID = "红包ID格式错误"
|
||||
InvalidCoverImage = "无效的封面图片"
|
||||
InvalidHeterotypicImage = "无效的装饰图片"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
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 (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/common"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
)
|
||||
|
||||
// CheckRedEnvelopeEnabled 检查红包功能是否启用的中间件
|
||||
func CheckRedEnvelopeEnabled() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
enabled, err := model.GetBoolByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
/*
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/logger"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/service"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleRefundExpiredRedEnvelopes 处理过期红包退款的定时任务
|
||||
func HandleRefundExpiredRedEnvelopes(ctx context.Context, t *asynq.Task) error {
|
||||
logger.InfoF(ctx, "开始处理过期红包退款任务")
|
||||
refundExpiredRedEnvelopes(ctx)
|
||||
logger.InfoF(ctx, "过期红包退款任务完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundExpiredRedEnvelopes 退款过期红包
|
||||
func refundExpiredRedEnvelopes(ctx context.Context) {
|
||||
const batchSize = 100 // 每批处理100个红包
|
||||
var lastID uint64 = 0
|
||||
var totalProcessed int = 0
|
||||
|
||||
for {
|
||||
// 使用游标分页查询过期红包
|
||||
var expiredEnvelopes []model.RedEnvelope
|
||||
if err := db.DB(ctx).
|
||||
Where("id > ? AND status = ? AND expires_at < ? AND remaining_amount > 0", lastID, model.RedEnvelopeStatusActive, time.Now()).
|
||||
Order("id ASC").
|
||||
Limit(batchSize).
|
||||
Find(&expiredEnvelopes).Error; err != nil {
|
||||
logger.ErrorF(ctx, "查询过期红包失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有更多数据,退出循环
|
||||
if len(expiredEnvelopes) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "本批次找到 %d 个需要退款的过期红包", len(expiredEnvelopes))
|
||||
|
||||
// 处理每个过期红包
|
||||
for _, envelope := range expiredEnvelopes {
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 更新红包状态为已过期
|
||||
if err := tx.Model(&model.RedEnvelope{}).
|
||||
Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.RedEnvelopeStatusExpired,
|
||||
"remaining_amount": 0,
|
||||
"remaining_count": 0,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 退还剩余金额给创建者
|
||||
if envelope.RemainingAmount.IsPositive() {
|
||||
// 增加余额并减少total_payment
|
||||
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
|
||||
UserID: envelope.CreatorID,
|
||||
Amount: envelope.RemainingAmount.Neg(),
|
||||
Operation: service.BalanceDeduct,
|
||||
TotalField: "total_payment",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建退款订单记录
|
||||
remarkMsg := fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID)
|
||||
if envelope.Greeting != "" {
|
||||
remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, envelope.Greeting)
|
||||
}
|
||||
|
||||
order := model.Order{
|
||||
OrderName: "红包退款",
|
||||
PayerUserID: 0,
|
||||
PayeeUserID: envelope.CreatorID,
|
||||
Amount: envelope.RemainingAmount,
|
||||
Status: model.OrderStatusSuccess,
|
||||
Type: model.OrderTypeRedEnvelopeRefund,
|
||||
Remark: remarkMsg,
|
||||
TradeTime: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
|
||||
} else {
|
||||
totalProcessed++
|
||||
}
|
||||
|
||||
// 更新游标
|
||||
lastID = envelope.ID
|
||||
}
|
||||
}
|
||||
|
||||
if totalProcessed > 0 {
|
||||
logger.InfoF(ctx, "退款任务完成,共处理 %d 个过期红包", totalProcessed)
|
||||
} else {
|
||||
logger.InfoF(ctx, "没有需要退款的过期红包")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 (
|
||||
"math/rand"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// calculateRandomAmount 二倍均值算法计算随机红包金额
|
||||
func calculateRandomAmount(remaining decimal.Decimal, count int) decimal.Decimal {
|
||||
// 如果是最后一个红包,返回所有剩余金额(避免舍入误差)
|
||||
if count == 1 {
|
||||
return remaining
|
||||
}
|
||||
|
||||
minAmount := decimal.NewFromFloat(0.01)
|
||||
|
||||
// 确保剩余金额足够分配给所有人至少0.01
|
||||
minRequired := minAmount.Mul(decimal.NewFromInt(int64(count)))
|
||||
if remaining.LessThanOrEqual(minRequired) {
|
||||
// 如果剩余金额刚好或不足,每人分配0.01(确保不会出现0 LDC的情况)
|
||||
return minAmount
|
||||
}
|
||||
|
||||
// 二倍均值算法:金额范围 [0.01, min(剩余金额/剩余人数*2, 剩余金额-其他人最小金额)]
|
||||
avg := remaining.Div(decimal.NewFromInt(int64(count)))
|
||||
maxAmount := avg.Mul(decimal.NewFromInt(2))
|
||||
|
||||
// 确保给其他人留下足够的金额(每人至少0.01)
|
||||
maxPossible := remaining.Sub(minAmount.Mul(decimal.NewFromInt(int64(count - 1))))
|
||||
if maxAmount.GreaterThan(maxPossible) {
|
||||
maxAmount = maxPossible
|
||||
}
|
||||
|
||||
// 确保maxAmount不小于minAmount
|
||||
if maxAmount.LessThan(minAmount) {
|
||||
maxAmount = minAmount
|
||||
}
|
||||
|
||||
// 生成随机金额 [minAmount, maxAmount]
|
||||
diff := maxAmount.Sub(minAmount)
|
||||
if diff.LessThanOrEqual(decimal.Zero) {
|
||||
return minAmount
|
||||
}
|
||||
|
||||
// 生成随机数:转换为分(cents)来处理,避免精度问题
|
||||
diffCents := diff.Mul(decimal.NewFromInt(100)).IntPart()
|
||||
if diffCents <= 0 {
|
||||
return minAmount
|
||||
}
|
||||
|
||||
randCents := rand.Int63n(diffCents + 1) // [0, diffCents]
|
||||
randAmount := decimal.NewFromInt(randCents).Div(decimal.NewFromInt(100))
|
||||
amount := minAmount.Add(randAmount)
|
||||
|
||||
return amount.Round(2)
|
||||
}
|
||||
Reference in New Issue
Block a user