441 lines
14 KiB
Go
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]
|
|
}
|