主播中心/邀请活动

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/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,

View File

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

View File

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

View File

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

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/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) {

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 {
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,
})
}

View File

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

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