2026-05-08 18:55:47 +08:00

330 lines
11 KiB
Go

package voiceroomredpacket
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Send 创建语音房红包并完成发送人扣款。
func (s *Service) Send(ctx context.Context, user AuthUser, req SendRequest) (*SendResponse, error) {
req.RequestID = strings.TrimSpace(req.RequestID)
req.PacketMode = normalizePacketMode(req.PacketMode)
sysOrigin := normalizeSysOrigin(user.SysOrigin)
roomID := req.RoomID.Int64()
if req.RequestID == "" {
return nil, NewAppError(http.StatusBadRequest, "missing_request_id", "requestId is required")
}
if roomID <= 0 {
return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required")
}
if req.PacketMode != packetModeImmediate && req.PacketMode != packetModeDelayed {
return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_mode_invalid", "packetMode is invalid")
}
if existing, err := s.loadPacketByRequestID(ctx, req.RequestID); err != nil {
return nil, err
} else if existing != nil {
return s.buildSendResponse(ctx, *existing)
}
lockKey := fmt.Sprintf("voice_room:red_packet:send_lock:%s:%s", sysOrigin, req.RequestID)
lockOK, err := s.redis.SetNX(ctx, lockKey, "1", 15*time.Second).Result()
if err != nil {
return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_red_packet_lock_failed", err.Error())
}
if !lockOK {
return nil, NewAppError(http.StatusConflict, "voice_room_red_packet_in_progress", "red packet send is in progress")
}
defer s.redis.Del(context.Background(), lockKey)
if existing, err := s.loadPacketByRequestID(ctx, req.RequestID); err != nil {
return nil, err
} else if existing != nil {
return s.buildSendResponse(ctx, *existing)
}
cfg, err := s.loadConfig(ctx, sysOrigin)
if err != nil {
return nil, err
}
if !cfg.Enabled {
return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_disabled", "voice room red packet is disabled")
}
if !containsInt64(cfg.AmountOptions, req.TotalAmount) {
return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_amount_invalid", "totalAmount is invalid")
}
if !containsInt(cfg.CountOptions, req.TotalCount) {
return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_count_invalid", "totalCount is invalid")
}
if err := s.ensureUserInRoom(ctx, sysOrigin, user.UserID, roomID); err != nil {
return nil, err
}
regionCode, err := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID)
if err != nil {
return nil, err
}
var packet model.VoiceRoomRedPacket
var day string
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if existing, err := loadPacketByRequestIDTx(tx, req.RequestID); err != nil {
return err
} else if existing != nil {
packet = *existing
return nil
}
location := resolveLocation(cfg.Timezone)
now := time.Now()
localNow := now.In(location)
day = dayKey(localNow)
counter, err := s.incrementDailyCounterTx(tx, sysOrigin, user.UserID, day, cfg.DailySendLimit, now)
if err != nil {
return err
}
if counter.SendCount > cfg.DailySendLimit {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_daily_limit_exceeded", "daily send limit exceeded")
}
packetID, err := utils.NextID()
if err != nil {
return err
}
packetNo := buildPacketNo(localNow, packetID)
claimStart := now
if req.PacketMode == packetModeDelayed {
claimStart = now.Add(time.Duration(cfg.DelaySeconds) * time.Second)
}
expireAt := now.Add(time.Duration(cfg.RefundAfterSeconds) * time.Second)
packet = model.VoiceRoomRedPacket{
ID: packetID,
PacketNo: packetNo,
RequestID: req.RequestID,
SysOrigin: sysOrigin,
RoomID: roomID,
SenderUserID: user.UserID,
SenderRegionCode: regionCode,
PacketMode: req.PacketMode,
AmountMode: amountModeRandom,
TotalAmount: req.TotalAmount,
TotalCount: req.TotalCount,
RemainAmount: req.TotalAmount,
RemainCount: req.TotalCount,
ClaimStartTime: claimStart,
ExpireTime: expireAt,
Status: packetStatusInit,
SendWalletEventID: walletEventID(walletOriginSend, sysOrigin, packetNo, 0),
RefundStatus: refundStatusNone,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&packet).Error; err != nil {
return err
}
amounts, err := splitRandomAmounts(req.TotalAmount, req.TotalCount, packetID)
if err != nil {
return err
}
if sumAmounts(amounts) != req.TotalAmount {
return NewAppError(http.StatusInternalServerError, "voice_room_red_packet_amount_split_invalid", "split amount total mismatch")
}
slices := make([]model.VoiceRoomRedPacketSlice, 0, len(amounts))
for index, amount := range amounts {
sliceID, err := utils.NextID()
if err != nil {
return err
}
slices = append(slices, model.VoiceRoomRedPacketSlice{
ID: sliceID,
PacketID: packet.ID,
PacketNo: packet.PacketNo,
SortNo: index + 1,
Amount: amount,
Status: sliceStatusUnclaimed,
CreateTime: now,
UpdateTime: now,
})
}
return tx.Create(&slices).Error
}); err != nil {
return nil, err
}
if packet.Status != packetStatusInit {
return s.buildSendResponse(ctx, packet)
}
if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: walletReceiptExpenditure,
UserID: user.UserID,
SysOrigin: sysOrigin,
EventID: packet.SendWalletEventID,
Remark: fmt.Sprintf("voice room red packet %s room=%d", packet.PacketNo, packet.RoomID),
Amount: integration.NewPennyAmountPayloadFromDollar(packet.TotalAmount),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: walletOriginSend,
CustomizeOriginDesc: walletOriginSendDesc,
}); err != nil {
_ = s.markSendFailed(context.Background(), packet.ID, sysOrigin, user.UserID, day, err.Error())
return nil, mapWalletError("voice_room_red_packet_wallet_deduct_failed", err)
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.VoiceRoomRedPacket{}).
Where("id = ? AND status = ?", packet.ID, packetStatusInit).
Updates(map[string]any{
"status": packetStatusActive,
"refund_status": refundStatusPending,
"error_message": "",
"update_time": now,
}).Error; err != nil {
return nil, err
}
packet.Status = packetStatusActive
packet.RefundStatus = refundStatusPending
packet.UpdateTime = now
_ = s.redis.ZAdd(ctx, expireZSetKey(s.cfg), redis.Z{
Score: float64(packet.ExpireTime.Unix()),
Member: packet.PacketNo,
}).Err()
if err := s.notifyCreated(ctx, packet); err != nil {
logNotifyFailure(packet.PacketNo, err)
_ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacket{}).
Where("id = ?", packet.ID).
Update("error_message", trimErrorMessage(err.Error())).Error
}
return s.buildSendResponse(ctx, packet)
}
func (s *Service) loadPacketByRequestID(ctx context.Context, requestID string) (*model.VoiceRoomRedPacket, error) {
return loadPacketByRequestIDTx(s.db.WithContext(ctx), requestID)
}
func loadPacketByRequestIDTx(tx *gorm.DB, requestID string) (*model.VoiceRoomRedPacket, error) {
var row model.VoiceRoomRedPacket
err := tx.Where("request_id = ?", requestID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *Service) incrementDailyCounterTx(tx *gorm.DB, sysOrigin string, userID int64, day string, limit int, now time.Time) (*model.VoiceRoomRedPacketDailyCounter, error) {
var row model.VoiceRoomRedPacketDailyCounter
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sys_origin = ? AND user_id = ? AND day_key = ?", sysOrigin, userID, day).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := utils.NextID()
if idErr != nil {
return nil, idErr
}
row = model.VoiceRoomRedPacketDailyCounter{
ID: id,
SysOrigin: sysOrigin,
UserID: userID,
DayKey: day,
SendCount: 1,
CreateTime: now,
UpdateTime: now,
}
if limit > 0 && row.SendCount > limit {
return &row, nil
}
if err := tx.Create(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
if err != nil {
return nil, err
}
row.SendCount++
row.UpdateTime = now
if limit > 0 && row.SendCount > limit {
return &row, nil
}
if err := tx.Save(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *Service) markSendFailed(ctx context.Context, packetID int64, sysOrigin string, userID int64, day string, message string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ?", packetID).
Updates(map[string]any{
"status": packetStatusFailed,
"error_message": trimErrorMessage(message),
"update_time": time.Now(),
}).Error; err != nil {
return err
}
if day != "" {
if err := tx.Model(&model.VoiceRoomRedPacketDailyCounter{}).
Where("sys_origin = ? AND user_id = ? AND day_key = ? AND send_count > 0", sysOrigin, userID, day).
Update("send_count", gorm.Expr("send_count - 1")).Error; err != nil {
return err
}
}
return nil
})
}
func (s *Service) buildSendResponse(ctx context.Context, packet model.VoiceRoomRedPacket) (*SendResponse, error) {
if packet.Status == packetStatusFailed {
return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_send_failed", packet.ErrorMessage)
}
balanceAfter := int64(0)
if s.wallet != nil {
balances, err := s.wallet.MapGoldBalance(ctx, []int64{packet.SenderUserID})
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "voice_room_red_packet_wallet_balance_failed", err.Error())
}
balanceAfter = balances[packet.SenderUserID]
}
return &SendResponse{
PacketNo: packet.PacketNo,
RoomID: packet.RoomID,
PacketMode: packet.PacketMode,
TotalAmount: packet.TotalAmount,
TotalCount: packet.TotalCount,
RemainAmount: packet.RemainAmount,
RemainCount: packet.RemainCount,
ClaimStartTime: formatTime(packet.ClaimStartTime),
ExpireTime: formatTime(packet.ExpireTime),
Status: packet.Status,
BalanceAfter: balanceAfter,
}, nil
}
func buildPacketNo(now time.Time, id int64) string {
return fmt.Sprintf("VRP%s%d", now.Format("20060102150405"), id%100000000)
}
func expireZSetKey(cfg config.Config) string {
if value := strings.TrimSpace(cfg.VoiceRoomRedPacket.ExpireZSetKey); value != "" {
return value
}
return defaultExpireZSetKey
}