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