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

295 lines
9.8 KiB
Go

package voiceroomredpacket
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Claim 领取语音房红包。
func (s *Service) Claim(ctx context.Context, user AuthUser, req ClaimRequest) (*ClaimResponse, error) {
packetNo := strings.TrimSpace(req.PacketNo)
if packetNo == "" {
return nil, NewAppError(http.StatusBadRequest, "missing_packet_no", "packetNo is required")
}
sysOrigin := normalizeSysOrigin(user.SysOrigin)
lockKey := fmt.Sprintf("voice_room:red_packet:claim_lock:%s:%d", packetNo, user.UserID)
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_claim_in_progress", "red packet claim is in progress")
}
defer s.redis.Del(context.Background(), lockKey)
packet, err := s.loadPacketByNo(ctx, packetNo)
if err != nil {
return nil, err
}
if packet == nil {
return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found")
}
if existing, err := s.loadUserClaim(ctx, packet.ID, user.UserID); err != nil {
return nil, err
} else if existing != nil {
return s.resumeExistingClaim(ctx, *packet, *existing)
}
userRegion, err := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID)
if err != nil {
return nil, err
}
var claim model.VoiceRoomRedPacketClaim
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.VoiceRoomRedPacket
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("packet_no = ?", packetNo).
First(&locked).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found")
}
if err != nil {
return err
}
if err := validateClaimable(locked, userRegion); err != nil {
return err
}
existing, err := loadUserClaimTx(tx, locked.ID, user.UserID)
if err != nil {
return err
}
if existing != nil {
claim = *existing
return nil
}
var slice model.VoiceRoomRedPacketSlice
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("packet_id = ? AND status = ?", locked.ID, sliceStatusUnclaimed).
Order("sort_no asc").
First(&slice).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
_ = tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ?", locked.ID).
Updates(map[string]any{"status": packetStatusFinished, "update_time": time.Now()}).Error
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished")
}
if err != nil {
return err
}
claimID, err := utils.NextID()
if err != nil {
return err
}
now := time.Now()
claim = model.VoiceRoomRedPacketClaim{
ID: claimID,
PacketID: locked.ID,
PacketNo: locked.PacketNo,
SliceID: slice.ID,
SysOrigin: locked.SysOrigin,
RoomID: locked.RoomID,
UserID: user.UserID,
UserRegionCode: userRegion,
Amount: slice.Amount,
WalletEventID: walletEventID(walletOriginClaim, locked.SysOrigin, locked.PacketNo, claimID),
WalletStatus: walletStatusPending,
ClaimTime: now,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&claim).Error; err != nil {
return err
}
if err := tx.Model(&model.VoiceRoomRedPacketSlice{}).
Where("id = ? AND status = ?", slice.ID, sliceStatusUnclaimed).
Updates(map[string]any{
"status": sliceStatusClaiming,
"claim_user_id": user.UserID,
"claim_record_id": claim.ID,
"update_time": now,
}).Error; err != nil {
return err
}
remainAmount := locked.RemainAmount - slice.Amount
remainCount := locked.RemainCount - 1
if remainAmount < 0 {
remainAmount = 0
}
if remainCount < 0 {
remainCount = 0
}
return tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ?", locked.ID).
Updates(map[string]any{
"remain_amount": remainAmount,
"remain_count": remainCount,
"update_time": now,
}).Error
}); err != nil {
return nil, err
}
return s.completeClaimWallet(ctx, claim)
}
func (s *Service) resumeExistingClaim(ctx context.Context, packet model.VoiceRoomRedPacket, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) {
if claim.WalletStatus == walletStatusSuccess {
return s.buildClaimResponse(ctx, claim)
}
if err := validateClaimableForRetry(packet); err != nil {
return nil, err
}
return s.completeClaimWallet(ctx, claim)
}
func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) {
if claim.WalletStatus != walletStatusSuccess {
err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: walletReceiptIncome,
UserID: claim.UserID,
SysOrigin: claim.SysOrigin,
EventID: claim.WalletEventID,
Remark: fmt.Sprintf("voice room red packet claim %s", claim.PacketNo),
Amount: integration.NewPennyAmountPayloadFromDollar(claim.Amount),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: walletOriginClaim,
CustomizeOriginDesc: walletOriginClaimDesc,
})
if err != nil {
_ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacketClaim{}).
Where("id = ?", claim.ID).
Updates(map[string]any{
"wallet_status": walletStatusFailed,
"error_message": trimErrorMessage(err.Error()),
"update_time": time.Now(),
}).Error
return nil, mapWalletError("voice_room_red_packet_wallet_income_failed", err)
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.VoiceRoomRedPacketClaim{}).
Where("id = ?", claim.ID).
Updates(map[string]any{
"wallet_status": walletStatusSuccess,
"error_message": "",
"update_time": now,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.VoiceRoomRedPacketSlice{}).
Where("id = ?", claim.SliceID).
Updates(map[string]any{
"status": sliceStatusClaimed,
"update_time": now,
}).Error; err != nil {
return err
}
return tx.Model(&model.VoiceRoomRedPacket{}).
Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive).
Updates(map[string]any{
"status": packetStatusFinished,
"update_time": now,
}).Error
}); err != nil {
return nil, err
}
claim.WalletStatus = walletStatusSuccess
claim.UpdateTime = now
}
return s.buildClaimResponse(ctx, claim)
}
func validateClaimable(packet model.VoiceRoomRedPacket, userRegion string) error {
if packet.Status != packetStatusActive {
if packet.Status == packetStatusFinished {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished")
}
if packet.Status == packetStatusExpired || packet.Status == packetStatusRefunded || packet.Status == packetStatusRefundFailed {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired")
}
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_unavailable", "red packet is unavailable")
}
now := time.Now()
if now.Before(packet.ClaimStartTime) {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_not_started", "red packet is not started")
}
if !now.Before(packet.ExpireTime) {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired")
}
if !strings.EqualFold(strings.TrimSpace(packet.SenderRegionCode), strings.TrimSpace(userRegion)) {
return NewAppError(http.StatusForbidden, "voice_room_region_not_match", "user region does not match")
}
if packet.RemainCount <= 0 || packet.RemainAmount <= 0 {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished")
}
return nil
}
func validateClaimableForRetry(packet model.VoiceRoomRedPacket) error {
if packet.Status == packetStatusExpired || packet.Status == packetStatusRefunded || packet.Status == packetStatusRefundFailed {
return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired")
}
return nil
}
func (s *Service) loadPacketByNo(ctx context.Context, packetNo string) (*model.VoiceRoomRedPacket, error) {
var row model.VoiceRoomRedPacket
err := s.db.WithContext(ctx).Where("packet_no = ?", packetNo).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *Service) loadUserClaim(ctx context.Context, packetID, userID int64) (*model.VoiceRoomRedPacketClaim, error) {
return loadUserClaimTx(s.db.WithContext(ctx), packetID, userID)
}
func loadUserClaimTx(tx *gorm.DB, packetID, userID int64) (*model.VoiceRoomRedPacketClaim, error) {
var row model.VoiceRoomRedPacketClaim
err := tx.Where("packet_id = ? AND user_id = ?", packetID, userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *Service) buildClaimResponse(ctx context.Context, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) {
packet, err := s.loadPacketByNo(ctx, claim.PacketNo)
if err != nil {
return nil, err
}
if packet == nil {
return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found")
}
return &ClaimResponse{
PacketNo: claim.PacketNo,
Amount: claim.Amount,
RemainCount: packet.RemainCount,
TotalCount: packet.TotalCount,
ClaimTime: formatTime(claim.ClaimTime),
Status: claim.WalletStatus,
}, nil
}