Files
open-im-server-deploy/internal/api/redpacket.go
kim.dev.6789 e50142a3b9 复制项目
2026-01-14 22:16:44 +08:00

1023 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 写入失败补偿StreamRedis 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)
}