diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 54906e6..4f2abf5 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -196,6 +196,11 @@ func (g *Gateways) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[in return g.Room.MapRoomProfiles(ctx, roomIDs) } +// MarkRoomTurnoverRewardCoinsSent 透传到房间流水奖励标记接口。 +func (g *Gateways) MarkRoomTurnoverRewardCoinsSent(ctx context.Context, id string) (bool, error) { + return g.Room.MarkRoomTurnoverRewardCoinsSent(ctx, id) +} + // GetTeamPolicyRelease 透传到团队政策网关。 func (g *Gateways) GetTeamPolicyRelease(ctx context.Context, query TeamPolicyReleaseQuery) (TeamPolicyRelease, error) { return g.Team.GetTeamPolicyRelease(ctx, query) @@ -445,6 +450,11 @@ func (g *RoomGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map return g.client.MapRoomProfiles(ctx, roomIDs) } +// MarkRoomTurnoverRewardCoinsSent 标记 Java 房间流水统计记录已经发奖。 +func (g *RoomGateway) MarkRoomTurnoverRewardCoinsSent(ctx context.Context, id string) (bool, error) { + return g.client.MarkRoomTurnoverRewardCoinsSent(ctx, id) +} + // TeamGateway 负责团队政策相关接口。 type TeamGateway struct { client *Client diff --git a/internal/integration/java.go b/internal/integration/java.go index 41d9af5..91499f3 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -1030,6 +1030,19 @@ func (c *Client) GetCurrentWeekRoomContribution(ctx context.Context, roomID int6 return resp.Body, nil } +func (c *Client) MarkRoomTurnoverRewardCoinsSent(ctx context.Context, id string) (bool, error) { + id = strings.TrimSpace(id) + if id == "" { + return false, fmt.Errorf("empty room contribution id") + } + endpoint := c.cfg.Java.OtherBaseURL + "/room-contribution-activity-count/client/rewardCoinsSent?id=" + url.QueryEscape(id) + var resp resultResponse[bool] + if err := c.postJSON(ctx, endpoint, nil, nil, &resp); err != nil { + return false, err + } + return resp.Body, nil +} + func (c *Client) GetRoomProfileByUserID(ctx context.Context, userID int64) (RoomProfile, error) { if userID <= 0 { return RoomProfile{}, nil diff --git a/internal/model/room_turnover_reward_models.go b/internal/model/room_turnover_reward_models.go index 761bcf2..a682cf1 100644 --- a/internal/model/room_turnover_reward_models.go +++ b/internal/model/room_turnover_reward_models.go @@ -4,12 +4,13 @@ import "time" // RoomTurnoverRewardConfig 保存房间流水奖励的系统级配置。 type RoomTurnoverRewardConfig struct { - ID int64 `gorm:"column:id;primaryKey"` - SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_room_turnover_reward_sys_origin"` - Enabled bool `gorm:"column:enabled;index:idx_room_turnover_reward_enabled"` - Timezone string `gorm:"column:timezone;size:64"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_room_turnover_reward_sys_origin"` + Enabled bool `gorm:"column:enabled;index:idx_room_turnover_reward_enabled"` + AutoRewardEnabled bool `gorm:"column:auto_reward_enabled"` + Timezone string `gorm:"column:timezone;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` } func (RoomTurnoverRewardConfig) TableName() string { return "room_turnover_reward_config" } @@ -48,6 +49,7 @@ type RoomTurnoverRewardRecord struct { TurnoverThreshold int64 `gorm:"column:turnover_threshold"` RewardGold int64 `gorm:"column:reward_gold"` TrackID string `gorm:"column:track_id;size:128;uniqueIndex:uk_room_turnover_reward_track"` + SourceID string `gorm:"column:source_id;size:128"` Status string `gorm:"column:status;size:32;index:idx_room_turnover_reward_record_status"` RetryCount int `gorm:"column:retry_count"` LastError string `gorm:"column:last_error;size:1024"` diff --git a/internal/router/room_turnover_reward_routes.go b/internal/router/room_turnover_reward_routes.go index 59fa5d0..62757df 100644 --- a/internal/router/room_turnover_reward_routes.go +++ b/internal/router/room_turnover_reward_routes.go @@ -1,6 +1,8 @@ package router import ( + "errors" + "io" "net/http" "strconv" "strings" @@ -35,6 +37,32 @@ func registerRoomTurnoverRewardRoutes(engine *gin.Engine, javaClient authGateway } writeOK(c, resp) }) + appGroup.GET("/reward/claim/status", func(c *gin.Context) { + resp, err := service.GetClaimStatus(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/reward/claim", func(c *gin.Context) { + var req roomturnoverreward.RoomTurnoverRewardClaimRequest + if c.Request.ContentLength != 0 { + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, roomturnoverreward.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + } else if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(c, roomturnoverreward.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.ClaimReward(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) group := engine.Group("/resident-activity/room-turnover-reward") group.Use(consoleAuthMiddleware(javaClient)) diff --git a/internal/service/roomturnoverreward/claim.go b/internal/service/roomturnoverreward/claim.go new file mode 100644 index 0000000..ee6cc1b --- /dev/null +++ b/internal/service/roomturnoverreward/claim.go @@ -0,0 +1,254 @@ +package roomturnoverreward + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GetClaimStatus 返回当前用户是否存在可手动领取的房间流水奖励。 +func (s *Service) GetClaimStatus(ctx context.Context, user common.AuthUser) (*RoomTurnoverRewardClaimStatusResponse, error) { + if user.UserID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "user is required") + } + sysOrigin := s.normalizeSysOrigin(user.SysOrigin) + resp := &RoomTurnoverRewardClaimStatusResponse{ + CanClaim: false, + Reason: "no_claimable_reward", + SysOrigin: sysOrigin, + AutoRewardEnabled: true, + } + + configRow, ok, err := s.loadConfigRow(ctx, sysOrigin) + if err != nil { + return nil, err + } + if !ok { + resp.Reason = "config_not_found" + return resp, nil + } + resp.AutoRewardEnabled = configRow.AutoRewardEnabled + if configRow.AutoRewardEnabled { + resp.Reason = "auto_reward_enabled" + return resp, nil + } + + record, ok, err := s.findClaimableRewardRecord(ctx, sysOrigin, user.UserID, 0) + if err != nil { + return nil, err + } + if !ok { + return resp, nil + } + view := toRecordView(record) + resp.CanClaim = true + resp.Reason = "available" + resp.Record = &view + return resp, nil +} + +// ClaimReward 手动领取一条已结算但未发放的房间流水奖励。 +func (s *Service) ClaimReward(ctx context.Context, user common.AuthUser, req RoomTurnoverRewardClaimRequest) (*RoomTurnoverRewardClaimResponse, error) { + if s.repo.Java == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable") + } + if user.UserID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "user is required") + } + + sysOrigin := s.normalizeSysOrigin(user.SysOrigin) + configRow, ok, err := s.loadConfigRow(ctx, sysOrigin) + if err != nil { + return nil, err + } + if !ok { + return nil, NewAppError(http.StatusNotFound, "room_turnover_reward_config_not_found", "config not found") + } + if configRow.AutoRewardEnabled { + return nil, NewAppError(http.StatusConflict, "manual_claim_disabled", "manual claim is disabled when auto reward is enabled") + } + + recordID := req.RecordID.Int64() + var saved model.RoomTurnoverRewardRecord + claimed := false + var dispatchErr error + + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + record, found, findErr := s.lockRewardRecord(ctx, tx, sysOrigin, user.UserID, recordID) + if findErr != nil { + return findErr + } + if !found { + return NewAppError(http.StatusNotFound, "no_claimable_reward", "no claimable reward") + } + if record.Status == roomTurnoverRewardStatusSuccess { + saved = record + return nil + } + if record.Status != roomTurnoverRewardStatusPending && record.Status != roomTurnoverRewardStatusFailed { + return NewAppError(http.StatusConflict, "reward_not_claimable", "reward is not claimable") + } + + dispatchErr = s.dispatchRewardRecord(ctx, &record) + now := time.Now() + if dispatchErr != nil { + retryCount := record.RetryCount + 1 + updates := map[string]any{ + "status": roomTurnoverRewardStatusFailed, + "retry_count": retryCount, + "last_error": truncateString(dispatchErr.Error(), 1024), + "update_time": now, + } + if err := tx.Model(&model.RoomTurnoverRewardRecord{}). + Where("id = ?", record.ID). + Updates(updates).Error; err != nil { + return err + } + record.Status = roomTurnoverRewardStatusFailed + record.RetryCount = retryCount + record.LastError = truncateString(dispatchErr.Error(), 1024) + record.UpdateTime = now + saved = record + return nil + } + + retryCount := record.RetryCount + if record.Status == roomTurnoverRewardStatusFailed { + retryCount++ + } + if err := tx.Model(&model.RoomTurnoverRewardRecord{}). + Where("id = ?", record.ID). + Updates(map[string]any{ + "status": roomTurnoverRewardStatusSuccess, + "retry_count": retryCount, + "last_error": "", + "sent_at": now, + "update_time": now, + }).Error; err != nil { + return err + } + record.Status = roomTurnoverRewardStatusSuccess + record.RetryCount = retryCount + record.LastError = "" + record.SentAt = &now + record.UpdateTime = now + saved = record + claimed = true + return nil + }) + if err != nil { + return nil, err + } + if dispatchErr != nil { + return nil, NewAppError(http.StatusBadGateway, "room_turnover_reward_claim_failed", dispatchErr.Error()) + } + + if claimed { + s.markSourceSent(ctx, saved) + } + return &RoomTurnoverRewardClaimResponse{ + Claimed: claimed, + Record: toRecordView(saved), + }, nil +} + +func (s *Service) dispatchRewardRecord(ctx context.Context, record *model.RoomTurnoverRewardRecord) error { + if record == nil { + return nil + } + return s.repo.Java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: "INCOME", + UserID: record.OwnerUserID, + SysOrigin: record.SysOrigin, + EventID: record.TrackID, + Origin: roomTurnoverRewardGoldOrigin, + Remark: fmt.Sprintf("room turnover reward %s room %d", record.CycleKey, record.RoomID), + Amount: integration.NewPennyAmountPayloadFromDollar(record.RewardGold), + CloseDelayAsset: false, + OpUserType: "APP", + }) +} + +func (s *Service) markSourceSent(ctx context.Context, record model.RoomTurnoverRewardRecord) { + sourceID := strings.TrimSpace(record.SourceID) + if sourceID == "" { + return + } + marked, err := s.repo.Java.MarkRoomTurnoverRewardCoinsSent(ctx, sourceID) + if err != nil { + log.Printf("mark room turnover reward source failed after manual claim. recordId=%d sourceId=%s err=%v", record.ID, sourceID, err) + return + } + if !marked { + log.Printf("room turnover reward source was not modified after manual claim. recordId=%d sourceId=%s", record.ID, sourceID) + } +} + +func (s *Service) loadConfigRow(ctx context.Context, sysOrigin string) (model.RoomTurnoverRewardConfig, bool, error) { + var row model.RoomTurnoverRewardConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.RoomTurnoverRewardConfig{}, false, nil + } + if err != nil { + return model.RoomTurnoverRewardConfig{}, false, err + } + return row, true, nil +} + +func (s *Service) findClaimableRewardRecord(ctx context.Context, sysOrigin string, userID int64, recordID int64) (model.RoomTurnoverRewardRecord, bool, error) { + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND owner_user_id = ?", s.normalizeSysOrigin(sysOrigin), userID). + Where("status IN ?", []string{roomTurnoverRewardStatusPending, roomTurnoverRewardStatusFailed}) + if recordID > 0 { + query = query.Where("id = ?", recordID) + } + var record model.RoomTurnoverRewardRecord + err := query. + Order("period_end_at desc"). + Order("id desc"). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.RoomTurnoverRewardRecord{}, false, nil + } + if err != nil { + return model.RoomTurnoverRewardRecord{}, false, err + } + return record, true, nil +} + +func (s *Service) lockRewardRecord(ctx context.Context, tx *gorm.DB, sysOrigin string, userID int64, recordID int64) (model.RoomTurnoverRewardRecord, bool, error) { + query := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND owner_user_id = ?", s.normalizeSysOrigin(sysOrigin), userID) + if recordID > 0 { + query = query.Where("id = ?", recordID) + } else { + query = query.Where("status IN ?", []string{roomTurnoverRewardStatusPending, roomTurnoverRewardStatusFailed}) + } + var record model.RoomTurnoverRewardRecord + err := query. + Order("period_end_at desc"). + Order("id desc"). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.RoomTurnoverRewardRecord{}, false, nil + } + if err != nil { + return model.RoomTurnoverRewardRecord{}, false, err + } + return record, true, nil +} diff --git a/internal/service/roomturnoverreward/claim_test.go b/internal/service/roomturnoverreward/claim_test.go new file mode 100644 index 0000000..e417427 --- /dev/null +++ b/internal/service/roomturnoverreward/claim_test.go @@ -0,0 +1,140 @@ +package roomturnoverreward + +import ( + "context" + "testing" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/model" +) + +func TestGetClaimStatusReturnsClaimableRecordWhenAutoRewardDisabled(t *testing.T) { + service, db := newTestService(t) + ctx := context.Background() + autoRewardEnabled := false + configResp, err := service.SaveConfig(ctx, SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + AutoRewardEnabled: &autoRewardEnabled, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + record := seedRoomTurnoverRewardRecord(t, db, configResp.ID, roomTurnoverRewardStatusPending) + + resp, err := service.GetClaimStatus(ctx, common.AuthUser{UserID: record.OwnerUserID, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetClaimStatus() error = %v", err) + } + if !resp.CanClaim || resp.Reason != "available" || resp.Record == nil || resp.Record.ID != record.ID { + t.Fatalf("claim status = %+v", resp) + } +} + +func TestGetClaimStatusDisablesButtonWhenAutoRewardEnabled(t *testing.T) { + service, db := newTestService(t) + ctx := context.Background() + configResp, err := service.SaveConfig(ctx, SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + record := seedRoomTurnoverRewardRecord(t, db, configResp.ID, roomTurnoverRewardStatusPending) + + resp, err := service.GetClaimStatus(ctx, common.AuthUser{UserID: record.OwnerUserID, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetClaimStatus() error = %v", err) + } + if resp.CanClaim || resp.Reason != "auto_reward_enabled" { + t.Fatalf("claim status = %+v", resp) + } +} + +func TestClaimRewardDispatchesGoldAndMarksRecordSuccess(t *testing.T) { + service, db := newTestService(t) + gateway := &stubRoomTurnoverRewardJavaGateway{} + service.repo.Java = gateway + ctx := context.Background() + autoRewardEnabled := false + configResp, err := service.SaveConfig(ctx, SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + AutoRewardEnabled: &autoRewardEnabled, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + record := seedRoomTurnoverRewardRecord(t, db, configResp.ID, roomTurnoverRewardStatusPending) + + resp, err := service.ClaimReward(ctx, common.AuthUser{UserID: record.OwnerUserID, SysOrigin: "LIKEI"}, RoomTurnoverRewardClaimRequest{ + RecordID: flexibleInt64(record.ID), + }) + if err != nil { + t.Fatalf("ClaimReward() error = %v", err) + } + if !resp.Claimed || resp.Record.Status != roomTurnoverRewardStatusSuccess || resp.Record.SentAt == "" { + t.Fatalf("claim response = %+v", resp) + } + if len(gateway.changed) != 1 { + t.Fatalf("wallet change count = %d, want 1", len(gateway.changed)) + } + if got := gateway.changed[0]; got.EventID != record.TrackID || got.UserID != record.OwnerUserID || got.Amount.DollarAmount != record.RewardGold { + t.Fatalf("wallet command = %+v", got) + } + if len(gateway.markedIDs) != 1 || gateway.markedIDs[0] != record.SourceID { + t.Fatalf("marked IDs = %+v", gateway.markedIDs) + } + + var saved model.RoomTurnoverRewardRecord + if err := db.Where("id = ?", record.ID).First(&saved).Error; err != nil { + t.Fatalf("load saved record: %v", err) + } + if saved.Status != roomTurnoverRewardStatusSuccess || saved.SentAt == nil { + t.Fatalf("saved record = %+v", saved) + } +} + +func seedRoomTurnoverRewardRecord(t *testing.T, db roomTurnoverRewardDB, configID int64, status string) model.RoomTurnoverRewardRecord { + t.Helper() + now := time.Now() + record := model.RoomTurnoverRewardRecord{ + ID: now.UnixNano(), + ConfigID: configID, + SysOrigin: "LIKEI", + CycleKey: "20260511", + PeriodStartAt: now.AddDate(0, 0, -7), + PeriodEndAt: now, + RoomID: 101, + RoomAccount: "R101", + RoomName: "Owner room", + OwnerUserID: 9001, + TurnoverAmount: 120000, + Level: 1, + TurnoverThreshold: 100000, + RewardGold: 2000, + TrackID: "room-turnover:LIKEI:20260511:101", + SourceID: "source-101", + Status: status, + CreateTime: now, + UpdateTime: now, + } + if err := db.WithContext(context.Background()).Create(&record).Error; err != nil { + t.Fatalf("seed reward record: %v", err) + } + return record +} diff --git a/internal/service/roomturnoverreward/config.go b/internal/service/roomturnoverreward/config.go index 27082aa..7d6cd3c 100644 --- a/internal/service/roomturnoverreward/config.go +++ b/internal/service/roomturnoverreward/config.go @@ -24,11 +24,12 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RoomTurnove First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return &RoomTurnoverRewardConfigResponse{ - Configured: false, - SysOrigin: sysOrigin, - Enabled: false, - Timezone: s.defaultTimezone(), - LevelConfigs: defaultLevelPayloads(), + Configured: false, + SysOrigin: sysOrigin, + Enabled: false, + AutoRewardEnabled: true, + Timezone: s.defaultTimezone(), + LevelConfigs: defaultLevelPayloads(), }, nil } if err != nil { @@ -40,13 +41,14 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RoomTurnove return nil, err } return &RoomTurnoverRewardConfigResponse{ - Configured: true, - ID: configRow.ID, - SysOrigin: configRow.SysOrigin, - Enabled: configRow.Enabled, - Timezone: configRow.Timezone, - UpdateTime: formatDateTime(configRow.UpdateTime), - LevelConfigs: buildLevelPayloads(levels), + Configured: true, + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + AutoRewardEnabled: configRow.AutoRewardEnabled, + Timezone: configRow.Timezone, + UpdateTime: formatDateTime(configRow.UpdateTime), + LevelConfigs: buildLevelPayloads(levels), }, nil } @@ -83,6 +85,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRoomTurnoverRewardConf if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() var configRow model.RoomTurnoverRewardConfig + creatingConfig := false if requestID > 0 { err := tx.Where("id = ?", requestID).First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -102,10 +105,12 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRoomTurnoverRewardConf return idErr } configRow = model.RoomTurnoverRewardConfig{ - ID: configID, - SysOrigin: sysOrigin, - CreateTime: now, + ID: configID, + SysOrigin: sysOrigin, + AutoRewardEnabled: true, + CreateTime: now, } + creatingConfig = true } else if err != nil { return err } @@ -113,6 +118,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRoomTurnoverRewardConf configRow.SysOrigin = sysOrigin configRow.Enabled = req.Enabled + configRow.AutoRewardEnabled = resolveAutoRewardEnabled(req.AutoRewardEnabled, configRow.AutoRewardEnabled, creatingConfig) configRow.Timezone = timezone configRow.UpdateTime = now if configRow.CreateTime.IsZero() { @@ -142,6 +148,16 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveRoomTurnoverRewardConf return s.GetConfig(ctx, sysOriginBySavedID(ctx, s.repo.DB, savedConfigID, sysOrigin)) } +func resolveAutoRewardEnabled(value *bool, current bool, creating bool) bool { + if value != nil { + return *value + } + if creating { + return true + } + return current +} + func (s *Service) normalizeLevelInputs(inputs []RoomTurnoverRewardLevelInput) ([]model.RoomTurnoverRewardLevel, error) { if len(inputs) == 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required") diff --git a/internal/service/roomturnoverreward/config_test.go b/internal/service/roomturnoverreward/config_test.go index 14340b1..9e6e676 100644 --- a/internal/service/roomturnoverreward/config_test.go +++ b/internal/service/roomturnoverreward/config_test.go @@ -43,6 +43,9 @@ func TestGetConfigReturnsDefaultLevelsWhenMissing(t *testing.T) { if resp.Configured || resp.Enabled { t.Fatalf("GetConfig() configured/enabled = %v/%v, want false/false", resp.Configured, resp.Enabled) } + if !resp.AutoRewardEnabled { + t.Fatalf("GetConfig() autoRewardEnabled = false, want true") + } if resp.SysOrigin != "LIKEI" || resp.Timezone != "Asia/Riyadh" { t.Fatalf("GetConfig() sys/timezone = %q/%q", resp.SysOrigin, resp.Timezone) } @@ -103,4 +106,28 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) { if got := second.LevelConfigs[0]; got.TurnoverThreshold != 200000 || got.RewardGold != 5000 { t.Fatalf("upserted level = %+v", got) } + if !second.AutoRewardEnabled { + t.Fatalf("autoRewardEnabled = false, want default true") + } +} + +func TestSaveConfigCanDisableAutoReward(t *testing.T) { + service, _ := newTestService(t) + autoRewardEnabled := false + + resp, err := service.SaveConfig(context.Background(), SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + AutoRewardEnabled: &autoRewardEnabled, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + if resp.AutoRewardEnabled { + t.Fatalf("autoRewardEnabled = true, want false") + } } diff --git a/internal/service/roomturnoverreward/current_rank_test.go b/internal/service/roomturnoverreward/current_rank_test.go index e15d2af..46a7986 100644 --- a/internal/service/roomturnoverreward/current_rank_test.go +++ b/internal/service/roomturnoverreward/current_rank_test.go @@ -10,6 +10,9 @@ import ( type stubRoomTurnoverRewardJavaGateway struct { contributions []integration.RoomContributionActivityCount current integration.RoomContributionActivityCount + changeErr error + changed []integration.GoldReceiptCommand + markedIDs []string userProfile integration.UserProfile userRoom integration.RoomProfile profiles map[int64]integration.RoomProfile @@ -35,9 +38,19 @@ func (s stubRoomTurnoverRewardJavaGateway) MapRoomProfiles(_ context.Context, _ return s.profiles, nil } +func (s *stubRoomTurnoverRewardJavaGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error { + s.changed = append(s.changed, cmd) + return s.changeErr +} + +func (s *stubRoomTurnoverRewardJavaGateway) MarkRoomTurnoverRewardCoinsSent(_ context.Context, id string) (bool, error) { + s.markedIDs = append(s.markedIDs, id) + return true, nil +} + func TestCurrentRoomRankingSortsAndMatchesHighestLevel(t *testing.T) { service, _ := newTestService(t) - service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + service.repo.Java = &stubRoomTurnoverRewardJavaGateway{ contributions: []integration.RoomContributionActivityCount{ {RoomID: integration.Int64Value(101), ContributionValue: integration.AmountValue(120000)}, {RoomID: integration.Int64Value(102), ContributionValue: integration.AmountValue(800000)}, diff --git a/internal/service/roomturnoverreward/home_test.go b/internal/service/roomturnoverreward/home_test.go index b92abf1..8cd7169 100644 --- a/internal/service/roomturnoverreward/home_test.go +++ b/internal/service/roomturnoverreward/home_test.go @@ -10,7 +10,7 @@ import ( func TestGetHomeReturnsUserRoomTurnoverAndExpectedReward(t *testing.T) { service, _ := newTestService(t) - service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + service.repo.Java = &stubRoomTurnoverRewardJavaGateway{ userProfile: integration.UserProfile{ ID: integration.Int64Value(9001), Account: "123456789", @@ -60,7 +60,7 @@ func TestGetHomeReturnsUserRoomTurnoverAndExpectedReward(t *testing.T) { func TestGetHomeReturnsZeroRewardWhenNoRoom(t *testing.T) { service, _ := newTestService(t) - service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + service.repo.Java = &stubRoomTurnoverRewardJavaGateway{ userProfile: integration.UserProfile{ ID: integration.Int64Value(9001), UserNickname: "Owner", @@ -78,7 +78,7 @@ func TestGetHomeReturnsZeroRewardWhenNoRoom(t *testing.T) { func TestGetHomeMatchesDefaultLevelsWhenConfigDisabled(t *testing.T) { service, _ := newTestService(t) - service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + service.repo.Java = &stubRoomTurnoverRewardJavaGateway{ userRoom: integration.RoomProfile{ ID: integration.Int64Value(101), UserID: integration.Int64Value(9001), diff --git a/internal/service/roomturnoverreward/mapper.go b/internal/service/roomturnoverreward/mapper.go index 81b9046..37a7a92 100644 --- a/internal/service/roomturnoverreward/mapper.go +++ b/internal/service/roomturnoverreward/mapper.go @@ -78,6 +78,7 @@ func toRecordView(row model.RoomTurnoverRewardRecord) RoomTurnoverRewardRecordVi TurnoverThreshold: row.TurnoverThreshold, RewardGold: row.RewardGold, TrackID: row.TrackID, + SourceID: row.SourceID, Status: row.Status, RetryCount: row.RetryCount, LastError: row.LastError, diff --git a/internal/service/roomturnoverreward/types.go b/internal/service/roomturnoverreward/types.go index b078530..0d19583 100644 --- a/internal/service/roomturnoverreward/types.go +++ b/internal/service/roomturnoverreward/types.go @@ -20,6 +20,8 @@ const ( roomTurnoverRewardStatusSuccess = "SUCCESS" roomTurnoverRewardStatusFailed = "FAILED" + roomTurnoverRewardGoldOrigin = "ROOM_CONTRIBUTION_REWARD" + defaultRoomTurnoverRewardTimezone = "Asia/Riyadh" ) @@ -79,22 +81,24 @@ type RoomTurnoverRewardLevelInput struct { // RoomTurnoverRewardConfigResponse 是房间流水奖励配置读模型。 type RoomTurnoverRewardConfigResponse struct { - Configured bool `json:"configured"` - ID int64 `json:"id,string"` - SysOrigin string `json:"sysOrigin"` - Enabled bool `json:"enabled"` - Timezone string `json:"timezone"` - UpdateTime string `json:"updateTime"` - LevelConfigs []RoomTurnoverRewardLevelPayload `json:"levelConfigs"` + Configured bool `json:"configured"` + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + AutoRewardEnabled bool `json:"autoRewardEnabled"` + Timezone string `json:"timezone"` + UpdateTime string `json:"updateTime"` + LevelConfigs []RoomTurnoverRewardLevelPayload `json:"levelConfigs"` } // SaveRoomTurnoverRewardConfigRequest 是房间流水奖励配置保存入参。 type SaveRoomTurnoverRewardConfigRequest struct { - ID flexibleInt64 `json:"id"` - SysOrigin string `json:"sysOrigin"` - Enabled bool `json:"enabled"` - Timezone string `json:"timezone"` - LevelConfigs []RoomTurnoverRewardLevelInput `json:"levelConfigs"` + ID flexibleInt64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + AutoRewardEnabled *bool `json:"autoRewardEnabled"` + Timezone string `json:"timezone"` + LevelConfigs []RoomTurnoverRewardLevelInput `json:"levelConfigs"` } // RoomTurnoverRewardRecordView 是后台发奖记录列表项。 @@ -117,6 +121,7 @@ type RoomTurnoverRewardRecordView struct { TurnoverThreshold int64 `json:"turnoverThreshold"` RewardGold int64 `json:"rewardGold"` TrackID string `json:"trackId"` + SourceID string `json:"sourceId"` Status string `json:"status"` RetryCount int `json:"retryCount"` LastError string `json:"lastError"` @@ -195,6 +200,26 @@ type RoomTurnoverRewardHomeResponse struct { LevelConfigs []RoomTurnoverRewardLevelPayload `json:"levelConfigs"` } +// RoomTurnoverRewardClaimStatusResponse 是 App 领取按钮判断接口的读模型。 +type RoomTurnoverRewardClaimStatusResponse struct { + CanClaim bool `json:"canClaim"` + Reason string `json:"reason"` + SysOrigin string `json:"sysOrigin"` + AutoRewardEnabled bool `json:"autoRewardEnabled"` + Record *RoomTurnoverRewardRecordView `json:"record,omitempty"` +} + +// RoomTurnoverRewardClaimRequest 是 App 手动领取奖励入参。 +type RoomTurnoverRewardClaimRequest struct { + RecordID flexibleInt64 `json:"recordId"` +} + +// RoomTurnoverRewardClaimResponse 是 App 手动领取奖励返回。 +type RoomTurnoverRewardClaimResponse struct { + Claimed bool `json:"claimed"` + Record RoomTurnoverRewardRecordView `json:"record"` +} + type roomTurnoverRewardDB interface { WithContext(ctx context.Context) *gorm.DB } @@ -205,6 +230,8 @@ type roomTurnoverRewardJavaGateway interface { GetCurrentWeekRoomContribution(ctx context.Context, roomID int64) (integration.RoomContributionActivityCount, error) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]integration.RoomContributionActivityCount, error) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + MarkRoomTurnoverRewardCoinsSent(ctx context.Context, id string) (bool, error) } type roomTurnoverRewardPorts struct { diff --git a/migrations/044_room_turnover_reward_manual_claim.sql b/migrations/044_room_turnover_reward_manual_claim.sql new file mode 100644 index 0000000..b2e707b --- /dev/null +++ b/migrations/044_room_turnover_reward_manual_claim.sql @@ -0,0 +1,31 @@ +SET @current_schema := DATABASE(); + +SET @add_room_turnover_reward_auto_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'room_turnover_reward_config' + AND column_name = 'auto_reward_enabled' + ), + 'ALTER TABLE `room_turnover_reward_config` ADD COLUMN `auto_reward_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT ''是否由 cron 自动发放奖励'' AFTER `enabled`', + 'SELECT 1' +); +PREPARE add_room_turnover_reward_auto_stmt FROM @add_room_turnover_reward_auto_sql; +EXECUTE add_room_turnover_reward_auto_stmt; +DEALLOCATE PREPARE add_room_turnover_reward_auto_stmt; + +SET @add_room_turnover_reward_source_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'room_turnover_reward_record' + AND column_name = 'source_id' + ), + 'ALTER TABLE `room_turnover_reward_record` ADD COLUMN `source_id` varchar(128) DEFAULT NULL COMMENT ''Java 房间流水统计记录ID'' AFTER `track_id`', + 'SELECT 1' +); +PREPARE add_room_turnover_reward_source_stmt FROM @add_room_turnover_reward_source_sql; +EXECUTE add_room_turnover_reward_source_stmt; +DEALLOCATE PREPARE add_room_turnover_reward_source_stmt;