package firstrechargereward import ( "context" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) func (s *Service) ProcessRechargePayload(ctx context.Context, payload string) (*ProcessRechargeResponse, error) { payload = strings.TrimSpace(payload) if payload == "" { return &ProcessRechargeResponse{Processed: false, Reason: "empty_payload"}, nil } event, skip, err := decodeRechargeEventPayload(payload, s.cfg.FirstRechargeReward.MQ.Tag) if err != nil { return nil, err } if skip { return &ProcessRechargeResponse{Processed: false, Reason: "tag_skipped"}, nil } return s.ProcessRechargeEvent(ctx, event) } func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent) (*ProcessRechargeResponse, error) { normalized, err := s.normalizeRechargeEvent(event) if err != nil { var appErr *AppError if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError { return &ProcessRechargeResponse{Processed: false, Reason: appErr.Code}, nil } return nil, err } if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) { return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil } if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) { return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil } if s.java == nil { return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable") } bundle, err := s.loadConfigBundle(ctx, normalized.SysOrigin) if err != nil { return nil, err } if bundle.Config == nil { return &ProcessRechargeResponse{Processed: false, Reason: "config_missing"}, nil } if !bundle.Config.Enabled { return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil } matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents) if !ok { return &ProcessRechargeResponse{Processed: false, Reason: "amount_not_reached"}, nil } record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched) if err != nil { return nil, err } if terminal { return &ProcessRechargeResponse{ Processed: true, RecordID: record.ID, Status: record.Status, Level: record.Level, }, nil } if err := s.dispatchRewardGroup(ctx, record); err != nil { _ = s.updateGrantRecordFailed(ctx, record.ID, err) return nil, err } if err := s.updateGrantRecordSuccess(ctx, record.ID); err != nil { return nil, err } record.Status = statusSuccess now := time.Now() record.SentAt = &now if err := s.pushRewardNotice(ctx, record); err != nil { log.Printf("push first recharge reward notice failed. recordId=%d userId=%d eventId=%s err=%v", record.ID, record.UserID, record.EventID, err) } _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) return &ProcessRechargeResponse{ Processed: true, RecordID: record.ID, Status: statusSuccess, Level: record.Level, }, nil } func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRechargeEvent, error) { sysOrigin := s.normalizeSysOrigin(event.SysOrigin) userID := event.UserID.Int64() if userID <= 0 { return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_user_id", "userId is required") } amountCents := event.AmountCents.Int64() if amountCents <= 0 { amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String()) } if amountCents <= 0 { return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_amount", "amountCents or amount is required") } payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform)) paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod)) appVersion := normalizeRechargeAppVersion(event) sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String()) eventID := strings.TrimSpace(event.EventID) if eventID == "" && sourceOrderID != "" { eventID = "RECHARGE_SUCCESS:" + firstNonEmpty(paymentMethod, payPlatform, "UNKNOWN") + ":" + sourceOrderID } if eventID == "" { return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_event_id", "eventId is required") } occurredAt := parseEventTime(event.OccurredAt.String()) if occurredAt.IsZero() { occurredAt = time.Now() } return normalizedRechargeEvent{ EventID: eventID, SysOrigin: sysOrigin, UserID: userID, AmountCents: amountCents, Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"), PayPlatform: payPlatform, PaymentMethod: paymentMethod, AppVersion: appVersion, SourceOrderID: sourceOrderID, OccurredAt: occurredAt, }, nil } func (s *Service) getOrCreateGrantRecord( ctx context.Context, event normalizedRechargeEvent, config *configSnapshot, level levelSnapshot, ) (*model.FirstRechargeRewardGrantRecord, bool, error) { var existing model.FirstRechargeRewardGrantRecord err := s.db.WithContext(ctx).Where("event_id = ?", event.EventID).First(&existing).Error if err == nil { return &existing, existing.Status == statusSuccess, nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, err } err = s.db.WithContext(ctx). Where("sys_origin = ? AND user_id = ?", event.SysOrigin, event.UserID). First(&existing).Error if err == nil { return &existing, existing.Status == statusSuccess, nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, err } id, err := utils.NextID() if err != nil { return nil, false, err } now := time.Now() record := model.FirstRechargeRewardGrantRecord{ ID: id, EventID: event.EventID, ConfigID: config.ID, LevelID: level.ID, SysOrigin: config.SysOrigin, UserID: event.UserID, RechargeAmountCents: event.AmountCents, RechargeThresholdCents: level.RechargeAmountCents, Level: level.Level, PayPlatform: event.PayPlatform, PaymentMethod: event.PaymentMethod, SourceOrderID: event.SourceOrderID, RewardGroupID: level.RewardGroupID, RewardGroupName: level.RewardGroupName, RewardGroupTrackID: id, Status: statusPending, RewardGroupStatus: statusPending, EventTime: event.OccurredAt, CreateTime: now, UpdateTime: now, } if profile, err := s.java.GetUserProfile(ctx, event.UserID); err == nil { record.Account = strings.TrimSpace(profile.Account) record.UserAvatar = strings.TrimSpace(profile.UserAvatar) record.UserNickname = strings.TrimSpace(profile.UserNickname) record.CountryCode = strings.TrimSpace(profile.CountryCode) record.CountryName = strings.TrimSpace(profile.CountryName) } if err := s.db.WithContext(ctx).Create(&record).Error; err != nil { if isDuplicateError(err) { if reload, ok, reloadErr := s.loadExistingGrantRecord(ctx, event); reloadErr != nil { return nil, false, reloadErr } else if ok { return &reload, reload.Status == statusSuccess, nil } } return nil, false, err } return &record, false, nil } func (s *Service) loadExistingGrantRecord(ctx context.Context, event normalizedRechargeEvent) (model.FirstRechargeRewardGrantRecord, bool, error) { var existing model.FirstRechargeRewardGrantRecord err := s.db.WithContext(ctx). Where("event_id = ? OR (sys_origin = ? AND user_id = ?)", event.EventID, event.SysOrigin, event.UserID). First(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { return model.FirstRechargeRewardGrantRecord{}, false, nil } if err != nil { return model.FirstRechargeRewardGrantRecord{}, false, err } return existing, true, nil } func (s *Service) dispatchRewardGroup(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error { if record.RewardGroupID <= 0 { return NewAppError(http.StatusBadRequest, "reward_group_missing", "reward group is missing") } trackID := record.RewardGroupTrackID if trackID <= 0 { trackID = record.ID } return s.java.SendActivityReward(ctx, integration.SendActivityRewardRequest{ TrackID: trackID, Origin: firstRechargeRewardOrigin, SysOrigin: record.SysOrigin, SourceGroupID: record.RewardGroupID, AcceptUserID: record.UserID, }) } func (s *Service) updateGrantRecordSuccess(ctx context.Context, recordID int64) error { now := time.Now() return s.db.WithContext(ctx). Model(&model.FirstRechargeRewardGrantRecord{}). Where("id = ?", recordID). Updates(map[string]any{ "status": statusSuccess, "reward_group_status": statusSuccess, "last_error": "", "sent_at": now, "update_time": now, }).Error } func (s *Service) updateGrantRecordFailed(ctx context.Context, recordID int64, cause error) error { return s.db.WithContext(ctx). Model(&model.FirstRechargeRewardGrantRecord{}). Where("id = ?", recordID). Updates(map[string]any{ "status": statusFailed, "reward_group_status": statusFailed, "retry_count": gorm.Expr("retry_count + 1"), "last_error": truncateMessage(cause.Error(), 1024), "update_time": time.Now(), }).Error } func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRechargeRewardGrantRecord) error { expand := map[string]any{ "scene": "FIRST_RECHARGE_REWARD", "showRewardPopup": true, "userId": record.UserID, "sysOrigin": record.SysOrigin, "eventId": record.EventID, "recordId": fmt.Sprintf("%d", record.ID), "level": record.Level, "rechargeAmount": formatAmountCents(record.RechargeAmountCents), "rechargeAmountCents": record.RechargeAmountCents, "rewardGroupId": fmt.Sprintf("%d", record.RewardGroupID), "rewardGroupName": record.RewardGroupName, "payPlatform": record.PayPlatform, "paymentMethod": record.PaymentMethod, "noticeType": firstRechargeRewardNoticeType, } return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{ ToAccounts: []int64{record.UserID}, NoticeType: firstRechargeRewardNoticeType, Title: firstRechargeRewardNoticeTitle, Content: firstRechargeRewardNoticeContent, Expand: expand, }) } func decodeRechargeEventPayload(payload string, expectedTag string) (RechargeEvent, bool, error) { var event RechargeEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { return event, false, err } if strings.TrimSpace(event.EventID) != "" || event.UserID.Int64() > 0 { return event, false, nil } var envelope rechargeMessageEnvelope if err := json.Unmarshal([]byte(payload), &envelope); err != nil { return RechargeEvent{}, false, err } if shouldSkipEnvelope(envelope.Tag, expectedTag) { return RechargeEvent{}, true, nil } bodyPayload, ok, err := envelope.bodyPayload() if err != nil || !ok { return RechargeEvent{}, false, err } if err := json.Unmarshal([]byte(bodyPayload), &event); err != nil { return RechargeEvent{}, false, err } return event, false, nil } type rechargeMessageEnvelope struct { Tag string `json:"tag"` Body json.RawMessage `json:"body"` } func (e rechargeMessageEnvelope) bodyPayload() (string, bool, error) { raw := strings.TrimSpace(string(e.Body)) if raw == "" || raw == "null" { return "", false, nil } if strings.HasPrefix(raw, "\"") { var body string if err := json.Unmarshal(e.Body, &body); err != nil { return "", false, err } body = strings.TrimSpace(body) return body, body != "", nil } return raw, true, nil } func shouldSkipEnvelope(actualTag, expectedTag string) bool { actualTag = strings.TrimSpace(actualTag) expectedTag = strings.TrimSpace(expectedTag) if actualTag == "" || expectedTag == "" || expectedTag == "*" { return false } return actualTag != expectedTag } func isAcceptedPaymentMethod(paymentMethod string, _ string) bool { paymentMethod = strings.ToUpper(strings.TrimSpace(paymentMethod)) return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty } func normalizeRechargeAppVersion(event RechargeEvent) string { return firstNonEmpty( event.AppVersion.String(), event.ClientVersion.String(), event.Version.String(), event.ReqVersion.String(), rechargePayloadString(event.Payload, "appVersion", "clientVersion", "version", "reqVersion"), ) } func rechargePayloadString(payload json.RawMessage, keys ...string) string { if len(payload) == 0 { return "" } var values map[string]any if err := json.Unmarshal(payload, &values); err != nil { return "" } for _, key := range keys { if value, ok := values[key]; ok { if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "" { return text } } } return "" } func firstPositiveAmountCents(values ...string) int64 { for _, value := range values { cents, err := parseAmountCents(value) if err == nil && cents > 0 { return cents } } return 0 } func parseEventTime(raw string) time.Time { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{} } if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { return t } if t, err := time.Parse("2006-01-02 15:04:05", raw); err == nil { return t } return time.Time{} } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func isDuplicateError(err error) bool { if err == nil { return false } text := strings.ToLower(err.Error()) return strings.Contains(text, "duplicate") || strings.Contains(text, "unique constraint") } func truncateMessage(message string, limit int) string { message = strings.TrimSpace(message) if limit <= 0 || len(message) <= limit { return message } return message[:limit] }