经理中心
This commit is contained in:
parent
dd2f4ea6c5
commit
946c3789b1
@ -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,
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
76
internal/router/manager_center_routes.go
Normal file
76
internal/router/manager_center_routes.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
654
internal/service/managercenter/service.go
Normal file
654
internal/service/managercenter/service.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
424
internal/service/managercenter/service_test.go
Normal file
424
internal/service/managercenter/service_test.go
Normal file
@ -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
|
||||
}
|
||||
187
internal/service/managercenter/types.go
Normal file
187
internal/service/managercenter/types.go
Normal file
@ -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)
|
||||
}
|
||||
425
internal/service/managercenter/vip_gift.go
Normal file
425
internal/service/managercenter/vip_gift.go
Normal file
@ -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
|
||||
}
|
||||
29
migrations/030_gm_vip_gift_record.sql
Normal file
29
migrations/030_gm_vip_gift_record.sql
Normal file
@ -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赠送记录';
|
||||
Loading…
x
Reference in New Issue
Block a user