经理中心

This commit is contained in:
hy001 2026-05-11 18:47:16 +08:00
parent dd2f4ea6c5
commit 946c3789b1
11 changed files with 1885 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

View 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赠送记录';