1023 lines
34 KiB
Go
1023 lines
34 KiB
Go
// Copyright © 2023 OpenIM. All rights reserved.
|
||
//
|
||
// 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 api
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math/rand"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/redis/go-redis/v9"
|
||
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/apistruct"
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/servererrs"
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/database"
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/database/mgo"
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/model"
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/msgprocessor"
|
||
"git.imall.cloud/openim/open-im-server-deploy/pkg/rpcli"
|
||
"git.imall.cloud/openim/protocol/constant"
|
||
"git.imall.cloud/openim/protocol/msg"
|
||
"git.imall.cloud/openim/protocol/sdkws"
|
||
"github.com/openimsdk/tools/apiresp"
|
||
"github.com/openimsdk/tools/errs"
|
||
"github.com/openimsdk/tools/log"
|
||
"github.com/openimsdk/tools/mcontext"
|
||
"github.com/openimsdk/tools/utils/idutil"
|
||
"github.com/openimsdk/tools/utils/jsonutil"
|
||
"github.com/openimsdk/tools/utils/timeutil"
|
||
)
|
||
|
||
type RedPacketApi struct {
|
||
groupClient *rpcli.GroupClient
|
||
userClient *rpcli.UserClient
|
||
msgClient msg.MsgClient
|
||
redPacketDB database.RedPacket
|
||
redPacketReceiveDB database.RedPacketReceive
|
||
walletDB database.Wallet
|
||
walletBalanceRecordDB database.WalletBalanceRecord
|
||
redisClient redis.UniversalClient
|
||
}
|
||
|
||
func NewRedPacketApi(groupClient *rpcli.GroupClient, userClient *rpcli.UserClient, msgClient msg.MsgClient, redPacketDB database.RedPacket, redPacketReceiveDB database.RedPacketReceive, walletDB database.Wallet, walletBalanceRecordDB database.WalletBalanceRecord, redisClient redis.UniversalClient) *RedPacketApi {
|
||
return &RedPacketApi{
|
||
groupClient: groupClient,
|
||
userClient: userClient,
|
||
msgClient: msgClient,
|
||
redPacketDB: redPacketDB,
|
||
redPacketReceiveDB: redPacketReceiveDB,
|
||
walletDB: walletDB,
|
||
walletBalanceRecordDB: walletBalanceRecordDB,
|
||
redisClient: redisClient,
|
||
}
|
||
}
|
||
|
||
// SendRedPacket 发送红包(只支持群聊,发送用户默认为群主)
|
||
func (r *RedPacketApi) SendRedPacket(c *gin.Context) {
|
||
var (
|
||
req apistruct.SendRedPacketReq
|
||
resp apistruct.SendRedPacketResp
|
||
)
|
||
if err := c.BindJSON(&req); err != nil {
|
||
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
|
||
return
|
||
}
|
||
|
||
// 验证红包参数
|
||
if req.TotalAmount <= 0 {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("totalAmount must be greater than 0"))
|
||
return
|
||
}
|
||
if req.TotalCount <= 0 {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("totalCount must be greater than 0"))
|
||
return
|
||
}
|
||
if req.RedPacketType != 1 && req.RedPacketType != 2 {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("redPacketType must be 1 (normal) or 2 (random)"))
|
||
return
|
||
}
|
||
|
||
// 获取群信息,验证群是否存在
|
||
groupInfos, err := r.groupClient.GetGroupsInfo(c, []string{req.GroupID})
|
||
if err != nil {
|
||
apiresp.GinError(c, err)
|
||
return
|
||
}
|
||
if len(groupInfos) == 0 {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("group not found"))
|
||
return
|
||
}
|
||
groupInfo := groupInfos[0]
|
||
|
||
// 获取群主ID(发送用户默认为群主)
|
||
sendUserID := groupInfo.OwnerUserID
|
||
if sendUserID == "" {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("group owner not found"))
|
||
return
|
||
}
|
||
|
||
// 获取群主用户信息(用于设置发送者昵称和头像)
|
||
userInfos, err := r.userClient.GetUsersInfo(c, []string{sendUserID})
|
||
if err != nil {
|
||
log.ZWarn(c, "SendRedPacket: failed to get user info", err, "sendUserID", sendUserID)
|
||
// 如果获取用户信息失败,继续发送,但不设置昵称和头像
|
||
}
|
||
|
||
var senderNickname, senderFaceURL string
|
||
if len(userInfos) > 0 && userInfos[0] != nil {
|
||
senderNickname = userInfos[0].Nickname
|
||
senderFaceURL = userInfos[0].FaceURL
|
||
}
|
||
|
||
// 生成红包ID
|
||
redPacketID := idutil.GetMsgIDByMD5(sendUserID + req.GroupID + timeutil.GetCurrentTimeFormatted())
|
||
|
||
// 计算会话ID
|
||
conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, req.GroupID)
|
||
|
||
// 计算过期时间(默认24小时)
|
||
expireTime := time.Now().Add(24 * time.Hour)
|
||
|
||
// 初始化Redis数据结构(所有红包类型都需要)
|
||
if r.redisClient == nil {
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("redis client is not available"))
|
||
return
|
||
}
|
||
|
||
ctx := context.Background()
|
||
queueKey := r.getRedPacketQueueKey(redPacketID)
|
||
usersKey := r.getRedPacketUsersKey(redPacketID)
|
||
expireDuration := 24 * time.Hour
|
||
|
||
if req.RedPacketType == model.RedPacketTypeRandom {
|
||
// 拼手气红包:预先分配金额并推送到Redis队列
|
||
amounts, err := r.allocateRandomAmounts(req.TotalAmount, req.TotalCount)
|
||
if err != nil {
|
||
log.ZError(c, "SendRedPacket: failed to allocate random amounts", err, "redPacketID", redPacketID, "groupID", req.GroupID)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to allocate random amounts"))
|
||
return
|
||
}
|
||
|
||
pipe := r.redisClient.Pipeline()
|
||
for _, amount := range amounts {
|
||
pipe.RPush(ctx, queueKey, strconv.FormatInt(amount, 10))
|
||
}
|
||
pipe.Expire(ctx, queueKey, expireDuration)
|
||
pipe.Expire(ctx, usersKey, expireDuration)
|
||
if _, err := pipe.Exec(ctx); err != nil {
|
||
log.ZError(c, "SendRedPacket: failed to push amounts to redis queue", err, "redPacketID", redPacketID, "groupID", req.GroupID)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to push amounts to redis queue"))
|
||
return
|
||
}
|
||
log.ZInfo(c, "SendRedPacket: pushed amounts to redis queue", "redPacketID", redPacketID, "count", len(amounts))
|
||
} else {
|
||
// 普通红包:初始化队列,每个元素为平均金额
|
||
avgAmount := req.TotalAmount / int64(req.TotalCount)
|
||
pipe := r.redisClient.Pipeline()
|
||
for i := int32(0); i < req.TotalCount; i++ {
|
||
pipe.RPush(ctx, queueKey, strconv.FormatInt(avgAmount, 10))
|
||
}
|
||
pipe.Expire(ctx, queueKey, expireDuration)
|
||
pipe.Expire(ctx, usersKey, expireDuration)
|
||
if _, err := pipe.Exec(ctx); err != nil {
|
||
log.ZError(c, "SendRedPacket: failed to initialize redis queue", err, "redPacketID", redPacketID, "groupID", req.GroupID)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to initialize redis queue"))
|
||
return
|
||
}
|
||
log.ZInfo(c, "SendRedPacket: initialized redis queue for normal red packet", "redPacketID", redPacketID, "count", req.TotalCount, "avgAmount", avgAmount)
|
||
}
|
||
|
||
// 创建红包数据库记录
|
||
redPacketRecord := &model.RedPacket{
|
||
RedPacketID: redPacketID,
|
||
SendUserID: sendUserID,
|
||
GroupID: req.GroupID,
|
||
ConversationID: conversationID,
|
||
SessionType: constant.ReadGroupChatType,
|
||
RedPacketType: req.RedPacketType,
|
||
TotalAmount: req.TotalAmount,
|
||
TotalCount: req.TotalCount,
|
||
RemainAmount: req.TotalAmount,
|
||
RemainCount: req.TotalCount,
|
||
Blessing: req.Blessing,
|
||
Status: model.RedPacketStatusActive,
|
||
ExpireTime: expireTime,
|
||
CreateTime: time.Now(),
|
||
}
|
||
|
||
// 保存红包记录到数据库
|
||
if err := r.redPacketDB.Create(c, redPacketRecord); err != nil {
|
||
log.ZError(c, "SendRedPacket: failed to create red packet record", err, "redPacketID", redPacketID, "groupID", req.GroupID)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to create red packet record"))
|
||
return
|
||
}
|
||
|
||
// 创建红包消息内容(使用自定义消息格式)
|
||
redPacketElem := apistruct.RedPacketElem{
|
||
RedPacketID: redPacketID,
|
||
RedPacketType: req.RedPacketType,
|
||
Blessing: req.Blessing,
|
||
}
|
||
// 将红包数据序列化为JSON字符串,存储在data字段中
|
||
redPacketData := jsonutil.StructToJsonString(redPacketElem)
|
||
|
||
// 构建自定义消息结构
|
||
customElem := apistruct.CustomElem{
|
||
Data: redPacketData,
|
||
Description: "redpacket", // 二级类型标识:红包消息
|
||
Extension: "", // 扩展字段,可用于未来扩展
|
||
}
|
||
content := jsonutil.StructToJsonString(customElem)
|
||
|
||
// 构建消息请求
|
||
msgData := &sdkws.MsgData{
|
||
SendID: sendUserID,
|
||
GroupID: req.GroupID,
|
||
ClientMsgID: idutil.GetMsgIDByMD5(sendUserID),
|
||
SenderPlatformID: 0, // 系统发送
|
||
SenderNickname: senderNickname,
|
||
SenderFaceURL: senderFaceURL,
|
||
SessionType: constant.ReadGroupChatType,
|
||
MsgFrom: constant.SysMsgType,
|
||
ContentType: constant.Custom, // 使用自定义消息类型(110)
|
||
CreateTime: timeutil.GetCurrentTimestampByMill(),
|
||
SendTime: timeutil.GetCurrentTimestampByMill(),
|
||
Content: []byte(content),
|
||
Options: make(map[string]bool),
|
||
}
|
||
|
||
// 发送消息
|
||
sendMsgReq := &msg.SendMsgReq{
|
||
MsgData: msgData,
|
||
}
|
||
sendMsgResp, err := r.msgClient.SendMsg(c, sendMsgReq)
|
||
if err != nil {
|
||
log.ZError(c, "SendRedPacket: failed to send message", err, "redPacketID", redPacketID, "groupID", req.GroupID)
|
||
apiresp.GinError(c, err)
|
||
return
|
||
}
|
||
|
||
// 返回响应
|
||
resp.RedPacketID = redPacketID
|
||
resp.ServerMsgID = sendMsgResp.ServerMsgID
|
||
resp.ClientMsgID = msgData.ClientMsgID
|
||
resp.SendTime = sendMsgResp.SendTime
|
||
|
||
log.ZInfo(c, "SendRedPacket: success", "redPacketID", redPacketID, "groupID", req.GroupID, "sendUserID", sendUserID)
|
||
apiresp.GinSuccess(c, resp)
|
||
}
|
||
|
||
// allocateRandomAmounts 分配拼手气红包金额(仿照微信红包算法)
|
||
// 算法原理:
|
||
// 1. 确保每个红包至少有最小金额(1分)
|
||
// 2. 对于前n-1个红包,每次分配时动态计算最大可分配金额
|
||
// 3. 在最小金额和最大金额之间随机分配
|
||
// 4. 最后一个红包直接分配剩余金额
|
||
// 5. 打乱顺序增加随机性
|
||
func (r *RedPacketApi) allocateRandomAmounts(totalAmount int64, totalCount int32) ([]int64, error) {
|
||
if totalCount <= 0 {
|
||
return nil, errs.ErrArgs.WrapMsg("totalCount must be greater than 0")
|
||
}
|
||
if totalAmount < int64(totalCount) {
|
||
return nil, errs.ErrArgs.WrapMsg("totalAmount must be at least totalCount (minimum 1 cent per packet)")
|
||
}
|
||
|
||
const minAmount = 1 // 最小金额:1分
|
||
amounts := make([]int64, totalCount)
|
||
remainAmount := totalAmount
|
||
remainCount := totalCount
|
||
|
||
// 使用随机种子
|
||
rand.Seed(time.Now().UnixNano())
|
||
|
||
// 分配前n-1个红包
|
||
for i := int32(0); i < totalCount-1; i++ {
|
||
// 计算最大可分配金额:剩余金额 / 剩余红包数 * 2
|
||
// 这样可以保证后续红包还能分配,避免出现0或全部的情况
|
||
// 但需要确保至少是最小金额
|
||
maxAmount := remainAmount / int64(remainCount) * 2
|
||
if maxAmount < minAmount {
|
||
maxAmount = minAmount
|
||
}
|
||
|
||
// 如果剩余金额不足以分配给剩余红包(每个至少1分),则直接分配剩余金额
|
||
if remainAmount <= int64(remainCount)*minAmount {
|
||
amounts[i] = minAmount
|
||
remainAmount -= minAmount
|
||
remainCount--
|
||
continue
|
||
}
|
||
|
||
// 确保最大金额不超过剩余金额减去剩余红包的最小金额
|
||
// 这样可以保证后续红包至少能分配到最小金额
|
||
maxAllowed := remainAmount - int64(remainCount-1)*minAmount
|
||
if maxAmount > maxAllowed {
|
||
maxAmount = maxAllowed
|
||
}
|
||
|
||
// 在最小金额和最大金额之间随机分配
|
||
// 如果最大金额等于最小金额,直接使用最小金额
|
||
var amount int64
|
||
if maxAmount <= minAmount {
|
||
amount = minAmount
|
||
} else {
|
||
amount = rand.Int63n(maxAmount-minAmount+1) + minAmount
|
||
}
|
||
|
||
amounts[i] = amount
|
||
remainAmount -= amount
|
||
remainCount--
|
||
}
|
||
|
||
// 最后一个红包,直接分配剩余金额
|
||
amounts[totalCount-1] = remainAmount
|
||
|
||
// 验证:确保每个红包至少1分
|
||
if amounts[totalCount-1] < minAmount {
|
||
return nil, errs.ErrInternalServer.WrapMsg("last packet amount is less than minimum")
|
||
}
|
||
|
||
// 打乱顺序(增加随机性)
|
||
rand.Shuffle(len(amounts), func(i, j int) {
|
||
amounts[i], amounts[j] = amounts[j], amounts[i]
|
||
})
|
||
|
||
// 最终验证:确保每个红包至少1分,总和等于总金额
|
||
var sum int64
|
||
for i := int32(0); i < totalCount; i++ {
|
||
if amounts[i] < minAmount {
|
||
return nil, errs.ErrInternalServer.WrapMsg("allocated amount is less than minimum")
|
||
}
|
||
sum += amounts[i]
|
||
}
|
||
if sum != totalAmount {
|
||
return nil, errs.ErrInternalServer.WrapMsg("allocated amount sum mismatch")
|
||
}
|
||
|
||
return amounts, nil
|
||
}
|
||
|
||
// writeRedPacketRecordToMongoDB 异步写入MongoDB领取记录
|
||
func (r *RedPacketApi) writeRedPacketRecordToMongoDB(ctx context.Context, redPacketID, userID string, amount int64) {
|
||
receiveID := idutil.GetMsgIDByMD5(userID + redPacketID + timeutil.GetCurrentTimeFormatted())
|
||
receiveRecord := &model.RedPacketReceive{
|
||
ReceiveID: receiveID,
|
||
RedPacketID: redPacketID,
|
||
ReceiveUserID: userID,
|
||
Amount: amount,
|
||
ReceiveTime: time.Now(),
|
||
IsLucky: false,
|
||
}
|
||
|
||
if err := r.redPacketReceiveDB.Create(ctx, receiveRecord); err != nil {
|
||
// 检查是否是唯一索引冲突(MongoDB E11000错误)
|
||
errStr := err.Error()
|
||
if errs.ErrArgs.Is(err) || (len(errStr) > 0 && (errStr == "E11000 duplicate key error" ||
|
||
errStr == "duplicate key error collection" ||
|
||
len(errStr) > 5 && errStr[:5] == "E11000")) {
|
||
log.ZDebug(ctx, "ReceiveRedPacket: receive record already exists (duplicate key)", "redPacketID", redPacketID, "userID", userID)
|
||
return
|
||
}
|
||
// 其他错误记录日志,后续补偿机制会处理
|
||
log.ZError(ctx, "ReceiveRedPacket: failed to write receive record to MongoDB", err,
|
||
"redPacketID", redPacketID,
|
||
"userID", userID,
|
||
"amount", amount)
|
||
}
|
||
}
|
||
|
||
// updateWalletBalanceAsync 异步更新钱包余额
|
||
func (r *RedPacketApi) updateWalletBalanceAsync(ctx context.Context, userID string, amount int64, redPacketID string) {
|
||
maxRetries := 3
|
||
var updateSuccess bool
|
||
|
||
for retry := 0; retry < maxRetries; retry++ {
|
||
// 查询或创建用户钱包
|
||
wallet, err := r.walletDB.Take(ctx, userID)
|
||
if err != nil {
|
||
// 如果钱包不存在,创建新钱包
|
||
if errs.ErrRecordNotFound.Is(err) || mgo.IsNotFound(err) {
|
||
wallet = &model.Wallet{
|
||
UserID: userID,
|
||
Balance: 0,
|
||
Version: 1,
|
||
CreateTime: time.Now(),
|
||
UpdateTime: time.Now(),
|
||
}
|
||
if err := r.walletDB.Create(ctx, wallet); err != nil {
|
||
log.ZError(ctx, "ReceiveRedPacket: failed to create wallet", err, "userID", userID, "retry", retry)
|
||
// 如果创建失败,可能是并发创建,下次重试会查到
|
||
if retry < maxRetries-1 {
|
||
time.Sleep(time.Millisecond * 50)
|
||
continue
|
||
}
|
||
return
|
||
}
|
||
} else {
|
||
log.ZError(ctx, "ReceiveRedPacket: failed to get wallet", err, "userID", userID, "retry", retry)
|
||
if retry < maxRetries-1 {
|
||
time.Sleep(time.Millisecond * 50)
|
||
continue
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// 使用版本号更新余额(防止并发覆盖)
|
||
params := &database.WalletUpdateParams{
|
||
UserID: userID,
|
||
Operation: "add",
|
||
Amount: amount,
|
||
OldBalance: wallet.Balance,
|
||
OldVersion: wallet.Version,
|
||
}
|
||
result, err := r.walletDB.UpdateBalanceWithVersion(ctx, params)
|
||
if err != nil {
|
||
// 如果是并发冲突(版本号不匹配),重试
|
||
log.ZWarn(ctx, "ReceiveRedPacket: concurrent modification detected, retrying", err,
|
||
"userID", userID,
|
||
"retry", retry+1,
|
||
"maxRetries", maxRetries)
|
||
if retry < maxRetries-1 {
|
||
time.Sleep(time.Millisecond * 100)
|
||
continue
|
||
}
|
||
log.ZError(ctx, "ReceiveRedPacket: failed to update wallet balance after retries", err, "userID", userID, "amount", amount)
|
||
return
|
||
}
|
||
|
||
// 更新成功,创建余额记录
|
||
updateSuccess = true
|
||
|
||
// 创建余额记录
|
||
recordID := idutil.GetMsgIDByMD5(userID + timeutil.GetCurrentTimeFormatted() + "add" + strconv.FormatInt(amount, 10) + redPacketID)
|
||
balanceRecord := &model.WalletBalanceRecord{
|
||
ID: recordID,
|
||
UserID: userID,
|
||
Amount: amount, // 领取红包金额(正数)
|
||
Type: 8, // 8 = 抢红包
|
||
BeforeBalance: params.OldBalance,
|
||
AfterBalance: result.NewBalance,
|
||
OrderID: "",
|
||
TransactionID: "",
|
||
RedPacketID: redPacketID,
|
||
Remark: "领取红包",
|
||
CreateTime: time.Now(),
|
||
}
|
||
if err := r.walletBalanceRecordDB.Create(ctx, balanceRecord); err != nil {
|
||
log.ZWarn(ctx, "ReceiveRedPacket: failed to create balance record", err,
|
||
"userID", userID,
|
||
"redPacketID", redPacketID,
|
||
"amount", amount)
|
||
}
|
||
|
||
log.ZInfo(ctx, "ReceiveRedPacket: wallet balance updated async",
|
||
"userID", userID,
|
||
"oldBalance", wallet.Balance,
|
||
"newBalance", result.NewBalance,
|
||
"amount", amount)
|
||
break
|
||
}
|
||
|
||
if !updateSuccess {
|
||
log.ZError(ctx, "ReceiveRedPacket: failed to update wallet balance after all retries", nil, "userID", userID, "maxRetries", maxRetries)
|
||
}
|
||
}
|
||
|
||
// writeToCompensationStream 写入失败补偿Stream(Redis Stream)
|
||
func (r *RedPacketApi) writeToCompensationStream(ctx context.Context, redPacketID, userID string, amount int64) {
|
||
if r.redisClient == nil {
|
||
return
|
||
}
|
||
|
||
streamKey := r.getRedPacketStreamKey(redPacketID)
|
||
// 使用 XADD 写入Stream,字段包含必要信息
|
||
values := map[string]interface{}{
|
||
"userID": userID,
|
||
"amount": amount,
|
||
"time": time.Now().Unix(),
|
||
}
|
||
|
||
if err := r.redisClient.XAdd(ctx, &redis.XAddArgs{
|
||
Stream: streamKey,
|
||
Values: values,
|
||
}).Err(); err != nil {
|
||
log.ZWarn(ctx, "ReceiveRedPacket: failed to write to compensation stream", err,
|
||
"redPacketID", redPacketID,
|
||
"userID", userID)
|
||
}
|
||
}
|
||
|
||
// ReceiveRedPacket 领取红包(新方案:Redis负责并发控制,MongoDB异步写入)
|
||
func (r *RedPacketApi) ReceiveRedPacket(c *gin.Context) {
|
||
var (
|
||
req apistruct.ReceiveRedPacketReq
|
||
resp apistruct.ReceiveRedPacketResp
|
||
)
|
||
if err := c.BindJSON(&req); err != nil {
|
||
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
|
||
return
|
||
}
|
||
|
||
// 获取当前用户ID
|
||
opUserID := mcontext.GetOpUserID(c)
|
||
if opUserID == "" {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("userID is required"))
|
||
return
|
||
}
|
||
|
||
// 查询红包基本信息(用于状态检查)
|
||
redPacket, err := r.redPacketDB.Take(c, req.RedPacketID)
|
||
if err != nil {
|
||
log.ZError(c, "ReceiveRedPacket: failed to get red packet", err, "redPacketID", req.RedPacketID, "userID", opUserID)
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("red packet not found"))
|
||
return
|
||
}
|
||
|
||
// 检查红包状态(过期状态检查)
|
||
if redPacket.Status == model.RedPacketStatusExpired {
|
||
apiresp.GinError(c, servererrs.ErrRedPacketExpired)
|
||
return
|
||
}
|
||
|
||
// 【核心】使用Redis Lua脚本原子性地抢红包
|
||
ctx := context.Background()
|
||
amount, err := r.grabRedPacketFromRedis(ctx, req.RedPacketID, opUserID)
|
||
if err != nil {
|
||
if servererrs.ErrRedPacketAlreadyReceived.Is(err) {
|
||
apiresp.GinError(c, err)
|
||
return
|
||
}
|
||
if servererrs.ErrRedPacketFinished.Is(err) {
|
||
apiresp.GinError(c, err)
|
||
return
|
||
}
|
||
// 其他错误(可能是Lua脚本执行失败或返回值解析失败)
|
||
log.ZError(c, "ReceiveRedPacket: failed to grab red packet from redis", err,
|
||
"redPacketID", req.RedPacketID,
|
||
"userID", opUserID,
|
||
"error", err.Error())
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to grab red packet: "+err.Error()))
|
||
return
|
||
}
|
||
|
||
// Redis抢红包成功,立即返回结果
|
||
resp.RedPacketID = req.RedPacketID
|
||
resp.Amount = amount
|
||
resp.IsLucky = false
|
||
|
||
log.ZInfo(c, "ReceiveRedPacket: successfully grabbed from redis", "redPacketID", req.RedPacketID, "userID", opUserID, "amount", amount)
|
||
apiresp.GinSuccess(c, resp)
|
||
|
||
// 【异步写入MongoDB】Redis成功后,异步写入MongoDB和补偿Stream
|
||
go func() {
|
||
// 写入失败补偿Stream(用于后续补偿)
|
||
r.writeToCompensationStream(ctx, req.RedPacketID, opUserID, amount)
|
||
|
||
// 异步写入MongoDB领取记录
|
||
r.writeRedPacketRecordToMongoDB(ctx, req.RedPacketID, opUserID, amount)
|
||
|
||
// 异步更新钱包余额
|
||
r.updateWalletBalanceAsync(ctx, opUserID, amount, req.RedPacketID)
|
||
}()
|
||
}
|
||
|
||
// getRedPacketQueueKey 获取红包队列的Redis key
|
||
func (r *RedPacketApi) getRedPacketQueueKey(redPacketID string) string {
|
||
return "rp:" + redPacketID + ":list"
|
||
}
|
||
|
||
// getRedPacketUsersKey 获取已领取用户Set的Redis key
|
||
func (r *RedPacketApi) getRedPacketUsersKey(redPacketID string) string {
|
||
return "rp:" + redPacketID + ":users"
|
||
}
|
||
|
||
// getRedPacketStreamKey 获取红包领取日志Stream的Redis key(用于失败补偿)
|
||
func (r *RedPacketApi) getRedPacketStreamKey(redPacketID string) string {
|
||
return "rp:" + redPacketID + ":stream"
|
||
}
|
||
|
||
// grabRedPacketLuaScript Redis Lua脚本:原子性地抢红包
|
||
// 返回值:
|
||
//
|
||
// -1: 用户已领取
|
||
// -2: 红包已领完
|
||
// -3: 金额解析失败(数据异常)
|
||
// >0: 领取成功,返回金额(分)
|
||
var grabRedPacketLuaScript = redis.NewScript(`
|
||
-- KEYS[1] 红包金额 list (rp:{packetId}:list)
|
||
-- KEYS[2] 已抢用户 set (rp:{packetId}:users)
|
||
-- ARGV[1] userId
|
||
|
||
-- 已抢判断
|
||
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
|
||
return -1
|
||
end
|
||
|
||
-- 抢红包
|
||
local money = redis.call('LPOP', KEYS[1])
|
||
if not money then
|
||
return -2
|
||
end
|
||
|
||
-- 将字符串金额转换为数字
|
||
local amount = tonumber(money)
|
||
if not amount then
|
||
return -3
|
||
end
|
||
|
||
-- 记录用户(在返回之前记录,确保原子性)
|
||
redis.call('SADD', KEYS[2], ARGV[1])
|
||
|
||
-- 返回数字金额
|
||
return amount
|
||
`)
|
||
|
||
// grabRedPacketFromRedis 使用Redis Lua脚本原子性地抢红包
|
||
func (r *RedPacketApi) grabRedPacketFromRedis(ctx context.Context, redPacketID, userID string) (int64, error) {
|
||
if r.redisClient == nil {
|
||
return 0, errs.ErrInternalServer.WrapMsg("redis client is not available")
|
||
}
|
||
|
||
listKey := r.getRedPacketQueueKey(redPacketID)
|
||
usersKey := r.getRedPacketUsersKey(redPacketID)
|
||
|
||
// 执行Lua脚本
|
||
result, err := grabRedPacketLuaScript.Eval(ctx, r.redisClient, []string{listKey, usersKey}, userID).Result()
|
||
if err != nil {
|
||
log.ZError(ctx, "ReceiveRedPacket: lua script execution failed", err,
|
||
"redPacketID", redPacketID,
|
||
"userID", userID,
|
||
"listKey", listKey,
|
||
"usersKey", usersKey)
|
||
return 0, errs.Wrap(err)
|
||
}
|
||
|
||
// 记录原始返回值,用于调试
|
||
log.ZDebug(ctx, "ReceiveRedPacket: lua script result", "redPacketID", redPacketID, "userID", userID, "result", result, "resultType", fmt.Sprintf("%T", result))
|
||
|
||
// 解析返回值(Lua脚本返回的是数字,但Redis可能返回字符串或数字)
|
||
var code int64
|
||
switch v := result.(type) {
|
||
case int64:
|
||
code = v
|
||
case int:
|
||
code = int64(v)
|
||
case string:
|
||
// 如果返回字符串,尝试转换为数字
|
||
parsed, err := strconv.ParseInt(v, 10, 64)
|
||
if err != nil {
|
||
log.ZError(ctx, "ReceiveRedPacket: failed to parse lua script return value", err, "redPacketID", redPacketID, "userID", userID, "result", result)
|
||
return 0, errs.ErrInternalServer.WrapMsg("invalid lua script return value: " + v)
|
||
}
|
||
code = parsed
|
||
default:
|
||
log.ZError(ctx, "ReceiveRedPacket: unexpected lua script return type", nil, "redPacketID", redPacketID, "userID", userID, "result", result, "type", fmt.Sprintf("%T", result))
|
||
return 0, errs.ErrInternalServer.WrapMsg(fmt.Sprintf("invalid lua script return value type: %T", result))
|
||
}
|
||
|
||
switch code {
|
||
case -1:
|
||
return 0, servererrs.ErrRedPacketAlreadyReceived
|
||
case -2:
|
||
return 0, servererrs.ErrRedPacketFinished
|
||
case -3:
|
||
return 0, errs.ErrInternalServer.WrapMsg("invalid red packet amount data")
|
||
default:
|
||
// code > 0 表示领取成功,返回金额
|
||
return code, nil
|
||
}
|
||
}
|
||
|
||
// paginationWrapper 实现 pagination.Pagination 接口
|
||
type paginationWrapper struct {
|
||
pageNumber int32
|
||
showNumber int32
|
||
}
|
||
|
||
func (p *paginationWrapper) GetPageNumber() int32 {
|
||
if p.pageNumber <= 0 {
|
||
return 1
|
||
}
|
||
return p.pageNumber
|
||
}
|
||
|
||
func (p *paginationWrapper) GetShowNumber() int32 {
|
||
if p.showNumber <= 0 {
|
||
return 20
|
||
}
|
||
return p.showNumber
|
||
}
|
||
|
||
// GetRedPacketsByGroup 根据群ID查询红包列表(后台管理接口)
|
||
func (r *RedPacketApi) GetRedPacketsByGroup(c *gin.Context) {
|
||
var (
|
||
req apistruct.GetRedPacketsByGroupReq
|
||
resp apistruct.GetRedPacketsByGroupResp
|
||
)
|
||
if err := c.BindJSON(&req); err != nil {
|
||
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
|
||
return
|
||
}
|
||
|
||
// 设置默认分页参数
|
||
if req.Pagination.PageNumber <= 0 {
|
||
req.Pagination.PageNumber = 1
|
||
}
|
||
if req.Pagination.ShowNumber <= 0 {
|
||
req.Pagination.ShowNumber = 20
|
||
}
|
||
|
||
// 创建分页对象
|
||
pagination := &paginationWrapper{
|
||
pageNumber: req.Pagination.PageNumber,
|
||
showNumber: req.Pagination.ShowNumber,
|
||
}
|
||
|
||
// 查询红包列表
|
||
var total int64
|
||
var redPackets []*model.RedPacket
|
||
var err error
|
||
if req.GroupID == "" {
|
||
// 如果群ID为空,查询所有红包
|
||
total, redPackets, err = r.redPacketDB.FindAllRedPackets(c, pagination)
|
||
} else {
|
||
// 如果群ID不为空,查询指定群的红包
|
||
total, redPackets, err = r.redPacketDB.FindRedPacketsByGroup(c, req.GroupID, pagination)
|
||
}
|
||
if err != nil {
|
||
log.ZError(c, "GetRedPacketsByGroup: failed to find red packets", err, "groupID", req.GroupID)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to find red packets"))
|
||
return
|
||
}
|
||
|
||
// 收集所有唯一的群ID
|
||
groupIDMap := make(map[string]bool)
|
||
for _, rp := range redPackets {
|
||
if rp.GroupID != "" {
|
||
groupIDMap[rp.GroupID] = true
|
||
}
|
||
}
|
||
|
||
// 批量查询群信息
|
||
groupIDList := make([]string, 0, len(groupIDMap))
|
||
for groupID := range groupIDMap {
|
||
groupIDList = append(groupIDList, groupID)
|
||
}
|
||
|
||
groupInfoMap := make(map[string]string) // groupID -> groupName
|
||
if len(groupIDList) > 0 {
|
||
groupInfos, err := r.groupClient.GetGroupsInfo(c, groupIDList)
|
||
if err == nil && groupInfos != nil {
|
||
for _, groupInfo := range groupInfos {
|
||
groupInfoMap[groupInfo.GroupID] = groupInfo.GroupName
|
||
}
|
||
} else {
|
||
log.ZWarn(c, "GetRedPacketsByGroup: failed to get groups info", err, "groupIDs", groupIDList)
|
||
}
|
||
}
|
||
|
||
// 转换为响应格式
|
||
resp.Total = total
|
||
resp.RedPackets = make([]*apistruct.RedPacketInfo, 0, len(redPackets))
|
||
for _, rp := range redPackets {
|
||
groupName := groupInfoMap[rp.GroupID]
|
||
resp.RedPackets = append(resp.RedPackets, &apistruct.RedPacketInfo{
|
||
RedPacketID: rp.RedPacketID,
|
||
SendUserID: rp.SendUserID,
|
||
GroupID: rp.GroupID,
|
||
GroupName: groupName,
|
||
RedPacketType: rp.RedPacketType,
|
||
TotalAmount: rp.TotalAmount,
|
||
TotalCount: rp.TotalCount,
|
||
RemainAmount: rp.RemainAmount,
|
||
RemainCount: rp.RemainCount,
|
||
Blessing: rp.Blessing,
|
||
Status: rp.Status,
|
||
ExpireTime: rp.ExpireTime.UnixMilli(),
|
||
CreateTime: rp.CreateTime.UnixMilli(),
|
||
})
|
||
}
|
||
|
||
log.ZInfo(c, "GetRedPacketsByGroup: success", "groupID", req.GroupID, "total", total, "count", len(resp.RedPackets))
|
||
apiresp.GinSuccess(c, resp)
|
||
}
|
||
|
||
// GetRedPacketReceiveInfo 查询红包领取情况(后台管理接口)
|
||
func (r *RedPacketApi) GetRedPacketReceiveInfo(c *gin.Context) {
|
||
var (
|
||
req apistruct.GetRedPacketReceiveInfoReq
|
||
resp apistruct.GetRedPacketReceiveInfoResp
|
||
)
|
||
if err := c.BindJSON(&req); err != nil {
|
||
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
|
||
return
|
||
}
|
||
|
||
// 查询红包信息
|
||
redPacket, err := r.redPacketDB.Take(c, req.RedPacketID)
|
||
if err != nil {
|
||
log.ZError(c, "GetRedPacketReceiveInfo: failed to get red packet", err, "redPacketID", req.RedPacketID)
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("red packet not found"))
|
||
return
|
||
}
|
||
|
||
// 查询领取记录
|
||
receives, err := r.redPacketReceiveDB.FindByRedPacketID(c, req.RedPacketID)
|
||
if err != nil {
|
||
log.ZError(c, "GetRedPacketReceiveInfo: failed to find receives", err, "redPacketID", req.RedPacketID)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to find receives"))
|
||
return
|
||
}
|
||
|
||
// 填充响应数据
|
||
resp.RedPacketID = redPacket.RedPacketID
|
||
resp.TotalAmount = redPacket.TotalAmount
|
||
resp.TotalCount = redPacket.TotalCount
|
||
resp.RemainAmount = redPacket.RemainAmount
|
||
resp.RemainCount = redPacket.RemainCount
|
||
resp.Status = redPacket.Status
|
||
resp.Receives = make([]*apistruct.RedPacketReceiveDetail, 0, len(receives))
|
||
for _, rec := range receives {
|
||
resp.Receives = append(resp.Receives, &apistruct.RedPacketReceiveDetail{
|
||
ReceiveID: rec.ReceiveID,
|
||
ReceiveUserID: rec.ReceiveUserID,
|
||
Amount: rec.Amount,
|
||
ReceiveTime: rec.ReceiveTime.UnixMilli(),
|
||
IsLucky: false, // 已去掉手气最佳功能,始终返回 false
|
||
})
|
||
}
|
||
|
||
log.ZInfo(c, "GetRedPacketReceiveInfo: success", "redPacketID", req.RedPacketID, "receiveCount", len(resp.Receives))
|
||
apiresp.GinSuccess(c, resp)
|
||
}
|
||
|
||
// PauseRedPacket 暂停红包(后台管理接口)- 清空Redis队列
|
||
func (r *RedPacketApi) PauseRedPacket(c *gin.Context) {
|
||
var (
|
||
req apistruct.PauseRedPacketReq
|
||
resp apistruct.PauseRedPacketResp
|
||
)
|
||
if err := c.BindJSON(&req); err != nil {
|
||
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
|
||
return
|
||
}
|
||
|
||
// 查询红包信息,验证红包是否存在
|
||
redPacket, err := r.redPacketDB.Take(c, req.RedPacketID)
|
||
if err != nil {
|
||
log.ZError(c, "PauseRedPacket: failed to get red packet", err, "redPacketID", req.RedPacketID)
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("red packet not found"))
|
||
return
|
||
}
|
||
|
||
// 只有拼手气红包才有Redis队列,需要清空
|
||
if redPacket.RedPacketType == model.RedPacketTypeRandom {
|
||
if r.redisClient == nil {
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("redis client is not available"))
|
||
return
|
||
}
|
||
|
||
// 获取Redis队列key
|
||
queueKey := r.getRedPacketQueueKey(req.RedPacketID)
|
||
ctx := context.Background()
|
||
|
||
// 清空Redis队列(删除整个key)
|
||
err = r.redisClient.Del(ctx, queueKey).Err()
|
||
if err != nil {
|
||
log.ZError(c, "PauseRedPacket: failed to delete redis queue", err, "redPacketID", req.RedPacketID, "queueKey", queueKey)
|
||
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to pause red packet"))
|
||
return
|
||
}
|
||
|
||
log.ZInfo(c, "PauseRedPacket: cleared redis queue", "redPacketID", req.RedPacketID, "queueKey", queueKey)
|
||
} else {
|
||
// 普通红包没有Redis队列,直接返回成功
|
||
log.ZInfo(c, "PauseRedPacket: normal red packet, no redis queue to clear", "redPacketID", req.RedPacketID)
|
||
}
|
||
|
||
// 返回响应
|
||
resp.RedPacketID = req.RedPacketID
|
||
|
||
log.ZInfo(c, "PauseRedPacket: success", "redPacketID", req.RedPacketID)
|
||
apiresp.GinSuccess(c, resp)
|
||
}
|
||
|
||
// GetRedPacketDetail 查询红包详情(用户端接口)
|
||
func (r *RedPacketApi) GetRedPacketDetail(c *gin.Context) {
|
||
var (
|
||
req apistruct.GetRedPacketDetailReq
|
||
resp apistruct.GetRedPacketDetailResp
|
||
)
|
||
if err := c.BindJSON(&req); err != nil {
|
||
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
|
||
return
|
||
}
|
||
|
||
// 获取当前用户ID
|
||
opUserID := mcontext.GetOpUserID(c)
|
||
if opUserID == "" {
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("userID is required"))
|
||
return
|
||
}
|
||
|
||
// 查询红包信息
|
||
redPacket, err := r.redPacketDB.Take(c, req.RedPacketID)
|
||
if err != nil {
|
||
log.ZError(c, "GetRedPacketDetail: failed to get red packet", err, "redPacketID", req.RedPacketID, "userID", opUserID)
|
||
apiresp.GinError(c, errs.ErrArgs.WrapMsg("red packet not found"))
|
||
return
|
||
}
|
||
|
||
// 验证用户是否在群里
|
||
groupMember, err := r.groupClient.GetGroupMemberInfo(c, redPacket.GroupID, opUserID)
|
||
if err != nil || groupMember == nil {
|
||
log.ZWarn(c, "GetRedPacketDetail: user not in group", err, "redPacketID", req.RedPacketID, "groupID", redPacket.GroupID, "userID", opUserID)
|
||
apiresp.GinError(c, errs.ErrNoPermission.WrapMsg("user not in group"))
|
||
return
|
||
}
|
||
|
||
// 判断是否过期(超过一周)
|
||
oneWeekAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||
isExpired := redPacket.CreateTime.Before(oneWeekAgo)
|
||
|
||
// 查询当前用户的领取信息
|
||
myReceive, err := r.redPacketReceiveDB.FindByUserAndRedPacketID(c, opUserID, req.RedPacketID)
|
||
var myReceiveDetail *apistruct.RedPacketMyReceiveDetail
|
||
if err == nil && myReceive != nil {
|
||
// 自己的领取信息不返回用户ID、昵称、头像
|
||
myReceiveDetail = &apistruct.RedPacketMyReceiveDetail{
|
||
ReceiveID: myReceive.ReceiveID,
|
||
Amount: myReceive.Amount,
|
||
ReceiveTime: myReceive.ReceiveTime.UnixMilli(),
|
||
IsLucky: false, // 已去掉手气最佳功能,始终返回 false
|
||
}
|
||
}
|
||
|
||
// 判断是否是群主或管理员
|
||
isOwnerOrAdmin := groupMember.RoleLevel == constant.GroupOwner || groupMember.RoleLevel == constant.GroupAdmin
|
||
|
||
// 构建响应
|
||
resp.RedPacketID = redPacket.RedPacketID
|
||
resp.GroupID = redPacket.GroupID
|
||
resp.RedPacketType = redPacket.RedPacketType
|
||
resp.TotalAmount = redPacket.TotalAmount
|
||
resp.TotalCount = redPacket.TotalCount
|
||
resp.RemainAmount = redPacket.RemainAmount
|
||
resp.RemainCount = redPacket.RemainCount
|
||
resp.Blessing = redPacket.Blessing
|
||
resp.Status = redPacket.Status
|
||
resp.IsExpired = isExpired
|
||
resp.MyReceive = myReceiveDetail
|
||
resp.Receives = []*apistruct.RedPacketUserReceiveDetail{}
|
||
|
||
// 如果是群主或管理员,返回所有领取记录
|
||
if isOwnerOrAdmin {
|
||
receives, err := r.redPacketReceiveDB.FindByRedPacketID(c, req.RedPacketID)
|
||
if err != nil {
|
||
log.ZWarn(c, "GetRedPacketDetail: failed to find receives", err, "redPacketID", req.RedPacketID)
|
||
} else {
|
||
// 收集所有领取者ID
|
||
userIDs := make([]string, 0, len(receives))
|
||
for _, rec := range receives {
|
||
userIDs = append(userIDs, rec.ReceiveUserID)
|
||
}
|
||
|
||
// 批量获取用户信息
|
||
userInfos, err := r.userClient.GetUsersInfo(c, userIDs)
|
||
if err != nil {
|
||
log.ZWarn(c, "GetRedPacketDetail: failed to get users info", err, "userIDs", userIDs)
|
||
}
|
||
|
||
// 构建用户信息映射
|
||
userInfoMap := make(map[string]*sdkws.UserInfo)
|
||
if userInfos != nil {
|
||
for _, userInfo := range userInfos {
|
||
if userInfo != nil {
|
||
userInfoMap[userInfo.UserID] = userInfo
|
||
}
|
||
}
|
||
}
|
||
|
||
// 构建领取记录列表
|
||
resp.Receives = make([]*apistruct.RedPacketUserReceiveDetail, 0, len(receives))
|
||
for _, rec := range receives {
|
||
userInfo := userInfoMap[rec.ReceiveUserID]
|
||
nickname := rec.ReceiveUserID
|
||
faceURL := ""
|
||
if userInfo != nil {
|
||
nickname = userInfo.Nickname
|
||
faceURL = userInfo.FaceURL
|
||
}
|
||
resp.Receives = append(resp.Receives, &apistruct.RedPacketUserReceiveDetail{
|
||
ReceiveID: rec.ReceiveID,
|
||
ReceiveUserID: rec.ReceiveUserID,
|
||
Nickname: nickname,
|
||
FaceURL: faceURL,
|
||
Amount: rec.Amount,
|
||
ReceiveTime: rec.ReceiveTime.UnixMilli(),
|
||
IsLucky: false, // 已去掉手气最佳功能,始终返回 false
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
log.ZInfo(c, "GetRedPacketDetail: success", "redPacketID", req.RedPacketID, "userID", opUserID, "isOwnerOrAdmin", isOwnerOrAdmin, "isExpired", isExpired)
|
||
apiresp.GinSuccess(c, resp)
|
||
}
|