2026-05-25 23:20:32 +08:00

441 lines
14 KiB
Go

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 != "<nil>" {
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]
}