From 4fc2ddebf106db7a8eb8813b74b7d13ec51295f6 Mon Sep 17 00:00:00 2001 From: hy001 Date: Mon, 27 Apr 2026 23:27:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=92=AD=E4=B8=AD=E5=BF=83/=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E6=B4=BB=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/main.go | 24 +- internal/config/config.go | 42 ++- internal/integration/gateways.go | 74 +++++ internal/integration/java.go | 204 ++++++++++++ internal/model/room_turnover_reward_models.go | 59 ++++ internal/router/host_center_routes.go | 55 ++++ internal/router/host_center_routes_test.go | 10 + .../router/room_turnover_reward_routes.go | 92 ++++++ internal/router/router.go | 48 ++- internal/service/hostcenter/aliases.go | 7 + internal/service/hostcenter/policy.go | 127 ++++++++ internal/service/hostcenter/policy_test.go | 123 ++++++++ internal/service/hostcenter/types.go | 51 +++ .../service/roomturnoverreward/aliases.go | 7 + internal/service/roomturnoverreward/config.go | 211 +++++++++++++ .../service/roomturnoverreward/config_test.go | 106 +++++++ .../roomturnoverreward/current_rank.go | 201 ++++++++++++ .../roomturnoverreward/current_rank_test.go | 82 +++++ internal/service/roomturnoverreward/home.go | 113 +++++++ .../service/roomturnoverreward/home_test.go | 103 +++++++ internal/service/roomturnoverreward/mapper.go | 121 ++++++++ internal/service/roomturnoverreward/page.go | 83 +++++ .../service/roomturnoverreward/page_test.go | 64 ++++ .../service/roomturnoverreward/records.go | 51 +++ internal/service/roomturnoverreward/types.go | 291 ++++++++++++++++++ internal/service/weekstar/config.go | 11 +- internal/service/weekstar/config_test.go | 51 ++- migrations/017_room_turnover_reward.sql | 61 ++++ 28 files changed, 2435 insertions(+), 37 deletions(-) create mode 100644 internal/model/room_turnover_reward_models.go create mode 100644 internal/router/host_center_routes.go create mode 100644 internal/router/host_center_routes_test.go create mode 100644 internal/router/room_turnover_reward_routes.go create mode 100644 internal/service/hostcenter/aliases.go create mode 100644 internal/service/hostcenter/policy.go create mode 100644 internal/service/hostcenter/policy_test.go create mode 100644 internal/service/hostcenter/types.go create mode 100644 internal/service/roomturnoverreward/aliases.go create mode 100644 internal/service/roomturnoverreward/config.go create mode 100644 internal/service/roomturnoverreward/config_test.go create mode 100644 internal/service/roomturnoverreward/current_rank.go create mode 100644 internal/service/roomturnoverreward/current_rank_test.go create mode 100644 internal/service/roomturnoverreward/home.go create mode 100644 internal/service/roomturnoverreward/home_test.go create mode 100644 internal/service/roomturnoverreward/mapper.go create mode 100644 internal/service/roomturnoverreward/page.go create mode 100644 internal/service/roomturnoverreward/page_test.go create mode 100644 internal/service/roomturnoverreward/records.go create mode 100644 internal/service/roomturnoverreward/types.go create mode 100644 migrations/017_room_turnover_reward.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index fee4d7f..52b1339 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -6,10 +6,12 @@ import ( "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" + "chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/weekstar" "context" @@ -39,6 +41,8 @@ func main() { registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) + hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() @@ -51,15 +55,17 @@ func main() { lingxian.NewAppProvider(lingxianService), ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ - Invite: inviteService, - Baishun: baishunService, - Lingxian: lingxianService, - GameOpen: gameOpenService, - GameProviders: gameProviders, - LuckyGift: luckyGiftService, - RegisterReward: registerRewardService, - SignInReward: signInRewardService, - WeekStar: weekStarService, + Invite: inviteService, + Baishun: baishunService, + Lingxian: lingxianService, + GameOpen: gameOpenService, + GameProviders: gameProviders, + LuckyGift: luckyGiftService, + HostCenter: hostCenterService, + RegisterReward: registerRewardService, + RoomTurnoverReward: roomTurnoverRewardService, + SignInReward: signInRewardService, + WeekStar: weekStarService, }) server := &http.Server{ Addr: app.Config.HTTP.ListenAddr, diff --git a/internal/config/config.go b/internal/config/config.go index 2f04161..d910fd4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,16 +10,17 @@ import ( // Config 是按运行层和业务模块分段后的应用配置。 type Config struct { - HTTP HTTPConfig - Store StoreConfig - Java JavaConfig - Worker WorkerConfig - Invite InviteConfig - WeekStar WeekStarConfig - Baishun BaishunConfig - Lingxian LingxianConfig - GameOpen GameOpenConfig - LuckyGift LuckyGiftConfig + HTTP HTTPConfig + Store StoreConfig + Java JavaConfig + Worker WorkerConfig + Invite InviteConfig + WeekStar WeekStarConfig + RoomTurnoverReward RoomTurnoverRewardConfig + Baishun BaishunConfig + Lingxian LingxianConfig + GameOpen GameOpenConfig + LuckyGift LuckyGiftConfig } // HTTPConfig 保存 HTTP 服务运行配置。 @@ -92,6 +93,13 @@ type WeekStarRocketMQConfig struct { Tag string } +// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。 +type RoomTurnoverRewardConfig struct { + DefaultSysOrigin string + Timezone string + CycleDuration time.Duration +} + // BaishunConfig 保存百顺模块配置。 type BaishunConfig struct { PlatformBaseURL string @@ -194,6 +202,20 @@ func Load() Config { Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"), }, }, + RoomTurnoverReward: RoomTurnoverRewardConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny( + []string{"CHATAPP_ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "ROOM_TURNOVER_REWARD_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, + "LIKEI", + )), + Timezone: getEnvAny( + []string{"CHATAPP_ROOM_TURNOVER_REWARD_TIMEZONE", "ROOM_TURNOVER_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, + "Asia/Riyadh", + ), + CycleDuration: time.Duration(getEnvIntAny( + []string{"CHATAPP_ROOM_TURNOVER_REWARD_CYCLE_SECONDS", "ROOM_TURNOVER_REWARD_CYCLE_SECONDS"}, + 10, + )) * time.Second, + }, Baishun: BaishunConfig{ PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""), GameListURL: getEnvAny([]string{"CHATAPP_BAISHUN_GAME_LIST_URL", "BAISHUN_GAME_LIST_URL"}, ""), diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 256fba2..81299a1 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -15,6 +15,8 @@ type Gateways struct { RewardDispatch *RewardDispatchGateway RewardQuery *RewardQueryGateway Profile *ProfileGateway + Room *RoomGateway + Team *TeamGateway Wallet *WalletGateway } @@ -29,6 +31,8 @@ func NewGateways(cfg config.Config) Gateways { RewardDispatch: &RewardDispatchGateway{client: client}, RewardQuery: &RewardQueryGateway{client: client}, Profile: &ProfileGateway{client: client}, + Room: &RoomGateway{client: client}, + Team: &TeamGateway{client: client}, Wallet: &WalletGateway{client: client}, } } @@ -88,6 +92,11 @@ func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfil return g.Profile.GetUserProfile(ctx, userID) } +// GetUserRegion 透传到用户地区网关。 +func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { + return g.Profile.GetUserRegion(ctx, userID, authorization) +} + // GetUserProfileByAccount 透传到资料网关。 func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account) @@ -103,6 +112,31 @@ func (g *Gateways) GetUserLanguage(ctx context.Context, userID int64) (string, e return g.Profile.GetUserLanguage(ctx, userID) } +// ListCurrentWeekRoomContribution 透传到房间流水网关。 +func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) { + return g.Room.ListCurrentWeekRoomContribution(ctx, limit) +} + +// GetCurrentWeekRoomContribution 透传到房间流水网关。 +func (g *Gateways) GetCurrentWeekRoomContribution(ctx context.Context, roomID int64) (RoomContributionActivityCount, error) { + return g.Room.GetCurrentWeekRoomContribution(ctx, roomID) +} + +// GetRoomProfileByUserID 透传到房间资料网关。 +func (g *Gateways) GetRoomProfileByUserID(ctx context.Context, userID int64) (RoomProfile, error) { + return g.Room.GetRoomProfileByUserID(ctx, userID) +} + +// MapRoomProfiles 透传到房间资料网关。 +func (g *Gateways) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]RoomProfile, error) { + return g.Room.MapRoomProfiles(ctx, roomIDs) +} + +// GetTeamPolicyRelease 透传到团队政策网关。 +func (g *Gateways) GetTeamPolicyRelease(ctx context.Context, query TeamPolicyReleaseQuery) (TeamPolicyRelease, error) { + return g.Team.GetTeamPolicyRelease(ctx, query) +} + // MapGoldBalance 透传到钱包网关。 func (g *Gateways) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { return g.Wallet.MapGoldBalance(ctx, userIDs) @@ -208,6 +242,11 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User return g.client.GetUserProfile(ctx, userID) } +// GetUserRegion 查询用户当前地区。 +func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { + return g.client.GetUserRegion(ctx, userID, authorization) +} + // GetUserProfileByAccount 根据账号查询用户资料。 func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { return g.client.GetUserProfileByAccount(ctx, sysOrigin, account) @@ -223,6 +262,41 @@ func (g *ProfileGateway) GetUserLanguage(ctx context.Context, userID int64) (str return g.client.GetUserLanguage(ctx, userID) } +// RoomGateway 负责房间资料和房间流水相关接口。 +type RoomGateway struct { + client *Client +} + +// ListCurrentWeekRoomContribution 查询当前周房间流水排行榜。 +func (g *RoomGateway) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) { + return g.client.ListCurrentWeekRoomContribution(ctx, limit) +} + +// GetCurrentWeekRoomContribution 查询指定房间当前周流水。 +func (g *RoomGateway) GetCurrentWeekRoomContribution(ctx context.Context, roomID int64) (RoomContributionActivityCount, error) { + return g.client.GetCurrentWeekRoomContribution(ctx, roomID) +} + +// GetRoomProfileByUserID 查询用户当前房间资料。 +func (g *RoomGateway) GetRoomProfileByUserID(ctx context.Context, userID int64) (RoomProfile, error) { + return g.client.GetRoomProfileByUserID(ctx, userID) +} + +// MapRoomProfiles 批量查询房间资料。 +func (g *RoomGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]RoomProfile, error) { + return g.client.MapRoomProfiles(ctx, roomIDs) +} + +// TeamGateway 负责团队政策相关接口。 +type TeamGateway struct { + client *Client +} + +// GetTeamPolicyRelease 查询已发布团队政策。 +func (g *TeamGateway) GetTeamPolicyRelease(ctx context.Context, query TeamPolicyReleaseQuery) (TeamPolicyRelease, error) { + return g.client.GetTeamPolicyRelease(ctx, query) +} + // WalletGateway 负责钱包相关接口。 type WalletGateway struct { client *Client diff --git a/internal/integration/java.go b/internal/integration/java.go index 2214d19..08ef7ba 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -41,6 +41,36 @@ type UserProfile struct { Account string `json:"account"` UserAvatar string `json:"userAvatar"` UserNickname string `json:"userNickname"` + CountryID Int64Value `json:"countryId"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + RegionCode string `json:"regionCode"` + OriginSys string `json:"originSys"` +} + +type UserRegion struct { + RegionID string `json:"regionId"` + RegionCode string `json:"regionCode"` +} + +type RoomContributionActivityCount struct { + ID string `json:"id"` + RoomID Int64Value `json:"roomId"` + ContributionValue AmountValue `json:"contributionValue"` + DateNumber int `json:"dateNumber"` + Received bool `json:"received"` + RewardCoinsSent bool `json:"rewardCoinsSent"` +} + +type RoomProfile struct { + ID Int64Value `json:"id"` + RoomAccount string `json:"roomAccount"` + UserID Int64Value `json:"userId"` + RoomCover string `json:"roomCover"` + RoomName string `json:"roomName"` + SysOrigin string `json:"sysOrigin"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` } type GoldReceiptCommand struct { @@ -104,6 +134,37 @@ type SendActivityRewardRequest struct { AcceptUserID int64 `json:"acceptUserId"` } +type TeamPolicyReleaseQuery struct { + SysOrigin string + Region string + PolicyType string + CountryCode string +} + +type TeamPolicyRelease struct { + ID Int64Value `json:"id"` + SysOrigin string `json:"sysOrigin"` + Region string `json:"region"` + CountryCode string `json:"countryCode"` + Type string `json:"type"` + HistoryRelease bool `json:"historyRelease"` + Release bool `json:"release"` + Title string `json:"title"` + Policy []TeamPolicyItem `json:"policy"` + PolicyType string `json:"policyType"` +} + +type TeamPolicyItem struct { + Level int `json:"level"` + OnlineTime Int64Value `json:"onlineTime"` + EffectiveDay int `json:"effectiveDay"` + Target Int64Value `json:"target"` + MemberSalary DecimalString `json:"memberSalary"` + OwnSalary DecimalString `json:"ownSalary"` + TotalSalary DecimalString `json:"totalSalary"` + PropsRewards any `json:"propsRewards"` +} + // OfficialNoticeCustomizeRequest 表示发送自定义官方通知的请求体。 type OfficialNoticeCustomizeRequest struct { ToAccounts []int64 `json:"toAccounts"` @@ -157,6 +218,57 @@ func (v *Int64Value) UnmarshalJSON(data []byte) error { return nil } +type AmountValue int64 + +func (v *AmountValue) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = 0 + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + raw = strings.Trim(raw, "\"") + } + if raw == "" { + *v = 0 + return nil + } + floatRaw := raw + if dot := strings.Index(raw, "."); dot >= 0 { + raw = raw[:dot] + } + if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil { + *v = AmountValue(parsed) + return nil + } + parsed, err := strconv.ParseFloat(floatRaw, 64) + if err != nil { + return err + } + *v = AmountValue(int64(parsed)) + return nil +} + +type DecimalString string + +func (v *DecimalString) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = "" + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + var parsed string + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + *v = DecimalString(parsed) + return nil + } + *v = DecimalString(raw) + return nil +} + func New(cfg config.Config) *Client { return &Client{ cfg: cfg, @@ -383,6 +495,22 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, return resp.Body, nil } +func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { + if userID <= 0 { + return UserRegion{}, fmt.Errorf("userId is required") + } + endpoint := c.cfg.Java.AppBaseURL + "/user/user-region?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + headers := javaAppHeadersFromAuthorization(authorization) + if strings.TrimSpace(authorization) != "" { + headers.Set("Authorization", authorization) + } + var resp resultResponse[UserRegion] + if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { + return UserRegion{}, err + } + return resp.Body, nil +} + func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" + url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account)) @@ -409,6 +537,82 @@ func (c *Client) GetUserProfileByAccountOne(ctx context.Context, account string) return resp.Body, nil } +func (c *Client) GetTeamPolicyRelease(ctx context.Context, query TeamPolicyReleaseQuery) (TeamPolicyRelease, error) { + values := url.Values{} + values.Set("sysOrigin", strings.TrimSpace(query.SysOrigin)) + values.Set("region", strings.TrimSpace(query.Region)) + values.Set("policyType", strings.TrimSpace(query.PolicyType)) + values.Set("countryCode", strings.TrimSpace(query.CountryCode)) + endpoint := c.cfg.Java.OtherBaseURL + "/team-policy/client/getReleaseByRegionAndTypeAndPolicyType?" + values.Encode() + var resp resultResponse[TeamPolicyRelease] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return TeamPolicyRelease{}, err + } + return resp.Body, nil +} + +func (c *Client) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) { + if limit <= 0 { + limit = 200 + } + endpoint := c.cfg.Java.OtherBaseURL + "/room-contribution-activity-count/client/listCurrentWeek?limit=" + + url.QueryEscape(strconv.Itoa(limit)) + var resp resultResponse[[]RoomContributionActivityCount] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return nil, err + } + return resp.Body, nil +} + +func (c *Client) GetCurrentWeekRoomContribution(ctx context.Context, roomID int64) (RoomContributionActivityCount, error) { + if roomID <= 0 { + return RoomContributionActivityCount{}, nil + } + endpoint := c.cfg.Java.OtherBaseURL + "/room-contribution-activity-count/client/currentWeek?roomId=" + + url.QueryEscape(strconv.FormatInt(roomID, 10)) + var resp resultResponse[RoomContributionActivityCount] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return RoomContributionActivityCount{}, err + } + return resp.Body, nil +} + +func (c *Client) GetRoomProfileByUserID(ctx context.Context, userID int64) (RoomProfile, error) { + if userID <= 0 { + return RoomProfile{}, nil + } + endpoint := c.cfg.Java.OtherBaseURL + "/room-manager/client/getProfileByUserId?userId=" + + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[RoomProfile] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return RoomProfile{}, err + } + return resp.Body, nil +} + +func (c *Client) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]RoomProfile, error) { + if len(roomIDs) == 0 { + return map[int64]RoomProfile{}, nil + } + endpoint := c.cfg.Java.OtherBaseURL + "/room-manager/client/mapProfileByRoomIds" + var resp resultResponse[map[string]RoomProfile] + if err := c.postJSON(ctx, endpoint, roomIDs, nil, &resp); err != nil { + return nil, err + } + result := make(map[int64]RoomProfile, len(resp.Body)) + for key, value := range resp.Body { + roomID, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64) + if err != nil { + roomID = int64(value.ID) + } + if roomID <= 0 { + continue + } + result[roomID] = value + } + return result, nil +} + func (c *Client) rewardBridgeBaseURL() string { if baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/"); baseURL != "" { return baseURL diff --git a/internal/model/room_turnover_reward_models.go b/internal/model/room_turnover_reward_models.go new file mode 100644 index 0000000..761bcf2 --- /dev/null +++ b/internal/model/room_turnover_reward_models.go @@ -0,0 +1,59 @@ +package model + +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"` +} + +func (RoomTurnoverRewardConfig) TableName() string { return "room_turnover_reward_config" } + +// RoomTurnoverRewardLevel 保存达到某个流水门槛后发放的金币奖励。 +type RoomTurnoverRewardLevel struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_room_turnover_reward_level,priority:1;uniqueIndex:uk_room_turnover_reward_threshold,priority:1;index:idx_room_turnover_reward_level_config"` + Level int `gorm:"column:level;uniqueIndex:uk_room_turnover_reward_level,priority:2"` + TurnoverThreshold int64 `gorm:"column:turnover_threshold;uniqueIndex:uk_room_turnover_reward_threshold,priority:2"` + RewardGold int64 `gorm:"column:reward_gold"` + Enabled bool `gorm:"column:enabled"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (RoomTurnoverRewardLevel) TableName() string { return "room_turnover_reward_level" } + +// RoomTurnoverRewardRecord 记录每周给房主发放的最高档房间流水奖励。 +type RoomTurnoverRewardRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_room_turnover_reward_record_config"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_room_turnover_reward_record_room,priority:1;index:idx_room_turnover_reward_record_cycle,priority:1"` + CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_room_turnover_reward_record_room,priority:2;index:idx_room_turnover_reward_record_cycle,priority:2"` + PeriodStartAt time.Time `gorm:"column:period_start_at"` + PeriodEndAt time.Time `gorm:"column:period_end_at"` + RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_room_turnover_reward_record_room,priority:3;index:idx_room_turnover_reward_record_room"` + RoomAccount string `gorm:"column:room_account;size:64"` + RoomName string `gorm:"column:room_name;size:255"` + RoomCover string `gorm:"column:room_cover;size:1024"` + OwnerUserID int64 `gorm:"column:owner_user_id;index:idx_room_turnover_reward_record_owner"` + CountryCode string `gorm:"column:country_code;size:32"` + CountryName string `gorm:"column:country_name;size:128"` + TurnoverAmount int64 `gorm:"column:turnover_amount"` + Level int `gorm:"column:level"` + 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"` + 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"` + SentAt *time.Time `gorm:"column:sent_at"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (RoomTurnoverRewardRecord) TableName() string { return "room_turnover_reward_record" } diff --git a/internal/router/host_center_routes.go b/internal/router/host_center_routes.go new file mode 100644 index 0000000..830d7a2 --- /dev/null +++ b/internal/router/host_center_routes.go @@ -0,0 +1,55 @@ +package router + +import ( + "net/http" + "net/url" + "strings" + + "chatapp3-golang/internal/service/hostcenter" + + "github.com/gin-gonic/gin" +) + +func registerHostCenterRoutes(engine *gin.Engine, service *hostcenter.Service) { + if service == nil { + return + } + + group := engine.Group("/app/h5/host-center") + group.GET("/policy", func(c *gin.Context) { + token := strings.TrimSpace(queryValuePreservePlus(c.Request.URL.RawQuery, "token")) + if token == "" { + writeError(c, hostcenter.NewAppError(http.StatusUnauthorized, "missing_token", "token is required")) + return + } + + resp, err := service.GetAnchorPolicy(c.Request.Context(), hostcenter.AnchorPolicyRequest{ + Token: token, + PolicyType: c.Query("policyType"), + }) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} + +func queryValuePreservePlus(rawQuery, name string) string { + for _, part := range strings.Split(rawQuery, "&") { + if part == "" { + continue + } + key, value, _ := strings.Cut(part, "=") + decodedKey, err := url.QueryUnescape(key) + if err != nil || decodedKey != name { + continue + } + decodedValue, err := url.QueryUnescape(strings.ReplaceAll(value, "+", "%2B")) + if err != nil { + return value + } + return decodedValue + } + return "" +} diff --git a/internal/router/host_center_routes_test.go b/internal/router/host_center_routes_test.go new file mode 100644 index 0000000..e7692c1 --- /dev/null +++ b/internal/router/host_center_routes_test.go @@ -0,0 +1,10 @@ +package router + +import "testing" + +func TestQueryValuePreservePlusKeepsTokenBase64Plus(t *testing.T) { + got := queryValuePreservePlus("lang=en&token=abc+def%2Bghi", "token") + if got != "abc+def+ghi" { + t.Fatalf("token = %q, want abc+def+ghi", got) + } +} diff --git a/internal/router/room_turnover_reward_routes.go b/internal/router/room_turnover_reward_routes.go new file mode 100644 index 0000000..59fa5d0 --- /dev/null +++ b/internal/router/room_turnover_reward_routes.go @@ -0,0 +1,92 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/roomturnoverreward" + + "github.com/gin-gonic/gin" +) + +// registerRoomTurnoverRewardRoutes 注册房间流水奖励后台配置和发奖记录路由。 +func registerRoomTurnoverRewardRoutes(engine *gin.Engine, javaClient authGateway, service *roomturnoverreward.Service) { + if service == nil { + return + } + + engine.GET("/public/h5/room-turnover-reward/page", func(c *gin.Context) { + resp, err := service.GetPublicPage(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + appGroup := engine.Group("/app/h5/room-turnover-reward") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/home", func(c *gin.Context) { + resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group := engine.Group("/resident-activity/room-turnover-reward") + group.Use(consoleAuthMiddleware(javaClient)) + + group.GET("/config", func(c *gin.Context) { + resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.POST("/config/save", func(c *gin.Context) { + var req roomturnoverreward.SaveRoomTurnoverRewardConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, roomturnoverreward.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/reward-record/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageRewardRecords( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("cycleKey"), + c.Query("status"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/current-rank", func(c *gin.Context) { + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.CurrentRoomRanking(c.Request.Context(), c.Query("sysOrigin"), limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index e617443..a638f7b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -9,10 +9,12 @@ import ( "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" + "chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/roomturnoverreward" "chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/weekstar" @@ -21,15 +23,17 @@ import ( // Services 聚合所有业务模块服务,供路由层统一装配。 type Services struct { - Invite *invite.InviteService - Baishun *baishun.BaishunService - Lingxian *lingxian.Service - GameOpen *gameopen.GameOpenService - GameProviders *gameprovider.Registry - LuckyGift *luckygift.LuckyGiftService - RegisterReward *registerreward.RegisterRewardService - SignInReward *signinreward.SignInRewardService - WeekStar *weekstar.WeekStarService + Invite *invite.InviteService + Baishun *baishun.BaishunService + Lingxian *lingxian.Service + GameOpen *gameopen.GameOpenService + GameProviders *gameprovider.Registry + LuckyGift *luckygift.LuckyGiftService + HostCenter *hostcenter.Service + RegisterReward *registerreward.RegisterRewardService + RoomTurnoverReward *roomturnoverreward.Service + SignInReward *signinreward.SignInRewardService + WeekStar *weekstar.WeekStarService } // NewRouter 创建 Gin 路由,并按模块注册所有接口。 @@ -40,13 +44,15 @@ func NewRouter( services Services, ) *gin.Engine { engine := gin.New() - engine.Use(gin.Logger(), gin.Recovery()) + engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware()) registerHealthRoutes(engine, cfg, repository) registerInviteRoutes(engine, javaClient, services.Invite) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerWeekStarRoutes(engine, javaClient, services.WeekStar) + registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) + registerHostCenterRoutes(engine, services.HostCenter) registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameProviderRoutes(engine, javaClient, services.GameProviders) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) @@ -56,6 +62,28 @@ func NewRouter( return engine } +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin == "" { + origin = "*" + } + + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type,Origin,Accept,req-lang,req-client,req-imei,req-app-intel,req-sys-origin,req-zone,X-Requested-With") + c.Header("Access-Control-Expose-Headers", "Authorization") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + // registerHealthRoutes 注册健康检查接口。 func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *repo.Repository) { engine.GET("/health", func(c *gin.Context) { diff --git a/internal/service/hostcenter/aliases.go b/internal/service/hostcenter/aliases.go new file mode 100644 index 0000000..85c3dec --- /dev/null +++ b/internal/service/hostcenter/aliases.go @@ -0,0 +1,7 @@ +package hostcenter + +import "chatapp3-golang/internal/common" + +type AppError = common.AppError + +var NewAppError = common.NewAppError diff --git a/internal/service/hostcenter/policy.go b/internal/service/hostcenter/policy.go new file mode 100644 index 0000000..928eca0 --- /dev/null +++ b/internal/service/hostcenter/policy.go @@ -0,0 +1,127 @@ +package hostcenter + +import ( + "context" + "net/http" + "strings" + + "chatapp3-golang/internal/integration" +) + +func (s *Service) GetAnchorPolicy(ctx context.Context, req AnchorPolicyRequest) (*AnchorPolicyResponse, error) { + if s.java == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable") + } + + token := trimBearer(req.Token) + if token == "" { + return nil, NewAppError(http.StatusUnauthorized, "missing_token", "token is required") + } + + credential, err := s.java.AuthenticateToken(ctx, token) + if err != nil { + return nil, NewAppError(http.StatusUnauthorized, "invalid_token", err.Error()) + } + userID := int64(credential.UserID) + if userID <= 0 { + return nil, NewAppError(http.StatusUnauthorized, "invalid_token", "token user is invalid") + } + + authorization := "Bearer " + token + profile, _ := s.java.GetUserProfile(ctx, userID) + userRegion, err := s.java.GetUserRegion(ctx, userID, authorization) + if err != nil { + return nil, err + } + + region := strings.TrimSpace(userRegion.RegionID) + if region == "" { + region = strings.TrimSpace(userRegion.RegionCode) + } + if region == "" { + region = strings.TrimSpace(profile.RegionCode) + } + if region == "" { + return nil, NewAppError(http.StatusNotFound, "user_region_not_found", "user region is not found") + } + + sysOrigin := normalizeSysOrigin(credential.SysOrigin, profile.OriginSys) + policyType := normalizePolicyType(req.PolicyType) + countryCode := strings.TrimSpace(profile.CountryCode) + + release, err := s.loadReleasePolicy(ctx, sysOrigin, region, policyType, countryCode) + if err != nil { + return nil, err + } + + resp := &AnchorPolicyResponse{ + UserID: userID, + SysOrigin: sysOrigin, + Region: region, + RegionCode: strings.TrimSpace(userRegion.RegionCode), + CountryCode: countryCode, + PolicyType: policyType, + Title: strings.TrimSpace(release.Title), + Release: release.Release, + Policy: release.Policy, + } + if resp.Title == "" { + resp.Title = "主播政策" + } + resp.Empty = int64(release.ID) == 0 && len(release.Policy) == 0 + return resp, nil +} + +func (s *Service) loadReleasePolicy(ctx context.Context, sysOrigin, region, policyType, countryCode string) (integration.TeamPolicyRelease, error) { + query := integration.TeamPolicyReleaseQuery{ + SysOrigin: sysOrigin, + Region: region, + PolicyType: javaPolicyType(policyType), + CountryCode: countryCode, + } + release, err := s.java.GetTeamPolicyRelease(ctx, query) + if err != nil { + return integration.TeamPolicyRelease{}, err + } + if !isEmptyRelease(release) || strings.TrimSpace(countryCode) == "" { + return release, nil + } + + query.CountryCode = "" + return s.java.GetTeamPolicyRelease(ctx, query) +} + +func normalizeSysOrigin(values ...string) string { + for _, value := range values { + if trimmed := strings.ToUpper(strings.TrimSpace(value)); trimmed != "" { + return trimmed + } + } + return defaultSysOrigin +} + +func normalizePolicyType(policyType string) string { + if strings.EqualFold(strings.TrimSpace(policyType), diamondPolicyType) { + return diamondPolicyType + } + return defaultPolicyType +} + +func javaPolicyType(policyType string) string { + if policyType == diamondPolicyType { + return diamondPolicyType + } + return defaultPolicyType +} + +func isEmptyRelease(release integration.TeamPolicyRelease) bool { + return int64(release.ID) == 0 && strings.TrimSpace(release.Title) == "" && len(release.Policy) == 0 +} + +func trimBearer(token string) string { + token = strings.TrimSpace(token) + if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") { + return strings.TrimSpace(token[7:]) + } + return token +} diff --git a/internal/service/hostcenter/policy_test.go b/internal/service/hostcenter/policy_test.go new file mode 100644 index 0000000..fad7f6f --- /dev/null +++ b/internal/service/hostcenter/policy_test.go @@ -0,0 +1,123 @@ +package hostcenter + +import ( + "context" + "errors" + "net/http" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" +) + +type stubHostCenterJavaGateway struct { + credential integration.UserCredential + profile integration.UserProfile + region integration.UserRegion + releases []integration.TeamPolicyRelease + queries []integration.TeamPolicyReleaseQuery + authErr error +} + +func (s *stubHostCenterJavaGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) { + if s.authErr != nil { + return integration.UserCredential{}, s.authErr + } + return s.credential, nil +} + +func (s *stubHostCenterJavaGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) { + return s.profile, nil +} + +func (s *stubHostCenterJavaGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) { + return s.region, nil +} + +func (s *stubHostCenterJavaGateway) GetTeamPolicyRelease(_ context.Context, query integration.TeamPolicyReleaseQuery) (integration.TeamPolicyRelease, error) { + s.queries = append(s.queries, query) + if len(s.releases) == 0 { + return integration.TeamPolicyRelease{}, nil + } + release := s.releases[0] + s.releases = s.releases[1:] + return release, nil +} + +func TestGetAnchorPolicyUsesTokenRegionAndCountryPolicy(t *testing.T) { + java := &stubHostCenterJavaGateway{ + credential: integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, + profile: integration.UserProfile{CountryCode: "AE"}, + region: integration.UserRegion{RegionID: "MENA", RegionCode: "MENA"}, + releases: []integration.TeamPolicyRelease{{ + ID: integration.Int64Value(99), + Release: true, + Title: "MENA Policy", + Policy: []integration.TeamPolicyItem{{ + Level: 1, + OnlineTime: integration.Int64Value(2), + EffectiveDay: 5, + Target: integration.Int64Value(10000), + }}, + }}, + } + service := NewService(config.Config{}, java) + + resp, err := service.GetAnchorPolicy(context.Background(), AnchorPolicyRequest{Token: "token-value"}) + if err != nil { + t.Fatalf("GetAnchorPolicy() error = %v", err) + } + if resp.UserID != 1001 || resp.Region != "MENA" || resp.CountryCode != "AE" || resp.PolicyType != defaultPolicyType { + t.Fatalf("unexpected response metadata: %+v", resp) + } + if len(resp.Policy) != 1 || resp.Policy[0].Level != 1 { + t.Fatalf("unexpected policy rows: %+v", resp.Policy) + } + if len(java.queries) != 1 { + t.Fatalf("query count = %d, want 1", len(java.queries)) + } + if got := java.queries[0]; got.SysOrigin != "LIKEI" || got.Region != "MENA" || got.CountryCode != "AE" || got.PolicyType != defaultPolicyType { + t.Fatalf("unexpected query: %+v", got) + } +} + +func TestGetAnchorPolicyFallsBackWhenCountryPolicyIsEmpty(t *testing.T) { + java := &stubHostCenterJavaGateway{ + credential: integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, + profile: integration.UserProfile{CountryCode: "AE"}, + region: integration.UserRegion{RegionID: "MENA"}, + releases: []integration.TeamPolicyRelease{ + {}, + {ID: integration.Int64Value(100), Title: "Default Policy"}, + }, + } + service := NewService(config.Config{}, java) + + resp, err := service.GetAnchorPolicy(context.Background(), AnchorPolicyRequest{Token: "token-value"}) + if err != nil { + t.Fatalf("GetAnchorPolicy() error = %v", err) + } + if resp.Title != "Default Policy" { + t.Fatalf("fallback title = %q", resp.Title) + } + if len(java.queries) != 2 { + t.Fatalf("query count = %d, want 2", len(java.queries)) + } + if java.queries[0].CountryCode != "AE" || java.queries[1].CountryCode != "" { + t.Fatalf("fallback queries = %+v", java.queries) + } +} + +func TestGetAnchorPolicyRejectsInvalidToken(t *testing.T) { + java := &stubHostCenterJavaGateway{authErr: errors.New("bad token")} + service := NewService(config.Config{}, java) + + _, err := service.GetAnchorPolicy(context.Background(), AnchorPolicyRequest{Token: "bad"}) + if err == nil { + t.Fatal("expected error") + } + appErr, ok := err.(*AppError) + if !ok || appErr.Status != http.StatusUnauthorized || appErr.Code != "invalid_token" { + t.Fatalf("unexpected error = %#v", err) + } +} diff --git a/internal/service/hostcenter/types.go b/internal/service/hostcenter/types.go new file mode 100644 index 0000000..a9b151a --- /dev/null +++ b/internal/service/hostcenter/types.go @@ -0,0 +1,51 @@ +package hostcenter + +import ( + "context" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" +) + +const ( + defaultPolicyType = "MONEY" + diamondPolicyType = "SALARY_DIAMOND" + defaultSysOrigin = "LIKEI" +) + +type AnchorPolicyRequest struct { + Token string + PolicyType string +} + +type AnchorPolicyResponse struct { + UserID int64 `json:"userId,string"` + SysOrigin string `json:"sysOrigin"` + Region string `json:"region"` + RegionCode string `json:"regionCode"` + CountryCode string `json:"countryCode"` + PolicyType string `json:"policyType"` + Title string `json:"title"` + Release bool `json:"release"` + Empty bool `json:"empty"` + Policy []integration.TeamPolicyItem `json:"policy"` +} + +type javaGateway interface { + AuthenticateToken(ctx context.Context, token string) (integration.UserCredential, error) + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) + GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error) + GetTeamPolicyRelease(ctx context.Context, query integration.TeamPolicyReleaseQuery) (integration.TeamPolicyRelease, error) +} + +type Service struct { + cfg config.Config + java javaGateway +} + +func NewService(cfg config.Config, javaGateway javaGateway) *Service { + return &Service{ + cfg: cfg, + java: javaGateway, + } +} diff --git a/internal/service/roomturnoverreward/aliases.go b/internal/service/roomturnoverreward/aliases.go new file mode 100644 index 0000000..967e352 --- /dev/null +++ b/internal/service/roomturnoverreward/aliases.go @@ -0,0 +1,7 @@ +package roomturnoverreward + +import "chatapp3-golang/internal/common" + +type AppError = common.AppError + +var NewAppError = common.NewAppError diff --git a/internal/service/roomturnoverreward/config.go b/internal/service/roomturnoverreward/config.go new file mode 100644 index 0000000..27082aa --- /dev/null +++ b/internal/service/roomturnoverreward/config.go @@ -0,0 +1,211 @@ +package roomturnoverreward + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回系统当前房间流水奖励配置;未配置时返回默认档位草稿。 +func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*RoomTurnoverRewardConfigResponse, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + + var configRow model.RoomTurnoverRewardConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &RoomTurnoverRewardConfigResponse{ + Configured: false, + SysOrigin: sysOrigin, + Enabled: false, + Timezone: s.defaultTimezone(), + LevelConfigs: defaultLevelPayloads(), + }, nil + } + if err != nil { + return nil, err + } + + levels, err := s.loadLevels(ctx, configRow.ID) + if err != nil { + 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), + }, nil +} + +// SaveConfig 保存系统级房间流水奖励配置;同一系统只保留一份当前配置。 +func (s *Service) SaveConfig(ctx context.Context, req SaveRoomTurnoverRewardConfigRequest) (*RoomTurnoverRewardConfigResponse, error) { + requestID := req.ID.Int64() + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + timezone := strings.TrimSpace(req.Timezone) + if timezone == "" { + timezone = s.defaultTimezone() + } + if _, err := time.LoadLocation(timezone); err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + levels, err := s.normalizeLevelInputs(req.LevelConfigs) + if err != nil { + return nil, err + } + if req.Enabled { + hasEnabledLevel := false + for _, level := range levels { + if level.Enabled { + hasEnabledLevel = true + break + } + } + if !hasEnabledLevel { + return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level") + } + } + + var savedConfigID int64 + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + var configRow model.RoomTurnoverRewardConfig + if requestID > 0 { + err := tx.Where("id = ?", requestID).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return NewAppError(http.StatusNotFound, "room_turnover_reward_config_not_found", "config not found") + } + if err != nil { + return err + } + if strings.TrimSpace(req.SysOrigin) == "" { + sysOrigin = configRow.SysOrigin + } + } else { + err := tx.Where("sys_origin = ?", sysOrigin).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + configID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.RoomTurnoverRewardConfig{ + ID: configID, + SysOrigin: sysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + } + + configRow.SysOrigin = sysOrigin + configRow.Enabled = req.Enabled + configRow.Timezone = timezone + configRow.UpdateTime = now + if configRow.CreateTime.IsZero() { + configRow.CreateTime = now + } + + if err := tx.Save(&configRow).Error; err != nil { + return err + } + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.RoomTurnoverRewardLevel{}).Error; err != nil { + return err + } + for index := range levels { + levels[index].ConfigID = configRow.ID + levels[index].CreateTime = now + levels[index].UpdateTime = now + } + if err := tx.Create(&levels).Error; err != nil { + return err + } + savedConfigID = configRow.ID + return nil + }); err != nil { + return nil, err + } + + return s.GetConfig(ctx, sysOriginBySavedID(ctx, s.repo.DB, savedConfigID, sysOrigin)) +} + +func (s *Service) normalizeLevelInputs(inputs []RoomTurnoverRewardLevelInput) ([]model.RoomTurnoverRewardLevel, error) { + if len(inputs) == 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "levelConfigs is required") + } + + seenLevels := make(map[int]struct{}, len(inputs)) + seenThresholds := make(map[int64]struct{}, len(inputs)) + rows := make([]model.RoomTurnoverRewardLevel, 0, len(inputs)) + for _, item := range inputs { + if item.Level <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be greater than 0") + } + if item.TurnoverThreshold <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_turnover_threshold", "turnoverThreshold must be greater than 0") + } + if item.RewardGold <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_gold", "rewardGold must be greater than 0") + } + if _, exists := seenLevels[item.Level]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_level", "levelConfigs must not contain duplicate levels") + } + if _, exists := seenThresholds[item.TurnoverThreshold]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_turnover_threshold", "levelConfigs must not contain duplicate thresholds") + } + seenLevels[item.Level] = struct{}{} + seenThresholds[item.TurnoverThreshold] = struct{}{} + id, err := utils.NextID() + if err != nil { + return nil, err + } + rows = append(rows, model.RoomTurnoverRewardLevel{ + ID: id, + Level: item.Level, + TurnoverThreshold: item.TurnoverThreshold, + RewardGold: item.RewardGold, + Enabled: item.Enabled, + }) + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].TurnoverThreshold < rows[j].TurnoverThreshold + }) + return rows, nil +} + +func (s *Service) loadLevels(ctx context.Context, configID int64) ([]model.RoomTurnoverRewardLevel, error) { + var levels []model.RoomTurnoverRewardLevel + if configID <= 0 { + return levels, nil + } + err := s.repo.DB.WithContext(ctx). + Where("config_id = ?", configID). + Order("level asc"). + Find(&levels).Error + return levels, err +} + +func sysOriginBySavedID(ctx context.Context, db roomTurnoverRewardDB, configID int64, fallback string) string { + if configID <= 0 { + return fallback + } + var row model.RoomTurnoverRewardConfig + if err := db.WithContext(ctx).Select("sys_origin").Where("id = ?", configID).First(&row).Error; err != nil { + return fallback + } + return row.SysOrigin +} diff --git a/internal/service/roomturnoverreward/config_test.go b/internal/service/roomturnoverreward/config_test.go new file mode 100644 index 0000000..14340b1 --- /dev/null +++ b/internal/service/roomturnoverreward/config_test.go @@ -0,0 +1,106 @@ +package roomturnoverreward + +import ( + "context" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.RoomTurnoverRewardConfig{}, + &model.RoomTurnoverRewardLevel{}, + &model.RoomTurnoverRewardRecord{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + service := NewService(config.Config{ + RoomTurnoverReward: config.RoomTurnoverRewardConfig{ + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + }, + }, db) + return service, db +} + +func TestGetConfigReturnsDefaultLevelsWhenMissing(t *testing.T) { + service, _ := newTestService(t) + + resp, err := service.GetConfig(context.Background(), "") + if err != nil { + t.Fatalf("GetConfig() error = %v", err) + } + if resp.Configured || resp.Enabled { + t.Fatalf("GetConfig() configured/enabled = %v/%v, want false/false", resp.Configured, resp.Enabled) + } + if resp.SysOrigin != "LIKEI" || resp.Timezone != "Asia/Riyadh" { + t.Fatalf("GetConfig() sys/timezone = %q/%q", resp.SysOrigin, resp.Timezone) + } + if len(resp.LevelConfigs) != 4 { + t.Fatalf("default level count = %d, want 4", len(resp.LevelConfigs)) + } + if got := resp.LevelConfigs[3]; got.TurnoverThreshold != 5000000 || got.RewardGold != 250000 { + t.Fatalf("highest default level = %+v", got) + } +} + +func TestSaveConfigRejectsDuplicateThreshold(t *testing.T) { + service, _ := newTestService(t) + + _, err := service.SaveConfig(context.Background(), SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + {Level: 2, TurnoverThreshold: 100000, RewardGold: 3000, Enabled: true}, + }, + }) + if err == nil { + t.Fatalf("SaveConfig() error = nil, want duplicate threshold error") + } +} + +func TestSaveConfigUpsertsBySysOrigin(t *testing.T) { + service, _ := newTestService(t) + ctx := context.Background() + + first, 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("first SaveConfig() error = %v", err) + } + second, err := service.SaveConfig(ctx, SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 200000, RewardGold: 5000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("second SaveConfig() error = %v", err) + } + if first.ID != second.ID { + t.Fatalf("config ID changed on upsert: first=%d second=%d", first.ID, second.ID) + } + if got := second.LevelConfigs[0]; got.TurnoverThreshold != 200000 || got.RewardGold != 5000 { + t.Fatalf("upserted level = %+v", got) + } +} diff --git a/internal/service/roomturnoverreward/current_rank.go b/internal/service/roomturnoverreward/current_rank.go new file mode 100644 index 0000000..607fe9d --- /dev/null +++ b/internal/service/roomturnoverreward/current_rank.go @@ -0,0 +1,201 @@ +package roomturnoverreward + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +type roomTurnoverRewardRankingLevel struct { + Level int + TurnoverThreshold int64 + RewardGold int64 + Enabled bool +} + +// CurrentRoomRanking 返回当前周期的房间流水排行榜,并根据当前配置计算预计命中的最高奖励档。 +func (s *Service) CurrentRoomRanking(ctx context.Context, sysOrigin string, limit int) (*RoomTurnoverRewardCurrentRankResponse, error) { + if s.repo.Java == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable") + } + sysOrigin = s.normalizeSysOrigin(sysOrigin) + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + levels, err := s.loadRankingLevels(ctx, sysOrigin) + if err != nil { + return nil, err + } + + now := time.Now().In(s.location) + periodStart, periodEnd := s.currentCycleBounds(now) + dateNumber, err := s.currentDateNumber(now) + if err != nil { + return nil, err + } + + sourceLimit := limit * 5 + if sourceLimit < 100 { + sourceLimit = 100 + } + if sourceLimit > 500 { + sourceLimit = 500 + } + contributions, err := s.repo.Java.ListCurrentWeekRoomContribution(ctx, sourceLimit) + if err != nil { + return nil, err + } + roomIDs := uniqueRoomIDs(contributions) + roomProfiles, err := s.repo.Java.MapRoomProfiles(ctx, roomIDs) + if err != nil { + return nil, err + } + + items := make([]RoomTurnoverRewardCurrentRankItem, 0, limit) + for _, contribution := range contributions { + roomID := int64(contribution.RoomID) + if roomID <= 0 { + continue + } + profile, ok := roomProfiles[roomID] + if !ok { + continue + } + if profileSysOrigin := strings.ToUpper(strings.TrimSpace(profile.SysOrigin)); profileSysOrigin != "" && profileSysOrigin != sysOrigin { + continue + } + turnoverAmount := int64(contribution.ContributionValue) + item := RoomTurnoverRewardCurrentRankItem{ + RoomID: roomID, + RoomAccount: profile.RoomAccount, + RoomName: profile.RoomName, + RoomCover: profile.RoomCover, + OwnerUserID: int64(profile.UserID), + CountryCode: profile.CountryCode, + CountryName: profile.CountryName, + TurnoverAmount: turnoverAmount, + DateNumber: contribution.DateNumber, + } + if item.DateNumber == 0 { + item.DateNumber = dateNumber + } + if level, ok := pickHighestRankingLevel(levels, turnoverAmount); ok { + item.MatchedLevel = level.Level + item.TurnoverThreshold = level.TurnoverThreshold + item.ExpectedRewardGold = level.RewardGold + } + items = append(items, item) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].TurnoverAmount == items[j].TurnoverAmount { + return items[i].RoomID < items[j].RoomID + } + return items[i].TurnoverAmount > items[j].TurnoverAmount + }) + if len(items) > limit { + items = items[:limit] + } + for index := range items { + items[index].Rank = index + 1 + } + + return &RoomTurnoverRewardCurrentRankResponse{ + SysOrigin: sysOrigin, + Timezone: s.defaultTimezone(), + CycleKey: s.cycleKey(periodStart), + PeriodStartAt: formatDateTime(periodStart), + PeriodEndAt: formatDateTime(periodEnd), + DateNumber: dateNumber, + Records: items, + }, nil +} + +func (s *Service) loadRankingLevels(ctx context.Context, sysOrigin string) ([]roomTurnoverRewardRankingLevel, error) { + var configRow model.RoomTurnoverRewardConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return defaultRankingLevels(), nil + } + if err != nil { + return nil, err + } + + levels, err := s.loadLevels(ctx, configRow.ID) + if err != nil { + return nil, err + } + result := make([]roomTurnoverRewardRankingLevel, 0, len(levels)) + for _, level := range levels { + result = append(result, roomTurnoverRewardRankingLevel{ + Level: level.Level, + TurnoverThreshold: level.TurnoverThreshold, + RewardGold: level.RewardGold, + Enabled: level.Enabled, + }) + } + return result, nil +} + +func defaultRankingLevels() []roomTurnoverRewardRankingLevel { + defaults := defaultLevelPayloads() + result := make([]roomTurnoverRewardRankingLevel, 0, len(defaults)) + for _, level := range defaults { + result = append(result, roomTurnoverRewardRankingLevel{ + Level: level.Level, + TurnoverThreshold: level.TurnoverThreshold, + RewardGold: level.RewardGold, + Enabled: level.Enabled, + }) + } + return result +} + +func pickHighestRankingLevel(levels []roomTurnoverRewardRankingLevel, turnoverAmount int64) (roomTurnoverRewardRankingLevel, bool) { + if turnoverAmount <= 0 || len(levels) == 0 { + return roomTurnoverRewardRankingLevel{}, false + } + sort.Slice(levels, func(i, j int) bool { + return levels[i].TurnoverThreshold > levels[j].TurnoverThreshold + }) + for _, level := range levels { + if !level.Enabled || level.TurnoverThreshold <= 0 || level.RewardGold <= 0 { + continue + } + if turnoverAmount >= level.TurnoverThreshold { + return level, true + } + } + return roomTurnoverRewardRankingLevel{}, false +} + +func uniqueRoomIDs(contributions []integration.RoomContributionActivityCount) []int64 { + result := make([]int64, 0, len(contributions)) + seen := make(map[int64]struct{}, len(contributions)) + for _, item := range contributions { + roomID := int64(item.RoomID) + if roomID <= 0 { + continue + } + if _, exists := seen[roomID]; exists { + continue + } + seen[roomID] = struct{}{} + result = append(result, roomID) + } + return result +} diff --git a/internal/service/roomturnoverreward/current_rank_test.go b/internal/service/roomturnoverreward/current_rank_test.go new file mode 100644 index 0000000..e15d2af --- /dev/null +++ b/internal/service/roomturnoverreward/current_rank_test.go @@ -0,0 +1,82 @@ +package roomturnoverreward + +import ( + "context" + "testing" + + "chatapp3-golang/internal/integration" +) + +type stubRoomTurnoverRewardJavaGateway struct { + contributions []integration.RoomContributionActivityCount + current integration.RoomContributionActivityCount + userProfile integration.UserProfile + userRoom integration.RoomProfile + profiles map[int64]integration.RoomProfile +} + +func (s stubRoomTurnoverRewardJavaGateway) GetUserProfile(_ context.Context, _ int64) (integration.UserProfile, error) { + return s.userProfile, nil +} + +func (s stubRoomTurnoverRewardJavaGateway) GetRoomProfileByUserID(_ context.Context, _ int64) (integration.RoomProfile, error) { + return s.userRoom, nil +} + +func (s stubRoomTurnoverRewardJavaGateway) GetCurrentWeekRoomContribution(_ context.Context, _ int64) (integration.RoomContributionActivityCount, error) { + return s.current, nil +} + +func (s stubRoomTurnoverRewardJavaGateway) ListCurrentWeekRoomContribution(_ context.Context, _ int) ([]integration.RoomContributionActivityCount, error) { + return s.contributions, nil +} + +func (s stubRoomTurnoverRewardJavaGateway) MapRoomProfiles(_ context.Context, _ []int64) (map[int64]integration.RoomProfile, error) { + return s.profiles, nil +} + +func TestCurrentRoomRankingSortsAndMatchesHighestLevel(t *testing.T) { + service, _ := newTestService(t) + service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + contributions: []integration.RoomContributionActivityCount{ + {RoomID: integration.Int64Value(101), ContributionValue: integration.AmountValue(120000)}, + {RoomID: integration.Int64Value(102), ContributionValue: integration.AmountValue(800000)}, + {RoomID: integration.Int64Value(103), ContributionValue: integration.AmountValue(900000)}, + }, + profiles: map[int64]integration.RoomProfile{ + 101: {ID: integration.Int64Value(101), RoomName: "A", SysOrigin: "LIKEI", UserID: integration.Int64Value(1001)}, + 102: {ID: integration.Int64Value(102), RoomName: "B", SysOrigin: "LIKEI", UserID: integration.Int64Value(1002)}, + 103: {ID: integration.Int64Value(103), RoomName: "C", SysOrigin: "TARAB", UserID: integration.Int64Value(1003)}, + }, + } + + _, err := service.SaveConfig(context.Background(), SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + {Level: 2, TurnoverThreshold: 500000, RewardGold: 15000, Enabled: true}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + resp, err := service.CurrentRoomRanking(context.Background(), "likei", 10) + if err != nil { + t.Fatalf("CurrentRoomRanking() error = %v", err) + } + if len(resp.Records) != 2 { + t.Fatalf("record count = %d, want 2", len(resp.Records)) + } + if got := resp.Records[0]; got.RoomID != 102 || got.Rank != 1 || got.MatchedLevel != 2 || got.ExpectedRewardGold != 15000 { + t.Fatalf("top record = %+v", got) + } + if got := resp.Records[1]; got.RoomID != 101 || got.Rank != 2 || got.MatchedLevel != 1 || got.ExpectedRewardGold != 2000 { + t.Fatalf("second record = %+v", got) + } + if resp.CycleKey == "" || resp.PeriodStartAt == "" || resp.PeriodEndAt == "" { + t.Fatalf("expected cycle metadata, got %+v", resp) + } +} diff --git a/internal/service/roomturnoverreward/home.go b/internal/service/roomturnoverreward/home.go new file mode 100644 index 0000000..b3e61e2 --- /dev/null +++ b/internal/service/roomturnoverreward/home.go @@ -0,0 +1,113 @@ +package roomturnoverreward + +import ( + "context" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/common" +) + +// GetHome 返回当前登录用户房间的流水奖励 H5 数据。 +func (s *Service) GetHome(ctx context.Context, user common.AuthUser) (*RoomTurnoverRewardHomeResponse, 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") + } + + config, err := s.GetConfig(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + now := time.Now().In(s.location) + periodStart, periodEnd := s.currentCycleBounds(now) + levels := enabledPageLevels(config.LevelConfigs) + + resp := &RoomTurnoverRewardHomeResponse{ + ActivityStatus: roomTurnoverRewardActivityStatus(config.Enabled), + Configured: config.Configured, + SysOrigin: config.SysOrigin, + Timezone: config.Timezone, + CycleKey: s.cycleKey(periodStart), + PeriodStartAt: formatDateTime(periodStart), + PeriodEndAt: formatDateTime(periodEnd), + CurrentPeriodText: formatRoomTurnoverRewardPeriod(periodStart, periodEnd), + CountdownMillis: maxInt64(0, periodEnd.UnixMilli()-now.UnixMilli()), + UserID: user.UserID, + HighlightRewardGold: 0, + LevelConfigs: levels, + } + + if profile, err := s.repo.Java.GetUserProfile(ctx, user.UserID); err == nil { + resp.UserAccount = profile.Account + resp.UserNickname = profile.UserNickname + resp.UserAvatar = profile.UserAvatar + } else if !isEmptyJavaProfileError(err) { + return nil, err + } + + roomProfile, err := s.repo.Java.GetRoomProfileByUserID(ctx, user.UserID) + if err != nil { + if isEmptyJavaProfileError(err) { + return resp, nil + } + return nil, err + } + if int64(roomProfile.ID) <= 0 { + return resp, nil + } + + resp.HasRoom = true + resp.RoomID = int64(roomProfile.ID) + resp.RoomAccount = roomProfile.RoomAccount + resp.RoomName = roomProfile.RoomName + resp.RoomCover = roomProfile.RoomCover + if resp.UserID == 0 { + resp.UserID = int64(roomProfile.UserID) + } + + contribution, err := s.repo.Java.GetCurrentWeekRoomContribution(ctx, resp.RoomID) + if err != nil { + return nil, err + } + resp.TurnoverAmount = int64(contribution.ContributionValue) + + if level, ok := pickHighestRankingLevel(rankingLevelsFromPayloads(levels), resp.TurnoverAmount); ok { + resp.MatchedLevel = level.Level + resp.TurnoverThreshold = level.TurnoverThreshold + resp.ExpectedRewardGold = level.RewardGold + resp.HighlightRewardGold = level.RewardGold + } + + return resp, nil +} + +func roomTurnoverRewardActivityStatus(enabled bool) string { + if enabled { + return roomTurnoverRewardActivityStatusOngoing + } + return roomTurnoverRewardActivityStatusDisabled +} + +func rankingLevelsFromPayloads(levels []RoomTurnoverRewardLevelPayload) []roomTurnoverRewardRankingLevel { + result := make([]roomTurnoverRewardRankingLevel, 0, len(levels)) + for _, level := range levels { + result = append(result, roomTurnoverRewardRankingLevel{ + Level: level.Level, + TurnoverThreshold: level.TurnoverThreshold, + RewardGold: level.RewardGold, + Enabled: level.Enabled, + }) + } + return result +} + +func isEmptyJavaProfileError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "empty") +} diff --git a/internal/service/roomturnoverreward/home_test.go b/internal/service/roomturnoverreward/home_test.go new file mode 100644 index 0000000..b92abf1 --- /dev/null +++ b/internal/service/roomturnoverreward/home_test.go @@ -0,0 +1,103 @@ +package roomturnoverreward + +import ( + "context" + "testing" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/integration" +) + +func TestGetHomeReturnsUserRoomTurnoverAndExpectedReward(t *testing.T) { + service, _ := newTestService(t) + service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + userProfile: integration.UserProfile{ + ID: integration.Int64Value(9001), + Account: "123456789", + UserNickname: "Owner", + UserAvatar: "https://example.com/avatar.png", + }, + userRoom: integration.RoomProfile{ + ID: integration.Int64Value(101), + RoomAccount: "R101", + RoomName: "Owner room", + RoomCover: "https://example.com/room.png", + UserID: integration.Int64Value(9001), + SysOrigin: "LIKEI", + }, + current: integration.RoomContributionActivityCount{ + RoomID: integration.Int64Value(101), + ContributionValue: integration.AmountValue(7200000), + }, + } + ctx := context.Background() + if _, err := service.SaveConfig(ctx, SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 1, TurnoverThreshold: 1000000, RewardGold: 1000, Enabled: true}, + {Level: 2, TurnoverThreshold: 7000000, RewardGold: 350000, Enabled: true}, + }, + }); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + resp, err := service.GetHome(ctx, common.AuthUser{UserID: 9001, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetHome() error = %v", err) + } + if !resp.HasRoom || resp.RoomID != 101 || resp.UserNickname != "Owner" { + t.Fatalf("profile fields = %+v", resp) + } + if resp.TurnoverAmount != 7200000 || resp.MatchedLevel != 2 || resp.ExpectedRewardGold != 350000 { + t.Fatalf("reward fields = %+v", resp) + } + if len(resp.LevelConfigs) != 2 || resp.HighlightRewardGold != 350000 { + t.Fatalf("level/highlight fields = %+v", resp) + } +} + +func TestGetHomeReturnsZeroRewardWhenNoRoom(t *testing.T) { + service, _ := newTestService(t) + service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + userProfile: integration.UserProfile{ + ID: integration.Int64Value(9001), + UserNickname: "Owner", + }, + } + + resp, err := service.GetHome(context.Background(), common.AuthUser{UserID: 9001, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetHome() error = %v", err) + } + if resp.HasRoom || resp.TurnoverAmount != 0 || resp.ExpectedRewardGold != 0 { + t.Fatalf("no room response = %+v", resp) + } +} + +func TestGetHomeMatchesDefaultLevelsWhenConfigDisabled(t *testing.T) { + service, _ := newTestService(t) + service.repo.Java = stubRoomTurnoverRewardJavaGateway{ + userRoom: integration.RoomProfile{ + ID: integration.Int64Value(101), + UserID: integration.Int64Value(9001), + SysOrigin: "LIKEI", + }, + current: integration.RoomContributionActivityCount{ + RoomID: integration.Int64Value(101), + ContributionValue: integration.AmountValue(7200000), + }, + } + + resp, err := service.GetHome(context.Background(), common.AuthUser{UserID: 9001, SysOrigin: "LIKEI"}) + if err != nil { + t.Fatalf("GetHome() error = %v", err) + } + if resp.Configured || resp.ActivityStatus != roomTurnoverRewardActivityStatusDisabled { + t.Fatalf("config fields = %+v", resp) + } + if resp.MatchedLevel != 4 || resp.ExpectedRewardGold != 250000 || resp.HighlightRewardGold != 250000 { + t.Fatalf("reward fields = %+v", resp) + } +} diff --git a/internal/service/roomturnoverreward/mapper.go b/internal/service/roomturnoverreward/mapper.go new file mode 100644 index 0000000..81b9046 --- /dev/null +++ b/internal/service/roomturnoverreward/mapper.go @@ -0,0 +1,121 @@ +package roomturnoverreward + +import ( + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" +) + +func (s *Service) normalizeSysOrigin(sysOrigin string) string { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin != "" { + return sysOrigin + } + if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.RoomTurnoverReward.DefaultSysOrigin)); fallback != "" { + return fallback + } + if fallback := strings.ToUpper(strings.TrimSpace(s.cfg.WeekStar.DefaultSysOrigin)); fallback != "" { + return fallback + } + return "LIKEI" +} + +func (s *Service) defaultTimezone() string { + if timezone := strings.TrimSpace(s.cfg.RoomTurnoverReward.Timezone); timezone != "" { + return timezone + } + if timezone := strings.TrimSpace(s.cfg.WeekStar.Timezone); timezone != "" { + return timezone + } + return defaultRoomTurnoverRewardTimezone +} + +func defaultLevelPayloads() []RoomTurnoverRewardLevelPayload { + return []RoomTurnoverRewardLevelPayload{ + {Level: 1, TurnoverThreshold: 100000, RewardGold: 2000, Enabled: true}, + {Level: 2, TurnoverThreshold: 500000, RewardGold: 15000, Enabled: true}, + {Level: 3, TurnoverThreshold: 2000000, RewardGold: 80000, Enabled: true}, + {Level: 4, TurnoverThreshold: 5000000, RewardGold: 250000, Enabled: true}, + } +} + +func buildLevelPayloads(rows []model.RoomTurnoverRewardLevel) []RoomTurnoverRewardLevelPayload { + sort.Slice(rows, func(i, j int) bool { + return rows[i].Level < rows[j].Level + }) + result := make([]RoomTurnoverRewardLevelPayload, 0, len(rows)) + for _, row := range rows { + result = append(result, RoomTurnoverRewardLevelPayload{ + ID: row.ID, + Level: row.Level, + TurnoverThreshold: row.TurnoverThreshold, + RewardGold: row.RewardGold, + Enabled: row.Enabled, + }) + } + return result +} + +func toRecordView(row model.RoomTurnoverRewardRecord) RoomTurnoverRewardRecordView { + return RoomTurnoverRewardRecordView{ + ID: row.ID, + ConfigID: row.ConfigID, + SysOrigin: row.SysOrigin, + CycleKey: row.CycleKey, + PeriodStartAt: formatDateTime(row.PeriodStartAt), + PeriodEndAt: formatDateTime(row.PeriodEndAt), + RoomID: row.RoomID, + RoomAccount: row.RoomAccount, + RoomName: row.RoomName, + RoomCover: row.RoomCover, + OwnerUserID: row.OwnerUserID, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + TurnoverAmount: row.TurnoverAmount, + Level: row.Level, + TurnoverThreshold: row.TurnoverThreshold, + RewardGold: row.RewardGold, + TrackID: row.TrackID, + Status: row.Status, + RetryCount: row.RetryCount, + LastError: row.LastError, + SentAt: formatDateTime(pointerTimeValue(row.SentAt)), + UpdateTime: formatDateTime(row.UpdateTime), + } +} + +func normalizePage(cursor, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func formatDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func pointerTimeValue(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return *value +} + +func truncateString(value string, limit int) string { + if limit <= 0 || len(value) <= limit { + return value + } + return value[:limit] +} diff --git a/internal/service/roomturnoverreward/page.go b/internal/service/roomturnoverreward/page.go new file mode 100644 index 0000000..35da4d0 --- /dev/null +++ b/internal/service/roomturnoverreward/page.go @@ -0,0 +1,83 @@ +package roomturnoverreward + +import ( + "context" + "sort" + "time" +) + +const ( + roomTurnoverRewardActivityStatusDisabled = "DISABLED" + roomTurnoverRewardActivityStatusOngoing = "ONGOING" +) + +// GetPublicPage 返回房间流水奖励 H5 页面所需的公开配置数据。 +func (s *Service) GetPublicPage(ctx context.Context, sysOrigin string) (*RoomTurnoverRewardPageResponse, error) { + config, err := s.GetConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + + now := time.Now().In(s.location) + periodStart, periodEnd := s.currentCycleBounds(now) + levels := enabledPageLevels(config.LevelConfigs) + activityStatus := roomTurnoverRewardActivityStatusDisabled + if config.Enabled { + activityStatus = roomTurnoverRewardActivityStatusOngoing + } + + return &RoomTurnoverRewardPageResponse{ + ActivityStatus: activityStatus, + Configured: config.Configured, + SysOrigin: config.SysOrigin, + Timezone: config.Timezone, + CycleKey: s.cycleKey(periodStart), + PeriodStartAt: formatDateTime(periodStart), + PeriodEndAt: formatDateTime(periodEnd), + CurrentPeriodText: formatRoomTurnoverRewardPeriod(periodStart, periodEnd), + CountdownMillis: maxInt64(0, periodEnd.UnixMilli()-now.UnixMilli()), + HighlightRewardGold: highestRewardGold(levels), + LevelConfigs: levels, + }, nil +} + +func enabledPageLevels(levels []RoomTurnoverRewardLevelPayload) []RoomTurnoverRewardLevelPayload { + result := make([]RoomTurnoverRewardLevelPayload, 0, len(levels)) + for _, level := range levels { + if !level.Enabled { + continue + } + result = append(result, level) + } + if len(result) == 0 { + result = append(result, levels...) + } + sort.Slice(result, func(i, j int) bool { + return result[i].TurnoverThreshold < result[j].TurnoverThreshold + }) + return result +} + +func highestRewardGold(levels []RoomTurnoverRewardLevelPayload) int64 { + var reward int64 + for _, level := range levels { + if level.RewardGold > reward { + reward = level.RewardGold + } + } + return reward +} + +func formatRoomTurnoverRewardPeriod(start, end time.Time) string { + if start.IsZero() || end.IsZero() { + return "" + } + return start.Format("Jan 02 15:04") + " - " + end.Format("Jan 02 15:04") +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/service/roomturnoverreward/page_test.go b/internal/service/roomturnoverreward/page_test.go new file mode 100644 index 0000000..30d1528 --- /dev/null +++ b/internal/service/roomturnoverreward/page_test.go @@ -0,0 +1,64 @@ +package roomturnoverreward + +import ( + "context" + "testing" +) + +func TestGetPublicPageReturnsEnabledLevelsAndPeriod(t *testing.T) { + service, _ := newTestService(t) + ctx := context.Background() + + if _, err := service.SaveConfig(ctx, SaveRoomTurnoverRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RoomTurnoverRewardLevelInput{ + {Level: 2, TurnoverThreshold: 7000000, RewardGold: 350000, Enabled: true}, + {Level: 1, TurnoverThreshold: 1000000, RewardGold: 50000, Enabled: false}, + }, + }); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + resp, err := service.GetPublicPage(ctx, "likei") + if err != nil { + t.Fatalf("GetPublicPage() error = %v", err) + } + if resp.ActivityStatus != roomTurnoverRewardActivityStatusOngoing { + t.Fatalf("activityStatus = %q, want %q", resp.ActivityStatus, roomTurnoverRewardActivityStatusOngoing) + } + if resp.CycleKey == "" || resp.PeriodStartAt == "" || resp.PeriodEndAt == "" || resp.CurrentPeriodText == "" { + t.Fatalf("period fields are incomplete: %+v", resp) + } + if resp.CountdownMillis <= 0 { + t.Fatalf("countdownMillis = %d, want > 0", resp.CountdownMillis) + } + if len(resp.LevelConfigs) != 1 { + t.Fatalf("level count = %d, want 1", len(resp.LevelConfigs)) + } + if got := resp.LevelConfigs[0]; got.TurnoverThreshold != 7000000 || got.RewardGold != 350000 { + t.Fatalf("level = %+v, want 7M/350k", got) + } + if resp.HighlightRewardGold != 350000 { + t.Fatalf("highlightRewardGold = %d, want 350000", resp.HighlightRewardGold) + } +} + +func TestGetPublicPageFallsBackToDefaultLevelsWhenMissing(t *testing.T) { + service, _ := newTestService(t) + + resp, err := service.GetPublicPage(context.Background(), "") + if err != nil { + t.Fatalf("GetPublicPage() error = %v", err) + } + if resp.ActivityStatus != roomTurnoverRewardActivityStatusDisabled { + t.Fatalf("activityStatus = %q, want disabled", resp.ActivityStatus) + } + if len(resp.LevelConfigs) != 4 { + t.Fatalf("level count = %d, want 4", len(resp.LevelConfigs)) + } + if resp.HighlightRewardGold != 250000 { + t.Fatalf("highlightRewardGold = %d, want 250000", resp.HighlightRewardGold) + } +} diff --git a/internal/service/roomturnoverreward/records.go b/internal/service/roomturnoverreward/records.go new file mode 100644 index 0000000..a6084c8 --- /dev/null +++ b/internal/service/roomturnoverreward/records.go @@ -0,0 +1,51 @@ +package roomturnoverreward + +import ( + "context" + "strings" + + "chatapp3-golang/internal/model" +) + +// PageRewardRecords 分页查询每周房间流水奖励发放记录。 +func (s *Service) PageRewardRecords(ctx context.Context, sysOrigin, cycleKey, status string, cursor, limit int) (map[string]any, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + page, size := normalizePage(cursor, limit) + + query := s.repo.DB.WithContext(ctx). + Model(&model.RoomTurnoverRewardRecord{}). + Where("sys_origin = ?", sysOrigin) + if cycleKey = strings.TrimSpace(cycleKey); cycleKey != "" { + query = query.Where("cycle_key = ?", cycleKey) + } + if status = strings.ToUpper(strings.TrimSpace(status)); status != "" { + query = query.Where("status = ?", status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.RoomTurnoverRewardRecord + if total > 0 { + if err := query. + Order("cycle_key desc"). + Order("turnover_amount desc"). + Order("id desc"). + Offset((page - 1) * size). + Limit(size). + Find(&rows).Error; err != nil { + return nil, err + } + } + + records := make([]RoomTurnoverRewardRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toRecordView(row)) + } + return map[string]any{ + "records": records, + "total": total, + }, nil +} diff --git a/internal/service/roomturnoverreward/types.go b/internal/service/roomturnoverreward/types.go new file mode 100644 index 0000000..b078530 --- /dev/null +++ b/internal/service/roomturnoverreward/types.go @@ -0,0 +1,291 @@ +package roomturnoverreward + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +const ( + roomTurnoverRewardStatusPending = "PENDING" + roomTurnoverRewardStatusSuccess = "SUCCESS" + roomTurnoverRewardStatusFailed = "FAILED" + + defaultRoomTurnoverRewardTimezone = "Asia/Riyadh" +) + +// flexibleInt64 兼容后台以字符串传递 Snowflake ID 的情况。 +type flexibleInt64 int64 + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = 0 + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + raw = strings.Trim(raw, "\"") + } + if raw == "" { + *v = 0 + return nil + } + if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil { + *v = flexibleInt64(parsed) + return nil + } + parsed, err := strconv.ParseFloat(raw, 64) + if err != nil { + return err + } + *v = flexibleInt64(int64(math.Round(parsed))) + return nil +} + +func (v flexibleInt64) Int64() int64 { + return int64(v) +} + +func (v flexibleInt64) MarshalJSON() ([]byte, error) { + return json.Marshal(strconv.FormatInt(int64(v), 10)) +} + +// RoomTurnoverRewardLevelPayload 是后台展示的奖励档位。 +type RoomTurnoverRewardLevelPayload struct { + ID int64 `json:"id,string"` + Level int `json:"level"` + TurnoverThreshold int64 `json:"turnoverThreshold"` + RewardGold int64 `json:"rewardGold"` + Enabled bool `json:"enabled"` +} + +// RoomTurnoverRewardLevelInput 是后台保存奖励档位的入参。 +type RoomTurnoverRewardLevelInput struct { + ID flexibleInt64 `json:"id"` + Level int `json:"level"` + TurnoverThreshold int64 `json:"turnoverThreshold"` + RewardGold int64 `json:"rewardGold"` + Enabled bool `json:"enabled"` +} + +// 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"` +} + +// SaveRoomTurnoverRewardConfigRequest 是房间流水奖励配置保存入参。 +type SaveRoomTurnoverRewardConfigRequest struct { + ID flexibleInt64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + LevelConfigs []RoomTurnoverRewardLevelInput `json:"levelConfigs"` +} + +// RoomTurnoverRewardRecordView 是后台发奖记录列表项。 +type RoomTurnoverRewardRecordView struct { + ID int64 `json:"id,string"` + ConfigID int64 `json:"configId,string"` + SysOrigin string `json:"sysOrigin"` + CycleKey string `json:"cycleKey"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + RoomID int64 `json:"roomId,string"` + RoomAccount string `json:"roomAccount"` + RoomName string `json:"roomName"` + RoomCover string `json:"roomCover"` + OwnerUserID int64 `json:"ownerUserId,string"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + TurnoverAmount int64 `json:"turnoverAmount"` + Level int `json:"level"` + TurnoverThreshold int64 `json:"turnoverThreshold"` + RewardGold int64 `json:"rewardGold"` + TrackID string `json:"trackId"` + Status string `json:"status"` + RetryCount int `json:"retryCount"` + LastError string `json:"lastError"` + SentAt string `json:"sentAt"` + UpdateTime string `json:"updateTime"` +} + +// RoomTurnoverRewardCurrentRankItem 是当前房间流水排行榜列表项。 +type RoomTurnoverRewardCurrentRankItem struct { + Rank int `json:"rank"` + RoomID int64 `json:"roomId,string"` + RoomAccount string `json:"roomAccount"` + RoomName string `json:"roomName"` + RoomCover string `json:"roomCover"` + OwnerUserID int64 `json:"ownerUserId,string"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + TurnoverAmount int64 `json:"turnoverAmount"` + DateNumber int `json:"dateNumber"` + MatchedLevel int `json:"matchedLevel"` + TurnoverThreshold int64 `json:"turnoverThreshold"` + ExpectedRewardGold int64 `json:"expectedRewardGold"` +} + +// RoomTurnoverRewardCurrentRankResponse 是当前房间流水排行榜读模型。 +type RoomTurnoverRewardCurrentRankResponse struct { + SysOrigin string `json:"sysOrigin"` + Timezone string `json:"timezone"` + CycleKey string `json:"cycleKey"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + DateNumber int `json:"dateNumber"` + Records []RoomTurnoverRewardCurrentRankItem `json:"records"` +} + +// RoomTurnoverRewardPageResponse 是房间流水奖励 H5 页面读模型。 +type RoomTurnoverRewardPageResponse struct { + ActivityStatus string `json:"activityStatus"` + Configured bool `json:"configured"` + SysOrigin string `json:"sysOrigin"` + Timezone string `json:"timezone"` + CycleKey string `json:"cycleKey"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + CurrentPeriodText string `json:"currentPeriodText"` + CountdownMillis int64 `json:"countdownMillis"` + HighlightRewardGold int64 `json:"highlightRewardGold"` + LevelConfigs []RoomTurnoverRewardLevelPayload `json:"levelConfigs"` +} + +// RoomTurnoverRewardHomeResponse 是房间流水奖励 H5 用户态首页读模型。 +type RoomTurnoverRewardHomeResponse struct { + ActivityStatus string `json:"activityStatus"` + Configured bool `json:"configured"` + SysOrigin string `json:"sysOrigin"` + Timezone string `json:"timezone"` + CycleKey string `json:"cycleKey"` + PeriodStartAt string `json:"periodStartAt"` + PeriodEndAt string `json:"periodEndAt"` + CurrentPeriodText string `json:"currentPeriodText"` + CountdownMillis int64 `json:"countdownMillis"` + UserID int64 `json:"userId,string"` + UserAccount string `json:"userAccount"` + UserNickname string `json:"userNickname"` + UserAvatar string `json:"userAvatar"` + HasRoom bool `json:"hasRoom"` + RoomID int64 `json:"roomId,string"` + RoomAccount string `json:"roomAccount"` + RoomName string `json:"roomName"` + RoomCover string `json:"roomCover"` + TurnoverAmount int64 `json:"turnoverAmount"` + MatchedLevel int `json:"matchedLevel"` + TurnoverThreshold int64 `json:"turnoverThreshold"` + ExpectedRewardGold int64 `json:"expectedRewardGold"` + HighlightRewardGold int64 `json:"highlightRewardGold"` + LevelConfigs []RoomTurnoverRewardLevelPayload `json:"levelConfigs"` +} + +type roomTurnoverRewardDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type roomTurnoverRewardJavaGateway interface { + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) + GetRoomProfileByUserID(ctx context.Context, userID int64) (integration.RoomProfile, error) + 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) +} + +type roomTurnoverRewardPorts struct { + DB roomTurnoverRewardDB + Java roomTurnoverRewardJavaGateway +} + +// Service 负责房间流水奖励配置与后台记录查询。 +type Service struct { + cfg config.Config + repo roomTurnoverRewardPorts + location *time.Location +} + +func NewService(cfg config.Config, db roomTurnoverRewardDB, javaGateway ...roomTurnoverRewardJavaGateway) *Service { + timezone := strings.TrimSpace(cfg.RoomTurnoverReward.Timezone) + if timezone == "" { + timezone = strings.TrimSpace(cfg.WeekStar.Timezone) + } + if timezone == "" { + timezone = defaultRoomTurnoverRewardTimezone + } + location, err := time.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + var java roomTurnoverRewardJavaGateway + if len(javaGateway) > 0 { + java = javaGateway[0] + } + return &Service{ + cfg: cfg, + repo: roomTurnoverRewardPorts{DB: db, Java: java}, + location: location, + } +} + +func (s *Service) cycleDuration() time.Duration { + duration := s.cfg.RoomTurnoverReward.CycleDuration + if duration <= 0 { + duration = 10 * time.Second + } + return duration +} + +func (s *Service) currentCycleBounds(now time.Time) (time.Time, time.Time) { + duration := s.cycleDuration() + local := now.In(s.location) + if duration >= 7*24*time.Hour { + start := s.weekCycleStart(local) + return start, start.AddDate(0, 0, 7) + } + anchor := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, s.location) + elapsed := local.Sub(anchor) + cycles := int64(elapsed / duration) + start := anchor.Add(time.Duration(cycles) * duration) + return start, start.Add(duration) +} + +func (s *Service) weekCycleStart(t time.Time) time.Time { + local := t.In(s.location) + weekday := int(local.Weekday()) + if weekday == 0 { + weekday = 7 + } + startDay := local.AddDate(0, 0, -(weekday - 1)) + return time.Date(startDay.Year(), startDay.Month(), startDay.Day(), 0, 0, 0, 0, s.location) +} + +func (s *Service) cycleKey(start time.Time) string { + if s.cycleDuration() >= 7*24*time.Hour { + return start.In(s.location).Format("20060102") + } + return start.In(s.location).Format("20060102150405") +} + +func (s *Service) currentDateNumber(now time.Time) (int, error) { + raw := s.weekCycleStart(now).Format("20060102") + dateNumber, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("parse current date number: %w", err) + } + return dateNumber, nil +} diff --git a/internal/service/weekstar/config.go b/internal/service/weekstar/config.go index 153a105..48cef7a 100644 --- a/internal/service/weekstar/config.go +++ b/internal/service/weekstar/config.go @@ -176,23 +176,20 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig if _, exists := seenRanks[item.Rank]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks") } - rewardItems, rewardGroupName, err := s.loadRewardPreviewItems(ctx, rewardGroupID) - if err != nil || len(rewardItems) == 0 { - return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "reward group detail is empty or unavailable") + rewardGroupName := strings.TrimSpace(item.RewardGroupName) + if _, loadedRewardGroupName, err := s.loadRewardPreviewItems(ctx, rewardGroupID); err == nil && strings.TrimSpace(loadedRewardGroupName) != "" { + rewardGroupName = loadedRewardGroupName } seenRanks[item.Rank] = struct{}{} id, idErr := utils.NextID() if idErr != nil { return nil, idErr } - if strings.TrimSpace(rewardGroupName) == "" { - rewardGroupName = item.RewardGroupName - } normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{ ID: id, Rank: item.Rank, RewardGroupID: rewardGroupID, - RewardGroupName: strings.TrimSpace(rewardGroupName), + RewardGroupName: rewardGroupName, }) } diff --git a/internal/service/weekstar/config_test.go b/internal/service/weekstar/config_test.go index 43cc997..c939f32 100644 --- a/internal/service/weekstar/config_test.go +++ b/internal/service/weekstar/config_test.go @@ -3,6 +3,7 @@ package weekstar import ( "context" "encoding/json" + "errors" "strings" "testing" "time" @@ -41,7 +42,19 @@ func (g weekStarTestGateway) GetUserProfile(context.Context, int64) (integration return integration.UserProfile{}, nil } +type unavailableRewardDetailGateway struct { + weekStarTestGateway +} + +func (g unavailableRewardDetailGateway) GetRewardGroupDetail(context.Context, int64) (integration.RewardGroupDetail, error) { + return integration.RewardGroupDetail{}, errors.New("java reward detail unavailable") +} + func newTestWeekStarService(t *testing.T) (*WeekStarService, *gorm.DB) { + return newTestWeekStarServiceWithGateway(t, weekStarTestGateway{}) +} + +func newTestWeekStarServiceWithGateway(t *testing.T, gateway weekStarGateway) (*WeekStarService, *gorm.DB) { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) @@ -62,7 +75,7 @@ func newTestWeekStarService(t *testing.T) (*WeekStarService, *gorm.DB) { DefaultSysOrigin: "LIKEI", Timezone: "Asia/Riyadh", }, - }, db, nil, weekStarTestGateway{}) + }, db, nil, gateway) return service, db } @@ -153,6 +166,42 @@ func TestSaveConfigAllowsCurrentWeekWhenNoActiveConfigExists(t *testing.T) { } } +func TestSaveConfigAllowsRewardGroupWhenDetailUnavailable(t *testing.T) { + service, _ := newTestWeekStarServiceWithGateway(t, unavailableRewardDetailGateway{}) + now := time.Now().In(service.location) + cycleStart, cycleEnd := service.cycleBounds(now) + + resp, err := service.SaveConfig(context.Background(), SaveWeekStarConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + StartAt: formatDateTime(cycleStart), + EndAt: formatDateTime(cycleEnd), + Timezone: "Asia/Riyadh", + GiftConfigs: []WeekStarGiftConfigInput{ + {GiftID: flexibleInt64(101), GiftName: "Gift 1", Sort: 1}, + {GiftID: flexibleInt64(102), GiftName: "Gift 2", Sort: 2}, + {GiftID: flexibleInt64(103), GiftName: "Gift 3", Sort: 3}, + }, + RewardConfigs: []WeekStarRewardConfigInput{ + {Rank: 1, RewardGroupID: flexibleInt64(2004471533988167125), RewardGroupName: "Top1 Fallback"}, + {Rank: 2, RewardGroupID: flexibleInt64(2004471533988167126), RewardGroupName: "Top2 Fallback"}, + {Rank: 3, RewardGroupID: flexibleInt64(2004471533988167127), RewardGroupName: "Top3 Fallback"}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + if resp == nil || len(resp.RewardConfigs) != 3 { + t.Fatalf("SaveConfig() response = %#v, want 3 reward configs", resp) + } + if got := resp.RewardConfigs[0].RewardGroupName; got != "Top1 Fallback" { + t.Fatalf("rewardGroupName = %q, want fallback name", got) + } + if len(resp.RewardConfigs[0].Items) != 0 { + t.Fatalf("reward items length = %d, want 0 when detail is unavailable", len(resp.RewardConfigs[0].Items)) + } +} + func TestSaveConfigRejectsCurrentWeekWhenActiveConfigExists(t *testing.T) { service, db := newTestWeekStarService(t) now := time.Now().In(service.location) diff --git a/migrations/017_room_turnover_reward.sql b/migrations/017_room_turnover_reward.sql new file mode 100644 index 0000000..878dc47 --- /dev/null +++ b/migrations/017_room_turnover_reward.sql @@ -0,0 +1,61 @@ +CREATE TABLE IF NOT EXISTS `room_turnover_reward_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '结算时区', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_room_turnover_reward_sys_origin` (`sys_origin`), + KEY `idx_room_turnover_reward_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励主配置'; + +CREATE TABLE IF NOT EXISTS `room_turnover_reward_level` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `level` int NOT NULL COMMENT '奖励等级', + `turnover_threshold` bigint NOT NULL COMMENT '流水门槛', + `reward_gold` bigint NOT NULL COMMENT '奖励房主金币', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_room_turnover_reward_level` (`config_id`, `level`), + UNIQUE KEY `uk_room_turnover_reward_threshold` (`config_id`, `turnover_threshold`), + KEY `idx_room_turnover_reward_level_config` (`config_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励等级配置'; + +CREATE TABLE IF NOT EXISTS `room_turnover_reward_record` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `cycle_key` varchar(32) NOT NULL COMMENT '周期标识,取周一 yyyyMMdd', + `period_start_at` datetime(3) NOT NULL COMMENT '周期开始时间', + `period_end_at` datetime(3) NOT NULL COMMENT '周期结束时间', + `room_id` bigint NOT NULL COMMENT '房间ID', + `room_account` varchar(64) DEFAULT NULL COMMENT '房间账号快照', + `room_name` varchar(255) DEFAULT NULL COMMENT '房间名称快照', + `room_cover` varchar(1024) DEFAULT NULL COMMENT '房间封面快照', + `owner_user_id` bigint NOT NULL COMMENT '房主用户ID', + `country_code` varchar(32) DEFAULT NULL COMMENT '国家编码', + `country_name` varchar(128) DEFAULT NULL COMMENT '国家名称', + `turnover_amount` bigint NOT NULL DEFAULT '0' COMMENT '本周期房间流水', + `level` int NOT NULL COMMENT '命中的最高奖励等级', + `turnover_threshold` bigint NOT NULL COMMENT '命中的流水门槛', + `reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖励金币', + `track_id` varchar(128) NOT NULL COMMENT '钱包发奖幂等ID', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '发奖状态', + `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', + `last_error` varchar(1024) DEFAULT NULL COMMENT '最近失败原因', + `sent_at` datetime(3) DEFAULT NULL COMMENT '发放时间', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_room_turnover_reward_record_room` (`sys_origin`, `cycle_key`, `room_id`), + UNIQUE KEY `uk_room_turnover_reward_track` (`track_id`), + KEY `idx_room_turnover_reward_record_cycle` (`sys_origin`, `cycle_key`), + KEY `idx_room_turnover_reward_record_config` (`config_id`), + KEY `idx_room_turnover_reward_record_room` (`room_id`), + KEY `idx_room_turnover_reward_record_owner` (`owner_user_id`), + KEY `idx_room_turnover_reward_record_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励发放记录';