diff --git a/cmd/api/main.go b/cmd/api/main.go index be9746f..eca1078 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -20,6 +20,7 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/managercenter" "chatapp3-golang/internal/service/rechargeagency" "chatapp3-golang/internal/service/rechargereward" "chatapp3-golang/internal/service/regionimgroup" @@ -59,6 +60,7 @@ func main() { 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) + managerCenterService := managercenter.NewService(app.Repository.DB, &app.Gateways) baishunService.SetTaskEventReporter(taskCenterService) gameOpenService.SetTaskEventReporter(taskCenterService) @@ -86,6 +88,7 @@ func main() { GameOpen: gameOpenService, GameProviders: gameProviders, LuckyGift: luckyGiftService, + ManagerCenter: managerCenterService, HostCenter: hostCenterService, RechargeAgency: rechargeAgencyService, RechargeReward: rechargeRewardService, diff --git a/internal/config/config.go b/internal/config/config.go index 95fc1ff..b624fdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -215,7 +215,7 @@ func Load() Config { BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL), ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"), OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL), - OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL"}, "http://127.0.0.1:2600"), + OrderBaseURL: getEnvAny([]string{"CHATAPP_JAVA_ORDER_BASE_URL", "GAME_JAVA_ORDER_BASE_URL", "LIKEI_GATEWAY_ROUTE_ORDER_URI"}, "http://127.0.0.1:2600"), WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"), }, Worker: WorkerConfig{ diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 207fb91..72b49db 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -96,6 +96,16 @@ func (g *Gateways) GivePropsBackpack(ctx context.Context, req GivePropsBackpackR return g.RewardDispatch.GivePropsBackpack(ctx, req) } +// GetNobleVIPAbility 透传到贵族 VIP 能力配置接口。 +func (g *Gateways) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) { + return g.RewardQuery.GetNobleVIPAbility(ctx, sourceID) +} + +// GetUserMaxNobleVIPAbility 透传到用户当前最高贵族 VIP 能力配置接口。 +func (g *Gateways) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) { + return g.RewardQuery.GetUserMaxNobleVIPAbility(ctx, userID) +} + // SwitchUseProps 透传到道具使用切换接口。 func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { return g.RewardDispatch.SwitchUseProps(ctx, userID, propsID) @@ -151,6 +161,11 @@ func (g *Gateways) RemoveUserProfileCacheAll(ctx context.Context, userID int64) return g.Profile.RemoveUserProfileCacheAll(ctx, userID) } +// PunishAccount 透传到账号处罚接口。 +func (g *Gateways) PunishAccount(ctx context.Context, req PunishAccountRequest) error { + return g.Profile.PunishAccount(ctx, req) +} + // ListCurrentWeekRoomContribution 透传到房间流水网关。 func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) { return g.Room.ListCurrentWeekRoomContribution(ctx, limit) @@ -306,6 +321,16 @@ func (g *RewardQueryGateway) GetRewardGroupDetail(ctx context.Context, groupID i return g.client.GetRewardGroupDetail(ctx, groupID) } +// GetNobleVIPAbility 查询贵族 VIP 能力配置。 +func (g *RewardQueryGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) { + return g.client.GetNobleVIPAbility(ctx, sourceID) +} + +// GetUserMaxNobleVIPAbility 查询用户当前最高贵族 VIP 能力配置。 +func (g *RewardQueryGateway) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) { + return g.client.GetUserMaxNobleVIPAbility(ctx, userID) +} + // ProfileGateway 负责用户资料相关接口。 type ProfileGateway struct { client *Client @@ -346,6 +371,11 @@ func (g *ProfileGateway) RemoveUserProfileCacheAll(ctx context.Context, userID i return g.client.RemoveUserProfileCacheAll(ctx, userID) } +// PunishAccount 处罚用户账号。 +func (g *ProfileGateway) PunishAccount(ctx context.Context, req PunishAccountRequest) error { + return g.client.PunishAccount(ctx, req) +} + // RoomGateway 负责房间资料和房间流水相关接口。 type RoomGateway struct { client *Client diff --git a/internal/integration/java.go b/internal/integration/java.go index 07c84a6..3665672 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -42,15 +42,16 @@ type ConsoleAccount struct { } type UserProfile struct { - ID Int64Value `json:"id"` - 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"` + ID Int64Value `json:"id"` + 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"` + AccountStatus string `json:"accountStatus"` } type UserRegion struct { @@ -159,6 +160,25 @@ type GivePropsBackpackRequest struct { OpUser int64 `json:"opUser,omitempty"` } +type PropsNobleVIPAbility struct { + ID Int64Value `json:"id"` + VIPType string `json:"vipType"` + VIPLevel int `json:"vipLevel"` + AdminNumber int `json:"adminNumber"` + RoomMaxMember int `json:"roomMaxMember"` + AvatarFrameID Int64Value `json:"avatarFrameId"` + CarID Int64Value `json:"carId"` + ChatBubbleID Int64Value `json:"chatBubbleId"` + DataCardID Int64Value `json:"dataCardId"` +} + +type PunishAccountRequest struct { + BeOptUserID int64 `json:"beOptUserId"` + OptUserID int64 `json:"optUserId"` + Status string `json:"status"` + Description string `json:"description"` +} + type SendActivityRewardRequest struct { TrackID int64 `json:"trackId"` Origin string `json:"origin"` @@ -595,6 +615,26 @@ func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackReq return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props/client/giveProps", req, nil, nil) } +func (c *Client) GetNobleVIPAbility(ctx context.Context, sourceID int64) (PropsNobleVIPAbility, error) { + endpoint := c.cfg.Java.OtherBaseURL + "/props/noble-vip/client/getAbilityDTO?sourceId=" + + url.QueryEscape(strconv.FormatInt(sourceID, 10)) + var resp resultResponse[PropsNobleVIPAbility] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return PropsNobleVIPAbility{}, err + } + return resp.Body, nil +} + +func (c *Client) GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (PropsNobleVIPAbility, error) { + endpoint := c.cfg.Java.OtherBaseURL + "/props/noble-vip/client/getUserMaxAbilityDTO?userId=" + + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[PropsNobleVIPAbility] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return PropsNobleVIPAbility{}, err + } + return resp.Body, nil +} + func (c *Client) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { endpoint := c.cfg.Java.OtherBaseURL + "/props/client/switch/use-props?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + @@ -926,6 +966,10 @@ func (c *Client) RemoveUserProfileCacheAll(ctx context.Context, userID int64) er return c.getJSON(ctx, endpoint, nil, nil) } +func (c *Client) PunishAccount(ctx context.Context, req PunishAccountRequest) error { + return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/punishment", req, nil, nil) +} + func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance" var resp resultResponse[map[string]int64] diff --git a/internal/router/manager_center_routes.go b/internal/router/manager_center_routes.go new file mode 100644 index 0000000..d54895a --- /dev/null +++ b/internal/router/manager_center_routes.go @@ -0,0 +1,76 @@ +package router + +import ( + "net/http" + "strings" + + "chatapp3-golang/internal/service/managercenter" + + "github.com/gin-gonic/gin" +) + +// registerManagerCenterRoutes 注册经理中心 H5 路由。 +func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, service *managercenter.Service) { + if service == nil { + return + } + + group := engine.Group("/app/h5/manager-center") + group.Use(authMiddleware(javaClient)) + + group.GET("/profile", func(c *gin.Context) { + resp, err := service.GetProfile(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/users/search", func(c *gin.Context) { + account := strings.TrimSpace(c.Query("account")) + resp, err := service.SearchUser(c.Request.Context(), mustAuthUser(c), account) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.GET("/props", func(c *gin.Context) { + resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.POST("/props/send", func(c *gin.Context) { + var req managercenter.SendPropsRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SendProps(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + group.POST("/users/ban", func(c *gin.Context) { + var req managercenter.BanUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.BanUser(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 168c09d..df47e4e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -14,6 +14,7 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/managercenter" "chatapp3-golang/internal/service/rechargeagency" "chatapp3-golang/internal/service/rechargereward" "chatapp3-golang/internal/service/regionimgroup" @@ -37,6 +38,7 @@ type Services struct { GameOpen *gameopen.GameOpenService GameProviders *gameprovider.Registry LuckyGift *luckygift.LuckyGiftService + ManagerCenter *managercenter.Service HostCenter *hostcenter.Service RechargeAgency *rechargeagency.Service RechargeReward *rechargereward.Service @@ -79,6 +81,7 @@ func NewRouter( registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) + registerManagerCenterRoutes(engine, javaClient, services.ManagerCenter) return engine } diff --git a/internal/service/managercenter/service.go b/internal/service/managercenter/service.go new file mode 100644 index 0000000..3424421 --- /dev/null +++ b/internal/service/managercenter/service.go @@ -0,0 +1,654 @@ +package managercenter + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/gorm" +) + +type administratorRow struct { + ID int64 `gorm:"column:id;primaryKey"` + UserID int64 `gorm:"column:user_id"` + Status bool `gorm:"column:status"` +} + +func (administratorRow) TableName() string { + return "sys_administrator" +} + +type userBaseInfoRow struct { + ID int64 `gorm:"column:id;primaryKey"` + Account string `gorm:"column:account"` + UserAvatar string `gorm:"column:user_avatar"` + UserNickname string `gorm:"column:user_nickname"` + CountryCode string `gorm:"column:country_code"` + CountryName string `gorm:"column:country_name"` + OriginSys string `gorm:"column:origin_sys"` + AccountStatus string `gorm:"column:account_status"` + IsDel bool `gorm:"column:is_del"` +} + +func (userBaseInfoRow) TableName() string { + return "user_base_info" +} + +type propsSourceRecordRow struct { + ID int64 `gorm:"column:id;primaryKey"` + Type string `gorm:"column:type"` + Code string `gorm:"column:code"` + Name string `gorm:"column:name"` + Cover string `gorm:"column:cover"` + SourceURL string `gorm:"column:source_url"` + Expand string `gorm:"column:expand"` + AdminFree bool `gorm:"column:admin_free"` + IsDel bool `gorm:"column:is_del"` +} + +func (propsSourceRecordRow) TableName() string { + return "props_source_record" +} + +type propsNobleVIPAbilityRow struct { + ID int64 `gorm:"column:id;primaryKey"` + VIPLevel int `gorm:"column:vip_level"` + AvatarFrameID int64 `gorm:"column:avatar_frame_id"` + CarID int64 `gorm:"column:car_id"` + ChatBubbleID int64 `gorm:"column:chat_bubble_id"` + DataCardID int64 `gorm:"column:data_card_id"` +} + +func (propsNobleVIPAbilityRow) TableName() string { + return "props_noble_vip_ability" +} + +func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) { + if err := s.ensureManager(ctx, user); err != nil { + return nil, err + } + + view, err := s.loadUserViewByID(ctx, user.UserID) + if err != nil { + return nil, err + } + return &ProfileResponse{Manager: view}, nil +} + +func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) { + if err := s.ensureManager(ctx, user); err != nil { + return nil, err + } + + account = strings.TrimSpace(account) + if account == "" { + return nil, badRequest("account_required", "account is required") + } + + view, err := s.findUserView(ctx, account) + if err != nil { + return nil, err + } + return &UserSearchResponse{User: view}, nil +} + +func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) { + if err := s.ensureManager(ctx, user); err != nil { + return nil, err + } + if s.db == nil { + return nil, serviceUnavailable("database_unavailable", "database is unavailable") + } + + propsType = normalizePropsType(propsType) + if propsType == propsTypeNobleVIP { + return s.listVIPProps(ctx, user) + } + + query := s.db.WithContext(ctx). + Table("props_source_record"). + Select("id, type, code, name, cover, source_url, expand, admin_free, is_del"). + Where("is_del = ? AND admin_free = ?", false, true) + if propsType != "" { + query = query.Where("type = ?", propsType) + } + + var rows []propsSourceRecordRow + if err := query.Order("type ASC, id ASC").Find(&rows).Error; err != nil { + return nil, serverError("props_query_failed", err.Error()) + } + + vipLevels, err := s.loadVIPLevels(ctx, rows) + if err != nil { + return nil, err + } + + records := make([]PropsView, 0, len(rows)) + for _, row := range rows { + view, ok := propsRowToView(row, vipLevels) + if !ok { + continue + } + records = append(records, view) + } + + return &PropsListResponse{ + Type: propsType, + Records: records, + }, nil +} + +func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) { + if err := s.ensureManager(ctx, user); err != nil { + return nil, err + } + if req.AcceptUserID.Int64() <= 0 { + return nil, badRequest("accept_user_required", "acceptUserId is required") + } + if req.PropsID.Int64() <= 0 { + return nil, badRequest("props_required", "propsId is required") + } + if s.java == nil { + return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable") + } + + target, err := s.loadUserRowByID(ctx, req.AcceptUserID.Int64()) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, notFound("user_not_found", "user not found") + } + return nil, serverError("user_query_failed", err.Error()) + } + + prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64()) + if err != nil { + if isAppErrorCode(err, "prop_not_giftable") { + if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil { + return nil, vipErr + } else if resp != nil { + return resp, nil + } + } + return nil, err + } + + days := propsGiftDays(prop.Type) + grants := []propsGrant{{ + propsID: prop.ID, + typ: prop.Type, + days: days, + }} + if prop.Type == propsTypeNobleVIP { + if _, err := s.loadGiftableNobleVIPAbility(ctx, prop.ID); err != nil { + return nil, err + } + ability, err := s.java.GetNobleVIPAbility(ctx, prop.ID) + if err != nil { + return nil, serverError("vip_ability_query_failed", err.Error()) + } + grants = append(grants, nobleVIPAccessoryGrants(ability)...) + } + + for _, grant := range grants { + if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ + AcceptUserID: req.AcceptUserID.Int64(), + PropsID: grant.propsID, + Type: grant.typ, + Origin: adminFreeOrigin, + OriginDesc: adminFreeOriginDesc, + Days: grant.days, + UseProps: true, + AllowGive: false, + OpUser: user.UserID, + }); err != nil { + return nil, serverError("send_props_failed", err.Error()) + } + } + + if prop.Type == propsTypeNobleVIP { + if err := s.switchMaxNobleVIP(ctx, req.AcceptUserID.Int64()); err != nil { + return nil, err + } + } + + return &SendPropsResponse{ + Success: true, + AcceptUserID: req.AcceptUserID, + PropsID: req.PropsID, + Type: prop.Type, + Days: days, + }, nil +} + +func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) { + if err := s.ensureManager(ctx, user); err != nil { + return nil, err + } + if req.UserID.Int64() <= 0 { + return nil, badRequest("user_required", "userId is required") + } + if s.java == nil { + return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable") + } + + target, err := s.loadUserRowByID(ctx, req.UserID.Int64()) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, notFound("user_not_found", "user not found") + } + return nil, serverError("user_query_failed", err.Error()) + } + + hasRecharge, err := s.userHasRecharge(ctx, target.ID) + if err != nil { + return nil, err + } + if hasRecharge { + return nil, badRequest("user_recharged", "only users without recharge can be banned") + } + + description := buildBanDescription(user.UserID, req.Reason) + if err := s.java.PunishAccount(ctx, integration.PunishAccountRequest{ + BeOptUserID: target.ID, + OptUserID: user.UserID, + Status: accountStatusArchive, + Description: description, + }); err != nil { + return nil, serverError("ban_user_failed", err.Error()) + } + + return &BanUserResponse{ + Success: true, + UserID: req.UserID, + AccountStatus: accountStatusArchive, + }, nil +} + +func (s *Service) ensureManager(ctx context.Context, user AuthUser) error { + if user.UserID <= 0 { + return unauthorized("invalid_user", "authenticated user is required") + } + if s.db == nil { + return serviceUnavailable("database_unavailable", "database is unavailable") + } + + var count int64 + err := s.db.WithContext(ctx). + Table("sys_administrator"). + Where("user_id = ? AND status = ?", user.UserID, true). + Count(&count).Error + if err != nil { + return serverError("manager_query_failed", err.Error()) + } + if count == 0 { + return forbidden("not_manager", "user is not manager") + } + return nil +} + +func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) { + if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 { + if row, err := s.loadUserRowByID(ctx, userID); err == nil { + return s.userRowToView(ctx, row) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return UserView{}, serverError("user_query_failed", err.Error()) + } + } + + if s.java != nil { + profile, err := s.java.GetUserProfileByAccountOne(ctx, account) + if err == nil && int64(profile.ID) > 0 { + if row, rowErr := s.loadUserRowByID(ctx, int64(profile.ID)); rowErr == nil { + view, viewErr := s.userRowToView(ctx, row) + if viewErr != nil { + return UserView{}, viewErr + } + overlayJavaProfile(&view, profile) + return view, nil + } else if !errors.Is(rowErr, gorm.ErrRecordNotFound) { + return UserView{}, serverError("user_query_failed", rowErr.Error()) + } + return s.javaProfileToView(ctx, profile) + } + } + + row, err := s.loadUserRowByAccount(ctx, account) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return UserView{}, notFound("user_not_found", "user not found") + } + return UserView{}, serverError("user_query_failed", err.Error()) + } + return s.userRowToView(ctx, row) +} + +func (s *Service) loadUserViewByID(ctx context.Context, userID int64) (UserView, error) { + row, err := s.loadUserRowByID(ctx, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return UserView{}, notFound("user_not_found", "user not found") + } + return UserView{}, serverError("user_query_failed", err.Error()) + } + return s.userRowToView(ctx, row) +} + +func (s *Service) loadUserRowByID(ctx context.Context, userID int64) (userBaseInfoRow, error) { + var row userBaseInfoRow + err := s.db.WithContext(ctx). + Table("user_base_info"). + Where("id = ? AND is_del = ?", userID, false). + First(&row).Error + return row, err +} + +func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (userBaseInfoRow, error) { + var row userBaseInfoRow + err := s.db.WithContext(ctx). + Table("user_base_info"). + Where("account = ? AND is_del = ?", account, false). + First(&row).Error + return row, err +} + +func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) { + hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID) + return UserView{ + UserID: ID(row.ID), + Account: row.Account, + UserAvatar: row.UserAvatar, + UserNickname: row.UserNickname, + CountryCode: row.CountryCode, + CountryName: row.CountryName, + OriginSys: row.OriginSys, + AccountStatus: row.AccountStatus, + HasRecharge: hasRecharge, + CanBan: canBan, + }, nil +} + +func (s *Service) javaProfileToView(ctx context.Context, profile integration.UserProfile) (UserView, error) { + hasRecharge, canBan := s.userRechargeEligibility(ctx, int64(profile.ID)) + return UserView{ + UserID: ID(profile.ID), + Account: profile.Account, + UserAvatar: profile.UserAvatar, + UserNickname: profile.UserNickname, + CountryCode: profile.CountryCode, + CountryName: profile.CountryName, + OriginSys: profile.OriginSys, + AccountStatus: profile.AccountStatus, + HasRecharge: hasRecharge, + CanBan: canBan, + }, nil +} + +func overlayJavaProfile(view *UserView, profile integration.UserProfile) { + if profile.Account != "" { + view.Account = profile.Account + } + if profile.UserAvatar != "" { + view.UserAvatar = profile.UserAvatar + } + if profile.UserNickname != "" { + view.UserNickname = profile.UserNickname + } + if profile.CountryCode != "" { + view.CountryCode = profile.CountryCode + } + if profile.CountryName != "" { + view.CountryName = profile.CountryName + } + if profile.OriginSys != "" { + view.OriginSys = profile.OriginSys + } + if profile.AccountStatus != "" { + view.AccountStatus = profile.AccountStatus + } +} + +func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) { + if userID <= 0 { + return false, nil + } + if s.java == nil { + return false, nil + } + + recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID}) + if err != nil { + return false, serverError("recharge_query_failed", err.Error()) + } + for _, item := range recharges[userID] { + raw := strings.TrimSpace(string(item.Amount)) + if raw == "" { + continue + } + amount, err := strconv.ParseFloat(raw, 64) + if err != nil { + return false, serverError("recharge_query_failed", err.Error()) + } + if amount > 0 { + return true, nil + } + } + return false, nil +} + +func (s *Service) userRechargeEligibility(ctx context.Context, userID int64) (bool, bool) { + hasRecharge, err := s.userHasRecharge(ctx, userID) + if err != nil { + return false, false + } + return hasRecharge, !hasRecharge +} + +func (s *Service) loadGiftableProps(ctx context.Context, propsID int64) (propsSourceRecordRow, error) { + var row propsSourceRecordRow + err := s.db.WithContext(ctx). + Table("props_source_record"). + Select("id, type, code, name, cover, source_url, expand, admin_free, is_del"). + Where("id = ? AND is_del = ? AND admin_free = ?", propsID, false, true). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return propsSourceRecordRow{}, badRequest("prop_not_giftable", "props is not admin-free giftable") + } + if err != nil { + return propsSourceRecordRow{}, serverError("props_query_failed", err.Error()) + } + return row, nil +} + +func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64) (propsNobleVIPAbilityRow, error) { + var row propsNobleVIPAbilityRow + err := s.db.WithContext(ctx). + Table("props_noble_vip_ability"). + Where("id = ?", propsID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "noble VIP ability is not giftable") + } + if err != nil { + return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error()) + } + if row.VIPLevel > 3 { + return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted") + } + return row, nil +} + +func (s *Service) loadVIPLevels(ctx context.Context, rows []propsSourceRecordRow) (map[int64]int, error) { + ids := make([]int64, 0) + for _, row := range rows { + if row.Type == propsTypeNobleVIP { + ids = append(ids, row.ID) + } + } + if len(ids) == 0 { + return nil, nil + } + + var abilities []propsNobleVIPAbilityRow + if err := s.db.WithContext(ctx). + Table("props_noble_vip_ability"). + Select("id, vip_level"). + Where("id IN ?", ids). + Find(&abilities).Error; err != nil { + return nil, serverError("vip_ability_query_failed", err.Error()) + } + + levels := make(map[int64]int, len(abilities)) + for _, ability := range abilities { + levels[ability.ID] = ability.VIPLevel + } + return levels, nil +} + +type propsGrant struct { + propsID int64 + typ string + days int +} + +func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant { + grants := make([]propsGrant, 0, 4) + appendIfPositive := func(propsID int64, typ string) { + if propsID <= 0 { + return + } + grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays}) + } + appendIfPositive(int64(ability.CarID), propsTypeRide) + appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame) + appendIfPositive(int64(ability.ChatBubbleID), propsTypeChatBubble) + appendIfPositive(int64(ability.DataCardID), propsTypeDataCard) + return grants +} + +func (s *Service) switchMaxNobleVIP(ctx context.Context, userID int64) error { + ability, err := s.java.GetUserMaxNobleVIPAbility(ctx, userID) + if err != nil { + return serverError("vip_ability_query_failed", err.Error()) + } + + ids := []int64{ + int64(ability.ID), + int64(ability.AvatarFrameID), + int64(ability.DataCardID), + int64(ability.CarID), + int64(ability.ChatBubbleID), + } + for _, propsID := range compactPositiveIDs(ids) { + if err := s.java.SwitchUseProps(ctx, userID, propsID); err != nil { + return serverError("switch_props_failed", err.Error()) + } + } + if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil { + return serverError("remove_profile_cache_failed", err.Error()) + } + return nil +} + +func compactPositiveIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsView, bool) { + level := 0 + if row.Type == propsTypeNobleVIP { + var exists bool + level, exists = vipLevels[row.ID] + if !exists || level > 3 { + return PropsView{}, false + } + } + return PropsView{ + ID: ID(row.ID), + Type: row.Type, + Code: row.Code, + Name: row.Name, + Cover: row.Cover, + SourceURL: row.SourceURL, + Expand: row.Expand, + Days: propsGiftDays(row.Type), + VIPLevel: level, + }, true +} + +func propsGiftDays(propsType string) int { + if propsType == propsTypeNobleVIP { + return defaultVIPGiftDays + } + return defaultGiftDays +} + +func normalizePropsType(propsType string) string { + return strings.ToUpper(strings.TrimSpace(propsType)) +} + +func buildBanDescription(managerID int64, reason string) string { + reason = strings.TrimSpace(reason) + if len([]rune(reason)) > 200 { + runes := []rune(reason) + reason = string(runes[:200]) + } + if reason == "" { + return fmt.Sprintf("manager center ban non-recharged user, manager=%d", managerID) + } + return fmt.Sprintf("manager center ban non-recharged user, manager=%d, reason=%s", managerID, reason) +} + +func isAppErrorCode(err error, code string) bool { + var appErr *AppError + return errors.As(err, &appErr) && appErr.Code == code +} + +func normalizeSysOrigin(sysOrigin string) string { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin == "" { + return "LIKEI" + } + return sysOrigin +} + +func vipPropsViewFromConfig(row model.VipLevelConfig) PropsView { + days := row.DurationDays + if days <= 0 { + days = defaultVIPGiftDays + } + name := strings.TrimSpace(row.DisplayName) + if name == "" { + name = fmt.Sprintf("VIP %d", row.Level) + } + code := strings.TrimSpace(row.LevelCode) + if code == "" { + code = fmt.Sprintf("VIP%d", row.Level) + } + return PropsView{ + ID: ID(row.ID), + Type: propsTypeNobleVIP, + Code: code, + Name: name, + Cover: row.BadgeURL, + SourceURL: row.BadgeURL, + Days: days, + VIPLevel: row.Level, + } +} diff --git a/internal/service/managercenter/service_test.go b/internal/service/managercenter/service_test.go new file mode 100644 index 0000000..390397d --- /dev/null +++ b/internal/service/managercenter/service_test.go @@ -0,0 +1,424 @@ +package managercenter + +import ( + "context" + "errors" + "testing" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type fakeManagerGateway struct { + recharges map[int64][]integration.UserTotalRecharge + rechargeErr error + abilities map[int64]integration.PropsNobleVIPAbility + maxAbility integration.PropsNobleVIPAbility + giveRequests []integration.GivePropsBackpackRequest + punishRequests []integration.PunishAccountRequest + switchIDs []int64 + cacheCleared []int64 + badgeIDs []int64 +} + +func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) { + return integration.UserProfile{}, nil +} + +func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) { + return integration.UserProfile{}, nil +} + +func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) { + if f.rechargeErr != nil { + return nil, f.rechargeErr + } + result := make(map[int64][]integration.UserTotalRecharge, len(userIDs)) + for _, userID := range userIDs { + result[userID] = f.recharges[userID] + } + return result, nil +} + +func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error { + f.giveRequests = append(f.giveRequests, req) + return nil +} + +func (f *fakeManagerGateway) GetNobleVIPAbility(_ context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) { + return f.abilities[sourceID], nil +} + +func (f *fakeManagerGateway) GetUserMaxNobleVIPAbility(context.Context, int64) (integration.PropsNobleVIPAbility, error) { + return f.maxAbility, nil +} + +func (f *fakeManagerGateway) SwitchUseProps(_ context.Context, _ int64, propsID int64) error { + f.switchIDs = append(f.switchIDs, propsID) + return nil +} + +func (f *fakeManagerGateway) ActivateTemporaryBadge(_ context.Context, _ int64, badgeID int64, _ int) error { + f.badgeIDs = append(f.badgeIDs, badgeID) + return nil +} + +func (f *fakeManagerGateway) RemoveUserProfileCacheAll(_ context.Context, userID int64) error { + f.cacheCleared = append(f.cacheCleared, userID) + return nil +} + +func (f *fakeManagerGateway) PunishAccount(_ context.Context, req integration.PunishAccountRequest) error { + f.punishRequests = append(f.punishRequests, req) + return nil +} + +func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) { + service, _ := newManagerCenterTestService(t) + seedManager(t, service.db.(*gorm.DB), 1) + seedProps(t, service.db.(*gorm.DB), + propsSourceRecordRow{ID: 101, Type: propsTypeRide, Name: "Giftable ride", AdminFree: true}, + propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false}, + propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true}, + propsSourceRecordRow{ID: 204, Type: propsTypeNobleVIP, Name: "VIP 4", AdminFree: true}, + ) + seedAbilities(t, service.db.(*gorm.DB), + propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3}, + propsNobleVIPAbilityRow{ID: 204, VIPLevel: 4}, + ) + + resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1}, "") + if err != nil { + t.Fatalf("ListProps() error = %v", err) + } + + got := map[int64]PropsView{} + for _, item := range resp.Records { + got[item.ID.Int64()] = item + } + if _, ok := got[101]; !ok { + t.Fatalf("records = %+v, want giftable ride", resp.Records) + } + if _, ok := got[102]; ok { + t.Fatalf("records = %+v, non-admin-free props must be hidden", resp.Records) + } + if item, ok := got[201]; !ok || item.Days != defaultVIPGiftDays || item.VIPLevel != 3 { + t.Fatalf("vip 201 = %+v, want level 3 with 3 days", item) + } + if _, ok := got[204]; ok { + t.Fatalf("records = %+v, VIP level 4 must be hidden", resp.Records) + } +} + +func TestListPropsReturnsVIPLevelConfigs(t *testing.T) { + service, _ := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedVIPConfigs(t, db, + model.VipLevelConfig{ID: 301, SysOrigin: "LIKEI", Level: 1, LevelCode: "VIP1", DisplayName: "VIP 1", Enabled: true, DurationDays: 30}, + model.VipLevelConfig{ID: 302, SysOrigin: "LIKEI", Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: false, DurationDays: 30}, + model.VipLevelConfig{ID: 303, SysOrigin: "ATYOU", Level: 1, LevelCode: "VIP1", DisplayName: "ATYOU VIP 1", Enabled: true, DurationDays: 30}, + ) + + resp, err := service.ListProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, propsTypeNobleVIP) + if err != nil { + t.Fatalf("ListProps() error = %v", err) + } + if len(resp.Records) != 1 { + t.Fatalf("records = %+v, want only one enabled LIKEI vip config", resp.Records) + } + got := resp.Records[0] + if got.ID.Int64() != 301 || got.Type != propsTypeNobleVIP || got.VIPLevel != 1 || got.Days != 30 { + t.Fatalf("vip props = %+v, want config 301 level 1", got) + } +} + +func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) { + service, fake := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, userBaseInfoRow{ID: 1, Account: "1"}) + fake.rechargeErr = errors.New("order service unavailable") + + resp, err := service.GetProfile(context.Background(), AuthUser{UserID: 1}) + if err != nil { + t.Fatalf("GetProfile() error = %v", err) + } + if resp.Manager.HasRecharge || resp.Manager.CanBan { + t.Fatalf("manager = %+v, want unknown recharge state and ban disabled", resp.Manager) + } +} + +func TestSendPropsRejectsNonAdminFree(t *testing.T) { + service, fake := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"}) + seedProps(t, db, propsSourceRecordRow{ID: 102, Type: propsTypeRide, Name: "Paid ride", AdminFree: false}) + + _, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{ + AcceptUserID: ID(2), + PropsID: ID(102), + }) + assertAppErrorCode(t, err, "prop_not_giftable") + if len(fake.giveRequests) != 0 { + t.Fatalf("give requests = %+v, want none", fake.giveRequests) + } +} + +func TestSendNobleVIPGivesAccessoriesAndSwitchesMax(t *testing.T) { + service, fake := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"}) + seedProps(t, db, propsSourceRecordRow{ID: 201, Type: propsTypeNobleVIP, Name: "VIP 3", AdminFree: true}) + seedAbilities(t, db, propsNobleVIPAbilityRow{ID: 201, VIPLevel: 3}) + fake.abilities[201] = integration.PropsNobleVIPAbility{ + ID: integration.Int64Value(201), + AvatarFrameID: integration.Int64Value(302), + CarID: integration.Int64Value(301), + ChatBubbleID: integration.Int64Value(303), + DataCardID: integration.Int64Value(304), + } + fake.maxAbility = fake.abilities[201] + + resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1}, SendPropsRequest{ + AcceptUserID: ID(2), + PropsID: ID(201), + }) + if err != nil { + t.Fatalf("SendProps() error = %v", err) + } + if !resp.Success || resp.Days != defaultVIPGiftDays { + t.Fatalf("resp = %+v, want success and VIP days", resp) + } + if len(fake.giveRequests) != 5 { + t.Fatalf("give requests = %+v, want main VIP plus 4 accessories", fake.giveRequests) + } + for _, req := range fake.giveRequests { + if req.AcceptUserID != 2 || req.Origin != adminFreeOrigin || req.OpUser != 1 || !req.UseProps || req.AllowGive { + t.Fatalf("give request = %+v, want admin-free grant to target", req) + } + } + wantSwitch := []int64{201, 302, 304, 301, 303} + if !equalInt64Slice(fake.switchIDs, wantSwitch) { + t.Fatalf("switch ids = %+v, want %+v", fake.switchIDs, wantSwitch) + } + if !equalInt64Slice(fake.cacheCleared, []int64{2}) { + t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared) + } +} + +func TestSendVIPUsesVIPLevelConfig(t *testing.T) { + service, _ := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, + userBaseInfoRow{ID: 1, Account: "1", UserNickname: "manager", OriginSys: "LIKEI"}, + userBaseInfoRow{ID: 2, Account: "2", OriginSys: "LIKEI"}, + ) + seedVIPConfigs(t, db, model.VipLevelConfig{ + ID: 301, + SysOrigin: "LIKEI", + Level: 3, + LevelCode: "VIP3", + DisplayName: "VIP 3", + Enabled: true, + DurationDays: 30, + PriceGold: 3000, + }) + + resp, err := service.SendProps(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, SendPropsRequest{ + AcceptUserID: ID(2), + PropsID: ID(301), + }) + if err != nil { + t.Fatalf("SendProps() error = %v", err) + } + if !resp.Success || resp.Type != propsTypeNobleVIP || resp.VIPLevel != 3 || resp.Days != 30 { + t.Fatalf("resp = %+v, want vip gift success", resp) + } + + var state model.UserVipState + if err := db.Where("sys_origin = ? AND user_id = ?", "LIKEI", int64(2)).First(&state).Error; err != nil { + t.Fatalf("load state: %v", err) + } + if state.Level != 3 || state.Status != vipStatusActive { + t.Fatalf("state = %+v, want vip3 active", state) + } + var orders int64 + if err := db.Model(&model.VipOrderRecord{}).Where("user_id = ? AND order_type = ?", int64(2), vipOrderTypeGMGift).Count(&orders).Error; err != nil { + t.Fatalf("count orders: %v", err) + } + if orders != 1 { + t.Fatalf("orders = %d, want 1", orders) + } + var records int64 + if err := db.Model(&gmVIPGiftRecordRow{}).Where("user_id = ? AND vip_level = ?", int64(2), 3).Count(&records).Error; err != nil { + t.Fatalf("count gift records: %v", err) + } + if records != 1 { + t.Fatalf("gift records = %d, want 1", records) + } +} + +func TestBanUserRejectsRechargedUser(t *testing.T) { + service, fake := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"}) + fake.recharges[2] = []integration.UserTotalRecharge{{Amount: integration.DecimalString("1.00")}} + + _, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)}) + assertAppErrorCode(t, err, "user_recharged") + if len(fake.punishRequests) != 0 { + t.Fatalf("punish requests = %+v, want none", fake.punishRequests) + } +} + +func TestBanUserFailsWhenRechargeQueryUnavailable(t *testing.T) { + service, fake := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"}) + fake.rechargeErr = errors.New("order service unavailable") + + _, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)}) + assertAppErrorCode(t, err, "recharge_query_failed") + if len(fake.punishRequests) != 0 { + t.Fatalf("punish requests = %+v, want none", fake.punishRequests) + } +} + +func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) { + service, fake := newManagerCenterTestService(t) + db := service.db.(*gorm.DB) + seedManager(t, db, 1) + seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2"}) + + resp, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{ + UserID: ID(2), + Reason: "spam", + }) + if err != nil { + t.Fatalf("BanUser() error = %v", err) + } + if !resp.Success || resp.AccountStatus != accountStatusArchive { + t.Fatalf("resp = %+v, want archived success", resp) + } + if len(fake.punishRequests) != 1 { + t.Fatalf("punish requests = %+v, want one", fake.punishRequests) + } + got := fake.punishRequests[0] + if got.BeOptUserID != 2 || got.OptUserID != 1 || got.Status != accountStatusArchive { + t.Fatalf("punish request = %+v, want target 2 manager 1 archive", got) + } +} + +func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) { + 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( + &administratorRow{}, + &userBaseInfoRow{}, + &propsSourceRecordRow{}, + &propsNobleVIPAbilityRow{}, + &model.VipLevelConfig{}, + &model.UserVipState{}, + &model.VipOrderRecord{}, + &gmVIPGiftRecordRow{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + fake := &fakeManagerGateway{ + recharges: map[int64][]integration.UserTotalRecharge{}, + abilities: map[int64]integration.PropsNobleVIPAbility{}, + } + return NewService(db, fake), fake +} + +func seedManager(t *testing.T, db *gorm.DB, userID int64) { + t.Helper() + if err := db.Create(&administratorRow{ID: userID, UserID: userID, Status: true}).Error; err != nil { + t.Fatalf("seed manager: %v", err) + } +} + +func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) { + t.Helper() + for _, row := range rows { + if row.AccountStatus == "" { + row.AccountStatus = "ACTIVE" + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + } +} + +func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) { + t.Helper() + for _, row := range rows { + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed props: %v", err) + } + } +} + +func seedAbilities(t *testing.T, db *gorm.DB, rows ...propsNobleVIPAbilityRow) { + t.Helper() + for _, row := range rows { + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed ability: %v", err) + } + } +} + +func seedVIPConfigs(t *testing.T, db *gorm.DB, rows ...model.VipLevelConfig) { + t.Helper() + now := time.Now() + for _, row := range rows { + if row.CreateTime.IsZero() { + row.CreateTime = now + } + if row.UpdateTime.IsZero() { + row.UpdateTime = now + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed vip config: %v", err) + } + } +} + +func assertAppErrorCode(t *testing.T, err error, code string) { + t.Helper() + if err == nil { + t.Fatalf("error is nil, want code %s", code) + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("error = %T %v, want AppError", err, err) + } + if appErr.Code != code { + t.Fatalf("error code = %s, want %s", appErr.Code, code) + } +} + +func equalInt64Slice(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/service/managercenter/types.go b/internal/service/managercenter/types.go new file mode 100644 index 0000000..85be6f2 --- /dev/null +++ b/internal/service/managercenter/types.go @@ -0,0 +1,187 @@ +package managercenter + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +const ( + propsTypeNobleVIP = "NOBLE_VIP" + propsTypeRide = "RIDE" + propsTypeAvatarFrame = "AVATAR_FRAME" + propsTypeChatBubble = "CHAT_BUBBLE" + propsTypeDataCard = "DATA_CARD" + propsTypeBadge = "BADGE" + propsTypeFloatPicture = "FLOAT_PICTURE" + + adminFreeOrigin = "ADMIN_FREE" + adminFreeOriginDesc = "Admin free" + vipGiftOrigin = "VIP_PURCHASE" + vipGiftOriginDesc = "VIP purchase" + + defaultGiftDays = 7 + defaultVIPGiftDays = 30 + + accountStatusArchive = "ARCHIVE" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type managerDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type managerGateway interface { + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) + GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error) + MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) + GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error + GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) + GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error) + SwitchUseProps(ctx context.Context, userID int64, propsID int64) error + ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error + RemoveUserProfileCacheAll(ctx context.Context, userID int64) error + PunishAccount(ctx context.Context, req integration.PunishAccountRequest) error +} + +type Service struct { + db managerDB + java managerGateway +} + +func NewService(db managerDB, java managerGateway) *Service { + return &Service{db: db, java: java} +} + +type ID int64 + +func (id ID) Int64() int64 { + return int64(id) +} + +func (id ID) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil +} + +func (id *ID) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *id = 0 + return nil + } + if strings.HasPrefix(raw, "\"") { + var parsed string + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + raw = strings.TrimSpace(parsed) + } + if raw == "" { + *id = 0 + return nil + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return err + } + *id = ID(parsed) + return nil +} + +type ProfileResponse struct { + Manager UserView `json:"manager"` +} + +type UserSearchResponse struct { + User UserView `json:"user"` +} + +type UserView struct { + UserID ID `json:"userId"` + Account string `json:"account"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` + CountryCode string `json:"countryCode,omitempty"` + CountryName string `json:"countryName,omitempty"` + OriginSys string `json:"originSys,omitempty"` + AccountStatus string `json:"accountStatus"` + HasRecharge bool `json:"hasRecharge"` + CanBan bool `json:"canBan"` +} + +type PropsListResponse struct { + Type string `json:"type,omitempty"` + Records []PropsView `json:"records"` +} + +type PropsView struct { + ID ID `json:"id"` + Type string `json:"type"` + Code string `json:"code,omitempty"` + Name string `json:"name"` + Cover string `json:"cover,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` + Expand string `json:"expand,omitempty"` + Days int `json:"days"` + VIPLevel int `json:"vipLevel,omitempty"` +} + +type SendPropsRequest struct { + AcceptUserID ID `json:"acceptUserId"` + PropsID ID `json:"propsId"` +} + +type SendPropsResponse struct { + Success bool `json:"success"` + AcceptUserID ID `json:"acceptUserId"` + PropsID ID `json:"propsId"` + Type string `json:"type"` + Days int `json:"days"` + VIPLevel int `json:"vipLevel,omitempty"` +} + +type BanUserRequest struct { + UserID ID `json:"userId"` + Reason string `json:"reason"` +} + +type BanUserResponse struct { + Success bool `json:"success"` + UserID ID `json:"userId"` + AccountStatus string `json:"accountStatus"` +} + +func badRequest(code, message string) error { + return NewAppError(http.StatusBadRequest, code, message) +} + +func unauthorized(code, message string) error { + return NewAppError(http.StatusUnauthorized, code, message) +} + +func forbidden(code, message string) error { + return NewAppError(http.StatusForbidden, code, message) +} + +func notFound(code, message string) error { + return NewAppError(http.StatusNotFound, code, message) +} + +func serviceUnavailable(code, message string) error { + return NewAppError(http.StatusServiceUnavailable, code, message) +} + +func serverError(code, message string) error { + return NewAppError(http.StatusInternalServerError, code, message) +} diff --git a/internal/service/managercenter/vip_gift.go b/internal/service/managercenter/vip_gift.go new file mode 100644 index 0000000..07a42a0 --- /dev/null +++ b/internal/service/managercenter/vip_gift.go @@ -0,0 +1,425 @@ +package managercenter + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + vipStatusActive = "ACTIVE" + vipStatusSuccess = "SUCCESS" + vipOrderTypeGMGift = "GM_GIFT" + vipGiftEventPrefix = "MANAGER-VIP-GIFT-" + mysqlTimestampMaxYear = 2038 +) + +type gmVIPGiftRecordRow struct { + ID int64 `gorm:"column:id;primaryKey"` + UserID int64 `gorm:"column:user_id"` + SysOrigin string `gorm:"column:sys_origin"` + FromLevel int `gorm:"column:from_level"` + VIPLevel int `gorm:"column:vip_level"` + LevelCode string `gorm:"column:level_code"` + DisplayName string `gorm:"column:display_name"` + DurationDays int `gorm:"column:duration_days"` + PriceGold int64 `gorm:"column:price_gold"` + StartAt time.Time `gorm:"column:start_at"` + ExpireAt time.Time `gorm:"column:expire_at"` + OrderID int64 `gorm:"column:order_id"` + EventID string `gorm:"column:event_id"` + Status string `gorm:"column:status"` + ApplicantID int64 `gorm:"column:applicant_id"` + ApplicantName string `gorm:"column:applicant_name"` + Remarks string `gorm:"column:remarks"` + ExecutedTime time.Time `gorm:"column:executed_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` + CreateUser int64 `gorm:"column:create_user"` + UpdateUser int64 `gorm:"column:update_user"` +} + +func (gmVIPGiftRecordRow) TableName() string { + return "gm_vip_gift_record" +} + +type vipGiftResource struct { + resourceID int64 + propsType string + badge bool +} + +func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListResponse, error) { + sysOrigin := normalizeSysOrigin(user.SysOrigin) + var rows []model.VipLevelConfig + if err := s.db.WithContext(ctx). + Where("sys_origin = ? AND enabled = ?", sysOrigin, true). + Order("level ASC"). + Find(&rows).Error; err != nil { + return nil, serverError("vip_config_query_failed", err.Error()) + } + + records := make([]PropsView, 0, len(rows)) + for _, row := range rows { + records = append(records, vipPropsViewFromConfig(row)) + } + return &PropsListResponse{ + Type: propsTypeNobleVIP, + Records: records, + }, nil +} + +func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) { + sysOrigin := normalizeSysOrigin(target.OriginSys) + config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID) + if err != nil || !found { + return nil, err + } + if !config.Enabled { + return nil, badRequest("vip_level_disabled", "vip level is disabled") + } + if config.Level < 1 || config.Level > 5 { + return nil, badRequest("vip_not_giftable", "vip level is invalid") + } + if config.DurationDays <= 0 { + config.DurationDays = defaultVIPGiftDays + } + + now := time.Now() + orderID, err := utils.NextID() + if err != nil { + return nil, serverError("id_generate_failed", err.Error()) + } + recordID, err := utils.NextID() + if err != nil { + return nil, serverError("id_generate_failed", err.Error()) + } + stateID, err := utils.NextID() + if err != nil { + return nil, serverError("id_generate_failed", err.Error()) + } + eventID := fmt.Sprintf("%s%d", vipGiftEventPrefix, orderID) + applicantName := s.managerApplicantName(ctx, manager.UserID) + + tx := s.db.WithContext(ctx).Begin() + if tx.Error != nil { + return nil, serverError("vip_gift_failed", tx.Error.Error()) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback().Error + } + }() + + state, err := s.loadLockedVIPState(tx, sysOrigin, target.ID) + if err != nil { + return nil, serverError("vip_state_query_failed", err.Error()) + } + currentActive := isActiveVIPState(state, now) + fromLevel := 0 + if currentActive { + fromLevel = state.Level + } + if currentActive && fromLevel > config.Level { + return nil, badRequest("vip_downgrade_not_allowed", "vip downgrade is not allowed") + } + + startAt := now + if currentActive && !state.StartAt.IsZero() { + startAt = state.StartAt + } + expireAt := resolveVIPGiftExpireAt(state, config, currentActive, now) + + order := model.VipOrderRecord{ + ID: orderID, + EventID: eventID, + SysOrigin: sysOrigin, + UserID: target.ID, + OrderType: vipOrderTypeGMGift, + FromLevel: fromLevel, + ToLevel: config.Level, + DurationDays: config.DurationDays, + OriginalPriceGold: config.PriceGold, + PaidGold: 0, + Status: vipStatusSuccess, + StartAt: startAt, + ExpireAt: expireAt, + ErrorMessage: "", + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&order).Error; err != nil { + return nil, serverError("vip_order_create_failed", err.Error()) + } + + if state == nil { + state = &model.UserVipState{ + ID: stateID, + SysOrigin: sysOrigin, + UserID: target.ID, + CreateTime: now, + } + } + applyVIPGiftConfigToState(state, config, startAt, expireAt, now) + if err := tx.Save(state).Error; err != nil { + return nil, serverError("vip_state_save_failed", err.Error()) + } + + record := gmVIPGiftRecordRow{ + ID: recordID, + UserID: target.ID, + SysOrigin: sysOrigin, + FromLevel: fromLevel, + VIPLevel: config.Level, + LevelCode: vipLevelCode(config), + DisplayName: vipDisplayName(config), + DurationDays: config.DurationDays, + PriceGold: config.PriceGold, + StartAt: startAt, + ExpireAt: expireAt, + OrderID: orderID, + EventID: eventID, + Status: vipStatusSuccess, + ApplicantID: manager.UserID, + ApplicantName: applicantName, + Remarks: fmt.Sprintf("manager center gift, manager=%d", manager.UserID), + ExecutedTime: now, + CreateTime: now, + UpdateTime: now, + CreateUser: manager.UserID, + UpdateUser: manager.UserID, + } + if err := tx.Create(&record).Error; err != nil { + return nil, serverError("vip_gift_record_create_failed", err.Error()) + } + + if err := tx.Commit().Error; err != nil { + return nil, serverError("vip_gift_failed", err.Error()) + } + committed = true + + if err := s.grantVIPGiftResources(ctx, target.ID, manager.UserID, config, expireAt, now); err != nil { + return nil, serverError("vip_resource_grant_failed", err.Error()) + } + + return &SendPropsResponse{ + Success: true, + AcceptUserID: ID(target.ID), + PropsID: ID(config.ID), + Type: propsTypeNobleVIP, + Days: config.DurationDays, + VIPLevel: config.Level, + }, nil +} + +func (s *Service) loadVIPConfigByID(ctx context.Context, sysOrigin string, configID int64) (model.VipLevelConfig, bool, error) { + var row model.VipLevelConfig + err := s.db.WithContext(ctx). + Where("id = ? AND sys_origin = ?", configID, sysOrigin). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.VipLevelConfig{}, false, nil + } + if err != nil { + return model.VipLevelConfig{}, false, serverError("vip_config_query_failed", err.Error()) + } + return row, true, nil +} + +func (s *Service) loadLockedVIPState(tx *gorm.DB, sysOrigin string, userID int64) (*model.UserVipState, error) { + var row model.UserVipState + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func isActiveVIPState(state *model.UserVipState, now time.Time) bool { + return state != nil && state.Status == vipStatusActive && state.Level > 0 && state.ExpireAt.After(now) +} + +func resolveVIPGiftExpireAt(state *model.UserVipState, config model.VipLevelConfig, currentActive bool, now time.Time) time.Time { + durationDays := config.DurationDays + if durationDays <= 0 { + durationDays = defaultVIPGiftDays + } + plusDuration := now.AddDate(0, 0, durationDays) + if !currentActive { + return plusDuration + } + if state.Level == config.Level { + return maxTime(state.ExpireAt, now).AddDate(0, 0, durationDays) + } + return maxTime(state.ExpireAt, plusDuration) +} + +func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelConfig, startAt time.Time, expireAt time.Time, now time.Time) { + state.Level = config.Level + state.LevelCode = vipLevelCode(config) + state.DisplayName = vipDisplayName(config) + state.Status = vipStatusActive + state.StartAt = startAt + state.ExpireAt = expireAt + state.BadgeResourceID = config.BadgeResourceID + state.BadgeName = config.BadgeName + state.BadgeURL = config.BadgeURL + state.AvatarFrameResourceID = config.AvatarFrameResourceID + state.AvatarFrameName = config.AvatarFrameName + state.AvatarFrameURL = config.AvatarFrameURL + state.EntryEffectResourceID = config.EntryEffectResourceID + state.EntryEffectName = config.EntryEffectName + state.EntryEffectURL = config.EntryEffectURL + state.ChatBubbleResourceID = config.ChatBubbleResourceID + state.ChatBubbleName = config.ChatBubbleName + state.ChatBubbleURL = config.ChatBubbleURL + state.FloatPictureResourceID = config.FloatPictureResourceID + state.FloatPictureName = config.FloatPictureName + state.FloatPictureURL = config.FloatPictureURL + state.UpdateTime = now + if state.CreateTime.IsZero() { + state.CreateTime = now + } +} + +func (s *Service) grantVIPGiftResources(ctx context.Context, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error { + resources := vipGiftResourcesFromConfig(config) + if len(resources) == 0 { + return nil + } + days := config.DurationDays + if days <= 0 { + days = defaultVIPGiftDays + } + for _, resource := range resources { + if resource.badge { + if err := s.java.ActivateTemporaryBadge(ctx, userID, resource.resourceID, days); err != nil { + return fmt.Errorf("grant vip badge %d: %w", resource.resourceID, err) + } + } else { + if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ + AcceptUserID: userID, + PropsID: resource.resourceID, + Type: resource.propsType, + Origin: vipGiftOrigin, + OriginDesc: vipGiftOriginDesc, + Days: days, + UseProps: true, + AllowGive: false, + OpUser: managerID, + }); err != nil { + return fmt.Errorf("grant vip resource %s:%d: %w", resource.propsType, resource.resourceID, err) + } + if err := s.java.SwitchUseProps(ctx, userID, resource.resourceID); err != nil { + return fmt.Errorf("switch vip resource %s:%d: %w", resource.propsType, resource.resourceID, err) + } + } + if err := s.alignVIPGiftResourceExpireAt(ctx, userID, resource, expireAt, now); err != nil { + return err + } + } + if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil { + return fmt.Errorf("remove vip user profile cache: %w", err) + } + return nil +} + +func (s *Service) alignVIPGiftResourceExpireAt(ctx context.Context, userID int64, resource vipGiftResource, expireAt time.Time, now time.Time) error { + expireAt = capMySQLTimestamp(expireAt) + var result *gorm.DB + if resource.badge { + result = s.db.WithContext(ctx).Exec( + "UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id = ?", + "TEMPORARY", expireAt, true, now, userID, resource.resourceID, + ) + } else { + result = s.db.WithContext(ctx).Exec( + "UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id = ? AND type = ?", + expireAt, true, false, now, userID, resource.resourceID, resource.propsType, + ) + } + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("vip resource backpack row not found after grant: %d", resource.resourceID) + } + return nil +} + +func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource { + resources := []vipGiftResource{ + {resourceID: config.BadgeResourceID, propsType: propsTypeBadge, badge: true}, + {resourceID: config.AvatarFrameResourceID, propsType: propsTypeAvatarFrame}, + {resourceID: config.EntryEffectResourceID, propsType: propsTypeRide}, + {resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble}, + {resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture}, + } + out := make([]vipGiftResource, 0, len(resources)) + for _, resource := range resources { + if resource.resourceID > 0 { + out = append(out, resource) + } + } + return out +} + +func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string { + row, err := s.loadUserRowByID(ctx, managerID) + if err != nil { + return fmt.Sprintf("%d", managerID) + } + if strings.TrimSpace(row.UserNickname) != "" { + return row.UserNickname + } + if strings.TrimSpace(row.Account) != "" { + return row.Account + } + return fmt.Sprintf("%d", managerID) +} + +func vipLevelCode(config model.VipLevelConfig) string { + code := strings.TrimSpace(config.LevelCode) + if code == "" { + return fmt.Sprintf("VIP%d", config.Level) + } + return code +} + +func vipDisplayName(config model.VipLevelConfig) string { + name := strings.TrimSpace(config.DisplayName) + if name == "" { + return fmt.Sprintf("VIP %d", config.Level) + } + return name +} + +func maxTime(a time.Time, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +func capMySQLTimestamp(value time.Time) time.Time { + maxValue := time.Date(mysqlTimestampMaxYear, time.January, 19, 0, 0, 0, 0, time.Local) + if value.After(maxValue) { + return maxValue + } + return value +} diff --git a/migrations/030_gm_vip_gift_record.sql b/migrations/030_gm_vip_gift_record.sql new file mode 100644 index 0000000..95f10f0 --- /dev/null +++ b/migrations/030_gm_vip_gift_record.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS `gm_vip_gift_record` ( + `id` bigint NOT NULL COMMENT '记录ID', + `user_id` bigint NOT NULL COMMENT 'APP用户ID', + `sys_origin` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统来源', + `from_level` int NOT NULL DEFAULT '0' COMMENT '赠送前VIP等级', + `vip_level` int NOT NULL COMMENT '赠送VIP等级', + `level_code` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '等级编码', + `display_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '显示名称', + `duration_days` int NOT NULL DEFAULT '30' COMMENT '赠送天数', + `price_gold` bigint NOT NULL DEFAULT '0' COMMENT '对应购买价格,金币', + `start_at` datetime(3) DEFAULT NULL COMMENT '权益开始时间', + `expire_at` datetime(3) DEFAULT NULL COMMENT '权益到期时间', + `order_id` bigint NOT NULL COMMENT '关联VIP订单ID', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '赠送幂等事件ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'SUCCESS' COMMENT '状态', + `applicant_id` bigint NOT NULL COMMENT '操作人ID', + `applicant_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称', + `remarks` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `executed_time` datetime(3) DEFAULT NULL COMMENT '执行时间', + `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `create_user` bigint DEFAULT NULL COMMENT '创建用户', + `update_user` bigint DEFAULT NULL COMMENT '更新用户', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gm_vip_gift_event` (`event_id`), + KEY `idx_gm_vip_gift_user` (`user_id`), + KEY `idx_gm_vip_gift_applicant` (`applicant_id`), + KEY `idx_gm_vip_gift_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='GM VIP赠送记录';