主播中心/邀请活动

This commit is contained in:
hy001 2026-04-27 23:27:37 +08:00
parent 7c1ecb804e
commit 4fc2ddebf1
28 changed files with 2435 additions and 37 deletions

View File

@ -6,10 +6,12 @@ import (
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
"context" "context"
@ -39,6 +41,8 @@ func main() {
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) 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) 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) 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()) workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() defer workerCancel()
@ -57,7 +61,9 @@ func main() {
GameOpen: gameOpenService, GameOpen: gameOpenService,
GameProviders: gameProviders, GameProviders: gameProviders,
LuckyGift: luckyGiftService, LuckyGift: luckyGiftService,
HostCenter: hostCenterService,
RegisterReward: registerRewardService, RegisterReward: registerRewardService,
RoomTurnoverReward: roomTurnoverRewardService,
SignInReward: signInRewardService, SignInReward: signInRewardService,
WeekStar: weekStarService, WeekStar: weekStarService,
}) })

View File

@ -16,6 +16,7 @@ type Config struct {
Worker WorkerConfig Worker WorkerConfig
Invite InviteConfig Invite InviteConfig
WeekStar WeekStarConfig WeekStar WeekStarConfig
RoomTurnoverReward RoomTurnoverRewardConfig
Baishun BaishunConfig Baishun BaishunConfig
Lingxian LingxianConfig Lingxian LingxianConfig
GameOpen GameOpenConfig GameOpen GameOpenConfig
@ -92,6 +93,13 @@ type WeekStarRocketMQConfig struct {
Tag string Tag string
} }
// RoomTurnoverRewardConfig 保存房间流水奖励模块配置。
type RoomTurnoverRewardConfig struct {
DefaultSysOrigin string
Timezone string
CycleDuration time.Duration
}
// BaishunConfig 保存百顺模块配置。 // BaishunConfig 保存百顺模块配置。
type BaishunConfig struct { type BaishunConfig struct {
PlatformBaseURL string PlatformBaseURL string
@ -194,6 +202,20 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_WEEK_STAR_ROCKETMQ_TAG", "WEEK_STAR_ROCKETMQ_TAG"}, "give_gift_v3"), 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{ Baishun: BaishunConfig{
PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""), PlatformBaseURL: getEnvAny([]string{"CHATAPP_BAISHUN_PLATFORM_BASE_URL", "BAISHUN_PLATFORM_BASE_URL"}, ""),
GameListURL: getEnvAny([]string{"CHATAPP_BAISHUN_GAME_LIST_URL", "BAISHUN_GAME_LIST_URL"}, ""), GameListURL: getEnvAny([]string{"CHATAPP_BAISHUN_GAME_LIST_URL", "BAISHUN_GAME_LIST_URL"}, ""),

View File

@ -15,6 +15,8 @@ type Gateways struct {
RewardDispatch *RewardDispatchGateway RewardDispatch *RewardDispatchGateway
RewardQuery *RewardQueryGateway RewardQuery *RewardQueryGateway
Profile *ProfileGateway Profile *ProfileGateway
Room *RoomGateway
Team *TeamGateway
Wallet *WalletGateway Wallet *WalletGateway
} }
@ -29,6 +31,8 @@ func NewGateways(cfg config.Config) Gateways {
RewardDispatch: &RewardDispatchGateway{client: client}, RewardDispatch: &RewardDispatchGateway{client: client},
RewardQuery: &RewardQueryGateway{client: client}, RewardQuery: &RewardQueryGateway{client: client},
Profile: &ProfileGateway{client: client}, Profile: &ProfileGateway{client: client},
Room: &RoomGateway{client: client},
Team: &TeamGateway{client: client},
Wallet: &WalletGateway{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) 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 透传到资料网关。 // GetUserProfileByAccount 透传到资料网关。
func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account) 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) 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 透传到钱包网关。 // MapGoldBalance 透传到钱包网关。
func (g *Gateways) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { func (g *Gateways) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
return g.Wallet.MapGoldBalance(ctx, userIDs) 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) 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 根据账号查询用户资料。 // GetUserProfileByAccount 根据账号查询用户资料。
func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
return g.client.GetUserProfileByAccount(ctx, sysOrigin, account) 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) 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 负责钱包相关接口。 // WalletGateway 负责钱包相关接口。
type WalletGateway struct { type WalletGateway struct {
client *Client client *Client

View File

@ -41,6 +41,36 @@ type UserProfile struct {
Account string `json:"account"` Account string `json:"account"`
UserAvatar string `json:"userAvatar"` UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"` 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 { type GoldReceiptCommand struct {
@ -104,6 +134,37 @@ type SendActivityRewardRequest struct {
AcceptUserID int64 `json:"acceptUserId"` 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 表示发送自定义官方通知的请求体。 // OfficialNoticeCustomizeRequest 表示发送自定义官方通知的请求体。
type OfficialNoticeCustomizeRequest struct { type OfficialNoticeCustomizeRequest struct {
ToAccounts []int64 `json:"toAccounts"` ToAccounts []int64 `json:"toAccounts"`
@ -157,6 +218,57 @@ func (v *Int64Value) UnmarshalJSON(data []byte) error {
return nil 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 { func New(cfg config.Config) *Client {
return &Client{ return &Client{
cfg: cfg, cfg: cfg,
@ -383,6 +495,22 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil 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) { func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" + endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" +
url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account)) 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 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 { func (c *Client) rewardBridgeBaseURL() string {
if baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/"); baseURL != "" { if baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/"); baseURL != "" {
return baseURL return baseURL

View File

@ -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" }

View File

@ -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 ""
}

View File

@ -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)
}
}

View File

@ -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)
})
}

View File

@ -9,10 +9,12 @@ import (
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter"
"chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/invite"
"chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/lingxian"
"chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/luckygift"
"chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/weekstar" "chatapp3-golang/internal/service/weekstar"
@ -27,7 +29,9 @@ type Services struct {
GameOpen *gameopen.GameOpenService GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry GameProviders *gameprovider.Registry
LuckyGift *luckygift.LuckyGiftService LuckyGift *luckygift.LuckyGiftService
HostCenter *hostcenter.Service
RegisterReward *registerreward.RegisterRewardService RegisterReward *registerreward.RegisterRewardService
RoomTurnoverReward *roomturnoverreward.Service
SignInReward *signinreward.SignInRewardService SignInReward *signinreward.SignInRewardService
WeekStar *weekstar.WeekStarService WeekStar *weekstar.WeekStarService
} }
@ -40,13 +44,15 @@ func NewRouter(
services Services, services Services,
) *gin.Engine { ) *gin.Engine {
engine := gin.New() engine := gin.New()
engine.Use(gin.Logger(), gin.Recovery()) engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware())
registerHealthRoutes(engine, cfg, repository) registerHealthRoutes(engine, cfg, repository)
registerInviteRoutes(engine, javaClient, services.Invite) registerInviteRoutes(engine, javaClient, services.Invite)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders) registerGameProviderRoutes(engine, javaClient, services.GameProviders)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
@ -56,6 +62,28 @@ func NewRouter(
return engine 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 注册健康检查接口。 // registerHealthRoutes 注册健康检查接口。
func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *repo.Repository) { func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *repo.Repository) {
engine.GET("/health", func(c *gin.Context) { engine.GET("/health", func(c *gin.Context) {

View File

@ -0,0 +1,7 @@
package hostcenter
import "chatapp3-golang/internal/common"
type AppError = common.AppError
var NewAppError = common.NewAppError

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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,
}
}

View File

@ -0,0 +1,7 @@
package roomturnoverreward
import "chatapp3-golang/internal/common"
type AppError = common.AppError
var NewAppError = common.NewAppError

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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]
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -176,23 +176,20 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig
if _, exists := seenRanks[item.Rank]; exists { if _, exists := seenRanks[item.Rank]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks") return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks")
} }
rewardItems, rewardGroupName, err := s.loadRewardPreviewItems(ctx, rewardGroupID) rewardGroupName := strings.TrimSpace(item.RewardGroupName)
if err != nil || len(rewardItems) == 0 { if _, loadedRewardGroupName, err := s.loadRewardPreviewItems(ctx, rewardGroupID); err == nil && strings.TrimSpace(loadedRewardGroupName) != "" {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "reward group detail is empty or unavailable") rewardGroupName = loadedRewardGroupName
} }
seenRanks[item.Rank] = struct{}{} seenRanks[item.Rank] = struct{}{}
id, idErr := utils.NextID() id, idErr := utils.NextID()
if idErr != nil { if idErr != nil {
return nil, idErr return nil, idErr
} }
if strings.TrimSpace(rewardGroupName) == "" {
rewardGroupName = item.RewardGroupName
}
normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{ normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{
ID: id, ID: id,
Rank: item.Rank, Rank: item.Rank,
RewardGroupID: rewardGroupID, RewardGroupID: rewardGroupID,
RewardGroupName: strings.TrimSpace(rewardGroupName), RewardGroupName: rewardGroupName,
}) })
} }

View File

@ -3,6 +3,7 @@ package weekstar
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -41,7 +42,19 @@ func (g weekStarTestGateway) GetUserProfile(context.Context, int64) (integration
return integration.UserProfile{}, nil 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) { func newTestWeekStarService(t *testing.T) (*WeekStarService, *gorm.DB) {
return newTestWeekStarServiceWithGateway(t, weekStarTestGateway{})
}
func newTestWeekStarServiceWithGateway(t *testing.T, gateway weekStarGateway) (*WeekStarService, *gorm.DB) {
t.Helper() t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
@ -62,7 +75,7 @@ func newTestWeekStarService(t *testing.T) (*WeekStarService, *gorm.DB) {
DefaultSysOrigin: "LIKEI", DefaultSysOrigin: "LIKEI",
Timezone: "Asia/Riyadh", Timezone: "Asia/Riyadh",
}, },
}, db, nil, weekStarTestGateway{}) }, db, nil, gateway)
return service, db 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) { func TestSaveConfigRejectsCurrentWeekWhenActiveConfigExists(t *testing.T) {
service, db := newTestWeekStarService(t) service, db := newTestWeekStarService(t)
now := time.Now().In(service.location) now := time.Now().In(service.location)

View File

@ -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='房间流水奖励发放记录';