459 lines
13 KiB
Go
459 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"chatapp3-golang/internal/config"
|
|
"chatapp3-golang/internal/integration"
|
|
"chatapp3-golang/internal/model"
|
|
"chatapp3-golang/internal/repo"
|
|
"chatapp3-golang/internal/util"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
registerRewardStatusPending = "PENDING"
|
|
registerRewardStatusSuccess = "SUCCESS"
|
|
registerRewardStatusSkipped = "SKIPPED"
|
|
registerRewardStatusFailed = "FAILED"
|
|
registerRewardGoldOrigin = "REGISTER_REWARDS"
|
|
registerRewardGoldDesc = "Register reward"
|
|
registerRewardPropsOrigin = "ACTIVITY_REWARD"
|
|
)
|
|
|
|
type registerRewardEvent struct {
|
|
EventID string
|
|
SysOrigin string
|
|
UserID int64
|
|
CountryID int64
|
|
}
|
|
|
|
type registerRewardResolvedConfig struct {
|
|
GoldAmount int64
|
|
RewardGroupID *int64
|
|
}
|
|
|
|
type RegisterRewardService struct {
|
|
cfg config.Config
|
|
repo *repo.Repository
|
|
java *integration.Client
|
|
}
|
|
|
|
func NewRegisterRewardService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *RegisterRewardService {
|
|
return &RegisterRewardService{
|
|
cfg: cfg,
|
|
repo: repository,
|
|
java: javaClient,
|
|
}
|
|
}
|
|
|
|
func (s *RegisterRewardService) Start(ctx context.Context) error {
|
|
if strings.TrimSpace(s.cfg.RegisterRewardStreamKey) == "" {
|
|
log.Printf("register reward consumer disabled: stream key is blank")
|
|
return nil
|
|
}
|
|
if err := s.ensureConsumerGroup(ctx); err != nil {
|
|
return err
|
|
}
|
|
log.Printf(
|
|
"register reward consumer started. stream=%s group=%s consumer=%s",
|
|
s.cfg.RegisterRewardStreamKey,
|
|
s.cfg.RegisterRewardConsumerGroup,
|
|
s.cfg.RegisterRewardConsumerName,
|
|
)
|
|
go s.consumeLoop(ctx)
|
|
return nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) consumeLoop(ctx context.Context) {
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
reclaimed, err := s.reclaimPending(ctx)
|
|
if err != nil {
|
|
s.handleLoopError(ctx, "reclaim register reward messages", err)
|
|
continue
|
|
}
|
|
if reclaimed > 0 {
|
|
continue
|
|
}
|
|
|
|
err = s.readNewMessages(ctx)
|
|
if err == nil || errors.Is(err, redis.Nil) {
|
|
continue
|
|
}
|
|
s.handleLoopError(ctx, "read register reward messages", err)
|
|
}
|
|
}
|
|
|
|
func (s *RegisterRewardService) handleLoopError(ctx context.Context, action string, err error) {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if isRegisterRewardNoGroupErr(err) {
|
|
if ensureErr := s.ensureConsumerGroup(ctx); ensureErr != nil {
|
|
log.Printf("%s failed and recreate consumer group failed: %v", action, ensureErr)
|
|
sleepWithContext(ctx, time.Second)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
log.Printf("%s failed: %v", action, err)
|
|
sleepWithContext(ctx, time.Second)
|
|
}
|
|
|
|
func (s *RegisterRewardService) ensureConsumerGroup(ctx context.Context) error {
|
|
err := s.repo.Redis.XGroupCreateMkStream(
|
|
ctx,
|
|
s.cfg.RegisterRewardStreamKey,
|
|
s.cfg.RegisterRewardConsumerGroup,
|
|
"0",
|
|
).Err()
|
|
if err != nil && !strings.Contains(strings.ToUpper(err.Error()), "BUSYGROUP") {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) reclaimPending(ctx context.Context) (int, error) {
|
|
messages, _, err := s.repo.Redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{
|
|
Stream: s.cfg.RegisterRewardStreamKey,
|
|
Group: s.cfg.RegisterRewardConsumerGroup,
|
|
Consumer: s.cfg.RegisterRewardConsumerName,
|
|
MinIdle: s.cfg.RegisterRewardMinIdle,
|
|
Start: "0-0",
|
|
Count: s.cfg.RegisterRewardBatchSize,
|
|
}).Result()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(messages) == 0 {
|
|
return 0, nil
|
|
}
|
|
return len(messages), s.processMessages(ctx, messages)
|
|
}
|
|
|
|
func (s *RegisterRewardService) readNewMessages(ctx context.Context) error {
|
|
streams, err := s.repo.Redis.XReadGroup(ctx, &redis.XReadGroupArgs{
|
|
Group: s.cfg.RegisterRewardConsumerGroup,
|
|
Consumer: s.cfg.RegisterRewardConsumerName,
|
|
Streams: []string{s.cfg.RegisterRewardStreamKey, ">"},
|
|
Count: s.cfg.RegisterRewardBatchSize,
|
|
Block: s.cfg.RegisterRewardBlock,
|
|
}).Result()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, stream := range streams {
|
|
if err := s.processMessages(ctx, stream.Messages); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) processMessages(ctx context.Context, messages []redis.XMessage) error {
|
|
for _, message := range messages {
|
|
ack, err := s.processMessage(ctx, message)
|
|
if ack {
|
|
if ackErr := s.repo.Redis.XAck(
|
|
context.Background(),
|
|
s.cfg.RegisterRewardStreamKey,
|
|
s.cfg.RegisterRewardConsumerGroup,
|
|
message.ID,
|
|
).Err(); ackErr != nil {
|
|
log.Printf("ack register reward message failed. streamId=%s err=%v", message.ID, ackErr)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) processMessage(ctx context.Context, message redis.XMessage) (bool, error) {
|
|
event, err := parseRegisterRewardEvent(message)
|
|
if err != nil {
|
|
log.Printf("drop malformed register reward message. streamId=%s err=%v values=%v", message.ID, err, message.Values)
|
|
return true, nil
|
|
}
|
|
|
|
logRecord, terminal, err := s.getOrCreateLog(ctx, event, message.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if terminal {
|
|
return true, nil
|
|
}
|
|
|
|
resolvedConfig, ack, err := s.resolveRewardConfig(ctx, logRecord, event)
|
|
if err != nil || ack {
|
|
return ack, err
|
|
}
|
|
|
|
if err := s.dispatchGold(ctx, event, resolvedConfig); err != nil {
|
|
_ = s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusFailed, err.Error(), resolvedConfig)
|
|
return false, err
|
|
}
|
|
if err := s.dispatchProps(ctx, logRecord.ID, event, resolvedConfig); err != nil {
|
|
_ = s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusFailed, err.Error(), resolvedConfig)
|
|
return false, err
|
|
}
|
|
if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusSuccess, "", resolvedConfig); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) resolveRewardConfig(ctx context.Context, logRecord *model.ResidentRegisterRewardLog, event registerRewardEvent) (registerRewardResolvedConfig, bool, error) {
|
|
if logRecord.Status == registerRewardStatusFailed && (logRecord.GoldAmount > 0 || logRecord.RewardGroupID != nil) {
|
|
return registerRewardResolvedConfig{
|
|
GoldAmount: logRecord.GoldAmount,
|
|
RewardGroupID: logRecord.RewardGroupID,
|
|
}, false, nil
|
|
}
|
|
|
|
cfg, err := s.loadRegisterRewardConfig(ctx, event.SysOrigin)
|
|
if err != nil {
|
|
return registerRewardResolvedConfig{}, false, err
|
|
}
|
|
if cfg == nil || !cfg.Enabled {
|
|
err = s.updateLogStatus(
|
|
ctx,
|
|
logRecord.ID,
|
|
registerRewardStatusSkipped,
|
|
"register reward config missing or disabled",
|
|
registerRewardResolvedConfig{},
|
|
)
|
|
return registerRewardResolvedConfig{}, err == nil, err
|
|
}
|
|
|
|
resolved := registerRewardResolvedConfig{
|
|
GoldAmount: cfg.GoldAmount,
|
|
RewardGroupID: cfg.RewardGroupID,
|
|
}
|
|
if resolved.GoldAmount <= 0 && resolved.RewardGroupID == nil {
|
|
err = s.updateLogStatus(
|
|
ctx,
|
|
logRecord.ID,
|
|
registerRewardStatusSkipped,
|
|
"register reward config has no reward payload",
|
|
resolved,
|
|
)
|
|
return registerRewardResolvedConfig{}, err == nil, err
|
|
}
|
|
if err := s.updateLogStatus(ctx, logRecord.ID, registerRewardStatusPending, "", resolved); err != nil {
|
|
return registerRewardResolvedConfig{}, false, err
|
|
}
|
|
return resolved, false, nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) loadRegisterRewardConfig(ctx context.Context, sysOrigin string) (*model.ResidentRegisterRewardConfig, error) {
|
|
var cfg model.ResidentRegisterRewardConfig
|
|
err := s.repo.DB.WithContext(ctx).
|
|
Where("sys_origin = ?", strings.ToUpper(strings.TrimSpace(sysOrigin))).
|
|
First(&cfg).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) dispatchGold(ctx context.Context, event registerRewardEvent, resolved registerRewardResolvedConfig) error {
|
|
if resolved.GoldAmount <= 0 {
|
|
return nil
|
|
}
|
|
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
|
ReceiptType: "INCOME",
|
|
UserID: event.UserID,
|
|
SysOrigin: event.SysOrigin,
|
|
EventID: event.EventID + ":gold",
|
|
Remark: registerRewardGoldDesc,
|
|
Amount: integration.NewPennyAmountPayloadFromDollar(resolved.GoldAmount),
|
|
CloseDelayAsset: false,
|
|
OpUserType: "APP",
|
|
CustomizeOrigin: registerRewardGoldOrigin,
|
|
CustomizeOriginDesc: registerRewardGoldDesc,
|
|
})
|
|
}
|
|
|
|
func (s *RegisterRewardService) dispatchProps(ctx context.Context, logID int64, event registerRewardEvent, resolved registerRewardResolvedConfig) error {
|
|
if resolved.RewardGroupID == nil {
|
|
return nil
|
|
}
|
|
return s.java.GrantProps(ctx, integration.GrantPropsRequest{
|
|
TrackID: logID,
|
|
AcceptUserID: event.UserID,
|
|
SysOrigin: event.SysOrigin,
|
|
Origin: registerRewardPropsOrigin,
|
|
SourceGroupID: *resolved.RewardGroupID,
|
|
})
|
|
}
|
|
|
|
func (s *RegisterRewardService) getOrCreateLog(ctx context.Context, event registerRewardEvent, streamMessageID string) (*model.ResidentRegisterRewardLog, bool, error) {
|
|
var record model.ResidentRegisterRewardLog
|
|
err := s.repo.DB.WithContext(ctx).
|
|
Where("event_id = ?", event.EventID).
|
|
First(&record).Error
|
|
if err == nil {
|
|
if record.StreamMessageID != streamMessageID {
|
|
if updateErr := s.repo.DB.WithContext(ctx).
|
|
Model(&model.ResidentRegisterRewardLog{}).
|
|
Where("id = ?", record.ID).
|
|
Updates(map[string]any{
|
|
"stream_message_id": streamMessageID,
|
|
"update_time": time.Now(),
|
|
}).Error; updateErr == nil {
|
|
record.StreamMessageID = streamMessageID
|
|
}
|
|
}
|
|
return &record, isRegisterRewardTerminalStatus(record.Status), nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, false, err
|
|
}
|
|
|
|
id, idErr := util.NextID()
|
|
if idErr != nil {
|
|
return nil, false, idErr
|
|
}
|
|
now := time.Now()
|
|
record = model.ResidentRegisterRewardLog{
|
|
ID: id,
|
|
EventID: event.EventID,
|
|
StreamMessageID: streamMessageID,
|
|
SysOrigin: event.SysOrigin,
|
|
UserID: event.UserID,
|
|
Status: registerRewardStatusPending,
|
|
CreateTime: now,
|
|
UpdateTime: now,
|
|
}
|
|
if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
|
return s.getOrCreateLog(ctx, event, streamMessageID)
|
|
}
|
|
return nil, false, err
|
|
}
|
|
return &record, false, nil
|
|
}
|
|
|
|
func (s *RegisterRewardService) updateLogStatus(ctx context.Context, logID int64, status, errorMessage string, resolved registerRewardResolvedConfig) error {
|
|
return s.repo.DB.WithContext(ctx).
|
|
Model(&model.ResidentRegisterRewardLog{}).
|
|
Where("id = ?", logID).
|
|
Updates(map[string]any{
|
|
"status": status,
|
|
"error_message": truncateRegisterRewardMessage(errorMessage),
|
|
"gold_amount": resolved.GoldAmount,
|
|
"reward_group_id": resolved.RewardGroupID,
|
|
"update_time": time.Now(),
|
|
}).Error
|
|
}
|
|
|
|
func parseRegisterRewardEvent(message redis.XMessage) (registerRewardEvent, error) {
|
|
eventID := strings.TrimSpace(toRegisterRewardString(message.Values["eventId"]))
|
|
if eventID == "" {
|
|
return registerRewardEvent{}, fmt.Errorf("eventId is required")
|
|
}
|
|
sysOrigin := strings.ToUpper(strings.TrimSpace(toRegisterRewardString(message.Values["sysOrigin"])))
|
|
if sysOrigin == "" {
|
|
return registerRewardEvent{}, fmt.Errorf("sysOrigin is required")
|
|
}
|
|
userID, err := toRegisterRewardInt64(message.Values["userId"])
|
|
if err != nil {
|
|
return registerRewardEvent{}, fmt.Errorf("invalid userId: %w", err)
|
|
}
|
|
if userID <= 0 {
|
|
return registerRewardEvent{}, fmt.Errorf("invalid userId")
|
|
}
|
|
countryID, _ := toRegisterRewardInt64(message.Values["countryId"])
|
|
return registerRewardEvent{
|
|
EventID: eventID,
|
|
SysOrigin: sysOrigin,
|
|
UserID: userID,
|
|
CountryID: countryID,
|
|
}, nil
|
|
}
|
|
|
|
func toRegisterRewardString(value any) string {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return typed
|
|
case []byte:
|
|
return string(typed)
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|
|
|
|
func toRegisterRewardInt64(value any) (int64, error) {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return 0, fmt.Errorf("value is nil")
|
|
case int64:
|
|
return typed, nil
|
|
case int32:
|
|
return int64(typed), nil
|
|
case int:
|
|
return int64(typed), nil
|
|
case uint64:
|
|
return int64(typed), nil
|
|
case float64:
|
|
return int64(typed), nil
|
|
case string:
|
|
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
|
case []byte:
|
|
return strconv.ParseInt(strings.TrimSpace(string(typed)), 10, 64)
|
|
default:
|
|
return strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64)
|
|
}
|
|
}
|
|
|
|
func isRegisterRewardTerminalStatus(status string) bool {
|
|
switch strings.ToUpper(strings.TrimSpace(status)) {
|
|
case registerRewardStatusSuccess, registerRewardStatusSkipped:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isRegisterRewardNoGroupErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToUpper(err.Error()), "NOGROUP")
|
|
}
|
|
|
|
func truncateRegisterRewardMessage(message string) string {
|
|
message = strings.TrimSpace(message)
|
|
if len(message) <= 255 {
|
|
return message
|
|
}
|
|
return message[:255]
|
|
}
|
|
|
|
func sleepWithContext(ctx context.Context, duration time.Duration) {
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-timer.C:
|
|
}
|
|
}
|