package luckygift import ( "bytes" "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Draw 执行幸运礼物抽奖,流程包括幂等检查、加锁、三方抽奖、明细落库和钱包返奖。 func (s *LuckyGiftService) Draw(ctx context.Context, req LuckyGiftDrawRequest) (*LuckyGiftDrawResponse, error) { req = normalizeLuckyGiftDrawRequest(req) if err := s.validateDrawRequest(req); err != nil { return nil, err } if !s.cfg.LuckyGift.Enabled { return nil, NewAppError(http.StatusServiceUnavailable, "lucky_gift_disabled", "lucky gift service is disabled") } account, err := s.resolveProviderConfig(ctx, req) if err != nil { return nil, err } if cached, err := s.loadSuccessfulResponse(ctx, req.BusinessID); err != nil { return nil, err } else if cached != nil { return cached, nil } // 使用业务 ID 做分布式锁,避免同一请求并发打到三方和钱包。 lockKey := "luckygift:draw:" + req.BusinessID lockTTL := s.lockTTL() lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", lockTTL).Result() if err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "lucky_gift_lock_failed", err.Error()) } if !lockOK { if cached, err := s.loadSuccessfulResponse(ctx, req.BusinessID); err != nil { return nil, err } else if cached != nil { return cached, nil } return nil, NewAppError(http.StatusConflict, "lucky_gift_in_progress", "lucky gift draw is in progress") } defer s.repo.Redis.Del(context.Background(), lockKey) if cached, err := s.loadSuccessfulResponse(ctx, req.BusinessID); err != nil { return nil, err } else if cached != nil { return cached, nil } // 先确保主记录存在,这样即使流程中断也能根据状态恢复。 requestJSON := utils.MustJSONString(req, "{}") walletEventID := luckyGiftWalletEventID(req.SysOrigin, req.BusinessID) record, err := s.ensureRequestRecord(ctx, req, requestJSON, walletEventID) if err != nil { return nil, err } results, providerCode, rewardTotal, rawProviderResponse, err := s.resolveProviderResults(ctx, account, req, record, walletEventID) if err != nil { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) return nil, err } if err := s.upsertOrderRecords(ctx, req.BusinessID, results); err != nil { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) return nil, err } if err := s.publishRewardEvent(ctx, req, results, rewardTotal); err != nil { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) return nil, err } // 三方有中奖收益时,再调用钱包给送礼人做金币返奖,并用 walletEventID 保证幂等。 if rewardTotal > 0 { exists, err := s.java.ExistsGoldEvent(ctx, walletEventID) if err != nil { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_exists_failed", err.Error()) } if !exists { if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: "INCOME", UserID: req.SendUserID, SysOrigin: req.SysOrigin, EventID: walletEventID, Remark: fmt.Sprintf("businessId=%s giftId=%d", req.BusinessID, req.GiftID), Amount: integration.NewPennyAmountPayloadFromDollar(rewardTotal), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: luckyGiftWalletOrigin, CustomizeOriginDesc: luckyGiftWalletOrigin, }); err != nil { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_change_failed", err.Error()) } } } // 最终余额以钱包查询结果为准,避免依赖本地推算。 balanceMap, err := s.java.MapGoldBalance(ctx, []int64{req.SendUserID}) if err != nil { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, err.Error()) return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_balance_failed", err.Error()) } balanceAfter, ok := balanceMap[req.SendUserID] if !ok { _ = s.markRequestFailed(ctx, req.BusinessID, providerCode, rawProviderResponse, "wallet balance missing") return nil, NewAppError(http.StatusBadGateway, "lucky_gift_wallet_balance_missing", "wallet balance missing") } resp := &LuckyGiftDrawResponse{ BusinessID: req.BusinessID, ProviderCode: providerCode, RewardTotal: rewardTotal, BalanceAfter: balanceAfter, Results: results, } if err := s.updateRequestRecord(ctx, req.BusinessID, map[string]any{ "provider_code": providerCode, "reward_total": rewardTotal, "balance_after": balanceAfter, "wallet_event_id": walletEventID, "status": luckyGiftDrawStatusSuccess, "error_message": "", "provider_response_json": rawProviderResponse, "response_json": utils.MustJSONString(resp, "{}"), "update_time": time.Now(), }); err != nil { return nil, err } return resp, nil } // resolveProviderResults 优先复用已缓存的三方响应,否则真正调用三方抽奖接口。 func (s *LuckyGiftService) resolveProviderResults( ctx context.Context, account config.LuckyGiftProviderConfig, req LuckyGiftDrawRequest, record *model.LuckyGiftDrawRequestRecord, walletEventID string, ) ([]LuckyGiftDrawResult, int, int64, string, error) { if record != nil && strings.TrimSpace(record.ProviderResponseJSON) != "" { providerCode, results, rewardTotal, err := parseLuckyGiftProviderResponse(record.ProviderResponseJSON, req.Orders) if err != nil { return nil, 0, 0, record.ProviderResponseJSON, NewAppError( http.StatusInternalServerError, "lucky_gift_cached_response_invalid", err.Error(), ) } return results, providerCode, rewardTotal, record.ProviderResponseJSON, nil } rawResponse, providerCode, results, rewardTotal, err := s.callLuckyGiftProvider(ctx, account, req) if err != nil { return nil, providerCode, 0, rawResponse, err } if err := s.updateRequestRecord(ctx, req.BusinessID, map[string]any{ "provider_code": providerCode, "reward_total": rewardTotal, "wallet_event_id": walletEventID, "status": luckyGiftDrawStatusProviderSuccess, "error_message": "", "provider_response_json": rawResponse, "update_time": time.Now(), }); err != nil { return nil, 0, 0, rawResponse, err } return results, providerCode, rewardTotal, rawResponse, nil } // callLuckyGiftProvider 组装请求、签名并调用幸运礼物三方接口。 func (s *LuckyGiftService) callLuckyGiftProvider( ctx context.Context, account config.LuckyGiftProviderConfig, req LuckyGiftDrawRequest, ) (string, int, []LuckyGiftDrawResult, int64, error) { endpoint, err := resolveLuckyGiftProviderEndpoint(account.URL) if err != nil { return "", 0, nil, 0, NewAppError(http.StatusInternalServerError, "lucky_gift_provider_endpoint_invalid", err.Error()) } payload, err := buildLuckyGiftProviderPayload(account, req) if err != nil { return "", 0, nil, 0, NewAppError(http.StatusInternalServerError, "lucky_gift_request_invalid", err.Error()) } body, err := json.Marshal(payload) if err != nil { return "", 0, nil, 0, err } timeoutCtx, cancel := context.WithTimeout(ctx, s.httpClient.Timeout) defer cancel() httpReq, err := http.NewRequestWithContext(timeoutCtx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return "", 0, nil, 0, err } httpReq.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(httpReq) if err != nil { return "", 0, nil, 0, NewAppError(http.StatusBadGateway, "lucky_gift_provider_request_failed", err.Error()) } defer resp.Body.Close() rawBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return "", 0, nil, 0, NewAppError(http.StatusBadGateway, "lucky_gift_provider_read_failed", readErr.Error()) } rawResponse := strings.TrimSpace(string(rawBody)) if resp.StatusCode >= http.StatusBadRequest { return rawResponse, 0, nil, 0, NewAppError( http.StatusBadGateway, "lucky_gift_provider_http_error", fmt.Sprintf("provider status=%d body=%s", resp.StatusCode, rawResponse), ) } providerCode, results, rewardTotal, err := parseLuckyGiftProviderResponse(rawResponse, req.Orders) if err != nil { return rawResponse, providerCode, nil, 0, NewAppError(http.StatusBadGateway, "lucky_gift_provider_invalid_response", err.Error()) } return rawResponse, providerCode, results, rewardTotal, nil } // validateDrawRequest 校验抽奖请求必要字段和子单合法性。 func (s *LuckyGiftService) validateDrawRequest(req LuckyGiftDrawRequest) error { switch { case strings.TrimSpace(req.BusinessID) == "": return NewAppError(http.StatusBadRequest, "missing_business_id", "businessId is required") case strings.TrimSpace(req.SysOrigin) == "": return NewAppError(http.StatusBadRequest, "missing_sys_origin", "sysOrigin is required") case req.StandardID <= 0: return NewAppError(http.StatusBadRequest, "missing_standard_id", "standardId is required") case req.RoomID <= 0: return NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") case req.SendUserID <= 0: return NewAppError(http.StatusBadRequest, "missing_send_user_id", "sendUserId is required") case req.GiftID <= 0: return NewAppError(http.StatusBadRequest, "missing_gift_id", "giftId is required") case req.GiftPrice < 0: return NewAppError(http.StatusBadRequest, "invalid_gift_price", "giftPrice must be greater than or equal to 0") case req.GiftNum <= 0: return NewAppError(http.StatusBadRequest, "invalid_gift_num", "giftNum must be greater than 0") case len(req.Orders) == 0: return NewAppError(http.StatusBadRequest, "missing_orders", "orders are required") } seen := make(map[string]struct{}, len(req.Orders)) for _, order := range req.Orders { if order.ID == "" { return NewAppError(http.StatusBadRequest, "missing_order_id", "order id is required") } if order.AcceptUserID <= 0 { return NewAppError(http.StatusBadRequest, "missing_accept_user_id", "acceptUserId is required") } if _, exists := seen[order.ID]; exists { return NewAppError(http.StatusBadRequest, "duplicate_order_id", "order id must be unique") } seen[order.ID] = struct{}{} } return nil } // normalizeLuckyGiftDrawRequest 清洗入参中的字符串字段。 func normalizeLuckyGiftDrawRequest(req LuckyGiftDrawRequest) LuckyGiftDrawRequest { req.BusinessID = strings.TrimSpace(req.BusinessID) req.ConsumeAssetRecordID = strings.TrimSpace(req.ConsumeAssetRecordID) req.SysOrigin = strings.ToUpper(strings.TrimSpace(req.SysOrigin)) for index := range req.Orders { req.Orders[index].ID = strings.TrimSpace(req.Orders[index].ID) } return req }