330 lines
11 KiB
Go
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
|
|
}
|