2026-05-22 14:22:27 +08:00

289 lines
11 KiB
Go

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
}
s.publishHighWinRegionBroadcasts(ctx, req, results, balanceAfter)
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
}