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