增加游戏用户白名单

This commit is contained in:
zhx 2026-07-14 15:09:31 +08:00
parent 24ebf737ae
commit aac2b4dc76
14 changed files with 2724 additions and 1371 deletions

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,8 @@ message GameCatalogItem {
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
int32 safe_height = 17;
// whitelist_enabled game_user_whitelist
bool whitelist_enabled = 18;
}
message AppGame {
@ -682,6 +684,53 @@ message DiceRobotResponse {
int64 server_time_ms = 2;
}
// GameWhitelistUser game-service 访 admin-server user-service
message GameWhitelistUser {
string app_code = 1;
string game_id = 2;
int64 user_id = 3;
int64 created_by_admin_id = 4;
int64 created_at_ms = 5;
}
message SetGameWhitelistEnabledRequest {
RequestMeta meta = 1;
string game_id = 2;
bool enabled = 3;
}
message ListGameWhitelistUsersRequest {
RequestMeta meta = 1;
string game_id = 2;
int32 page_size = 3;
}
message ListGameWhitelistUsersResponse {
repeated GameWhitelistUser users = 1;
int64 server_time_ms = 2;
}
message AddGameWhitelistUserRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message GameWhitelistUserResponse {
GameWhitelistUser user = 1;
int64 server_time_ms = 2;
}
message DeleteGameWhitelistUserRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message DeleteGameWhitelistUserResponse {
int64 server_time_ms = 1;
}
service GameAppService {
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
rpc ListRecentGames(ListRecentGamesRequest) returns (ListGamesResponse);
@ -718,6 +767,10 @@ service GameAdminService {
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
rpc SetGameWhitelistEnabled(SetGameWhitelistEnabledRequest) returns (CatalogResponse);
rpc ListGameWhitelistUsers(ListGameWhitelistUsersRequest) returns (ListGameWhitelistUsersResponse);
rpc AddGameWhitelistUser(AddGameWhitelistUserRequest) returns (GameWhitelistUserResponse);
rpc DeleteGameWhitelistUser(DeleteGameWhitelistUserRequest) returns (DeleteGameWhitelistUserResponse);
rpc ListSelfGames(ListSelfGamesRequest) returns (ListSelfGamesResponse);
rpc UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse);
rpc GetSelfGameNewUserPolicy(GetSelfGameNewUserPolicyRequest) returns (SelfGameNewUserPolicyResponse);

View File

@ -943,6 +943,10 @@ const (
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
GameAdminService_SetGameWhitelistEnabled_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameWhitelistEnabled"
GameAdminService_ListGameWhitelistUsers_FullMethodName = "/hyapp.game.v1.GameAdminService/ListGameWhitelistUsers"
GameAdminService_AddGameWhitelistUser_FullMethodName = "/hyapp.game.v1.GameAdminService/AddGameWhitelistUser"
GameAdminService_DeleteGameWhitelistUser_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteGameWhitelistUser"
GameAdminService_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
GameAdminService_GetSelfGameNewUserPolicy_FullMethodName = "/hyapp.game.v1.GameAdminService/GetSelfGameNewUserPolicy"
@ -972,6 +976,10 @@ type GameAdminServiceClient interface {
UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error)
SetGameWhitelistEnabled(ctx context.Context, in *SetGameWhitelistEnabledRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
ListGameWhitelistUsers(ctx context.Context, in *ListGameWhitelistUsersRequest, opts ...grpc.CallOption) (*ListGameWhitelistUsersResponse, error)
AddGameWhitelistUser(ctx context.Context, in *AddGameWhitelistUserRequest, opts ...grpc.CallOption) (*GameWhitelistUserResponse, error)
DeleteGameWhitelistUser(ctx context.Context, in *DeleteGameWhitelistUserRequest, opts ...grpc.CallOption) (*DeleteGameWhitelistUserResponse, error)
ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error)
GetSelfGameNewUserPolicy(ctx context.Context, in *GetSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error)
@ -1059,6 +1067,46 @@ func (c *gameAdminServiceClient) DeleteCatalog(ctx context.Context, in *DeleteCa
return out, nil
}
func (c *gameAdminServiceClient) SetGameWhitelistEnabled(ctx context.Context, in *SetGameWhitelistEnabledRequest, opts ...grpc.CallOption) (*CatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CatalogResponse)
err := c.cc.Invoke(ctx, GameAdminService_SetGameWhitelistEnabled_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListGameWhitelistUsers(ctx context.Context, in *ListGameWhitelistUsersRequest, opts ...grpc.CallOption) (*ListGameWhitelistUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListGameWhitelistUsersResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListGameWhitelistUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) AddGameWhitelistUser(ctx context.Context, in *AddGameWhitelistUserRequest, opts ...grpc.CallOption) (*GameWhitelistUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GameWhitelistUserResponse)
err := c.cc.Invoke(ctx, GameAdminService_AddGameWhitelistUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) DeleteGameWhitelistUser(ctx context.Context, in *DeleteGameWhitelistUserRequest, opts ...grpc.CallOption) (*DeleteGameWhitelistUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGameWhitelistUserResponse)
err := c.cc.Invoke(ctx, GameAdminService_DeleteGameWhitelistUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSelfGamesResponse)
@ -1239,6 +1287,10 @@ type GameAdminServiceServer interface {
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error)
DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error)
SetGameWhitelistEnabled(context.Context, *SetGameWhitelistEnabledRequest) (*CatalogResponse, error)
ListGameWhitelistUsers(context.Context, *ListGameWhitelistUsersRequest) (*ListGameWhitelistUsersResponse, error)
AddGameWhitelistUser(context.Context, *AddGameWhitelistUserRequest) (*GameWhitelistUserResponse, error)
DeleteGameWhitelistUser(context.Context, *DeleteGameWhitelistUserRequest) (*DeleteGameWhitelistUserResponse, error)
ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error)
UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error)
GetSelfGameNewUserPolicy(context.Context, *GetSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error)
@ -1284,6 +1336,18 @@ func (UnimplementedGameAdminServiceServer) SetGameStatus(context.Context, *SetGa
func (UnimplementedGameAdminServiceServer) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteCatalog not implemented")
}
func (UnimplementedGameAdminServiceServer) SetGameWhitelistEnabled(context.Context, *SetGameWhitelistEnabledRequest) (*CatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetGameWhitelistEnabled not implemented")
}
func (UnimplementedGameAdminServiceServer) ListGameWhitelistUsers(context.Context, *ListGameWhitelistUsersRequest) (*ListGameWhitelistUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListGameWhitelistUsers not implemented")
}
func (UnimplementedGameAdminServiceServer) AddGameWhitelistUser(context.Context, *AddGameWhitelistUserRequest) (*GameWhitelistUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddGameWhitelistUser not implemented")
}
func (UnimplementedGameAdminServiceServer) DeleteGameWhitelistUser(context.Context, *DeleteGameWhitelistUserRequest) (*DeleteGameWhitelistUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteGameWhitelistUser not implemented")
}
func (UnimplementedGameAdminServiceServer) ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListSelfGames not implemented")
}
@ -1464,6 +1528,78 @@ func _GameAdminService_DeleteCatalog_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_SetGameWhitelistEnabled_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetGameWhitelistEnabledRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).SetGameWhitelistEnabled(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_SetGameWhitelistEnabled_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).SetGameWhitelistEnabled(ctx, req.(*SetGameWhitelistEnabledRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListGameWhitelistUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListGameWhitelistUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListGameWhitelistUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListGameWhitelistUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListGameWhitelistUsers(ctx, req.(*ListGameWhitelistUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_AddGameWhitelistUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddGameWhitelistUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).AddGameWhitelistUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_AddGameWhitelistUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).AddGameWhitelistUser(ctx, req.(*AddGameWhitelistUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_DeleteGameWhitelistUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGameWhitelistUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).DeleteGameWhitelistUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_DeleteGameWhitelistUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).DeleteGameWhitelistUser(ctx, req.(*DeleteGameWhitelistUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListSelfGames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSelfGamesRequest)
if err := dec(in); err != nil {
@ -1801,6 +1937,22 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteCatalog",
Handler: _GameAdminService_DeleteCatalog_Handler,
},
{
MethodName: "SetGameWhitelistEnabled",
Handler: _GameAdminService_SetGameWhitelistEnabled_Handler,
},
{
MethodName: "ListGameWhitelistUsers",
Handler: _GameAdminService_ListGameWhitelistUsers_Handler,
},
{
MethodName: "AddGameWhitelistUser",
Handler: _GameAdminService_AddGameWhitelistUser_Handler,
},
{
MethodName: "DeleteGameWhitelistUser",
Handler: _GameAdminService_DeleteGameWhitelistUser_Handler,
},
{
MethodName: "ListSelfGames",
Handler: _GameAdminService_ListSelfGames_Handler,

View File

@ -16,6 +16,10 @@ type Client interface {
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
SetGameWhitelistEnabled(ctx context.Context, req *gamev1.SetGameWhitelistEnabledRequest) (*gamev1.CatalogResponse, error)
ListGameWhitelistUsers(ctx context.Context, req *gamev1.ListGameWhitelistUsersRequest) (*gamev1.ListGameWhitelistUsersResponse, error)
AddGameWhitelistUser(ctx context.Context, req *gamev1.AddGameWhitelistUserRequest) (*gamev1.GameWhitelistUserResponse, error)
DeleteGameWhitelistUser(ctx context.Context, req *gamev1.DeleteGameWhitelistUserRequest) (*gamev1.DeleteGameWhitelistUserResponse, error)
ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error)
GetSelfGameNewUserPolicy(ctx context.Context, req *gamev1.GetSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error)
@ -67,6 +71,22 @@ func (c *GRPCClient) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalo
return c.client.DeleteCatalog(ctx, req)
}
func (c *GRPCClient) SetGameWhitelistEnabled(ctx context.Context, req *gamev1.SetGameWhitelistEnabledRequest) (*gamev1.CatalogResponse, error) {
return c.client.SetGameWhitelistEnabled(ctx, req)
}
func (c *GRPCClient) ListGameWhitelistUsers(ctx context.Context, req *gamev1.ListGameWhitelistUsersRequest) (*gamev1.ListGameWhitelistUsersResponse, error) {
return c.client.ListGameWhitelistUsers(ctx, req)
}
func (c *GRPCClient) AddGameWhitelistUser(ctx context.Context, req *gamev1.AddGameWhitelistUserRequest) (*gamev1.GameWhitelistUserResponse, error) {
return c.client.AddGameWhitelistUser(ctx, req)
}
func (c *GRPCClient) DeleteGameWhitelistUser(ctx context.Context, req *gamev1.DeleteGameWhitelistUserRequest) (*gamev1.DeleteGameWhitelistUserResponse, error) {
return c.client.DeleteGameWhitelistUser(ctx, req)
}
func (c *GRPCClient) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
return c.client.ListSelfGames(ctx, req)
}

View File

@ -46,6 +46,8 @@ type catalogDTO struct {
Tags []string `json:"tags"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
// whitelistEnabled 只表示访问策略是否生效;成员通过独立接口按需加载,避免目录列表膨胀。
WhitelistEnabled bool `json:"whitelistEnabled"`
}
type diceConfigDTO struct {
@ -215,23 +217,24 @@ func catalogFromProto(item *gamev1.GameCatalogItem) catalogDTO {
return catalogDTO{}
}
return catalogDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
SafeHeight: item.GetSafeHeight(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
SafeHeight: item.GetSafeHeight(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
WhitelistEnabled: item.GetWhitelistEnabled(),
}
}

View File

@ -0,0 +1,150 @@
package gamemanagement
import (
"strconv"
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
type gameWhitelistEnabledRequest struct {
Enabled bool `json:"enabled"`
}
type gameWhitelistUserRequest struct {
// 字符串承载 user_id避免浏览器 Number 对大整数发生精度损失。
UserID string `json:"userId"`
}
type gameWhitelistUserDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
CreatedByAdminID int64 `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
}
// SetGameWhitelistEnabled 只切换访问策略,不隐式增删成员;空白名单开启后应明确表现为无人可见。
func (h *Handler) SetGameWhitelistEnabled(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
var req gameWhitelistEnabledRequest
if gameID == "" || c.ShouldBindJSON(&req) != nil {
response.BadRequest(c, "游戏白名单参数不正确")
return
}
resp, err := h.game.SetGameWhitelistEnabled(c.Request.Context(), &gamev1.SetGameWhitelistEnabledRequest{
Meta: requestMeta(c), GameId: gameID, Enabled: req.Enabled,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "set-game-whitelist-enabled", "game_catalog", gameID, strconv.FormatBool(req.Enabled))
response.OK(c, catalogFromProto(resp.GetGame()))
}
// ListGameWhitelistUsers 从 game-service 读取成员事实,再批量补 user-service 展示资料,避免复制用户主数据到游戏库。
func (h *Handler) ListGameWhitelistUsers(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
resp, err := h.game.ListGameWhitelistUsers(c.Request.Context(), &gamev1.ListGameWhitelistUsersRequest{
Meta: requestMeta(c), GameId: gameID, PageSize: int32(parsePositiveInt(c.Query("pageSize"), 200)),
})
if err != nil {
response.ServerError(c, "获取游戏白名单失败")
return
}
userIDs := make([]int64, 0, len(resp.GetUsers()))
for _, item := range resp.GetUsers() {
userIDs = append(userIDs, item.GetUserId())
}
profiles := map[int64]*userclient.User{}
if len(userIDs) > 0 {
profiles, err = h.user.BatchGetUsers(c.Request.Context(), userclient.BatchGetUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", UserIDs: userIDs,
})
if err != nil {
response.ServerError(c, "获取白名单用户资料失败")
return
}
}
items := make([]gameWhitelistUserDTO, 0, len(resp.GetUsers()))
for _, item := range resp.GetUsers() {
items = append(items, gameWhitelistUserFromProto(item, profiles[item.GetUserId()]))
}
response.OK(c, gin.H{"items": items, "serverTimeMs": resp.GetServerTimeMs()})
}
// AddGameWhitelistUser 先按内部 user_id 读取 owner 主数据,只有真实存在的用户才写入游戏访问成员表。
func (h *Handler) AddGameWhitelistUser(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
var req gameWhitelistUserRequest
if gameID == "" || c.ShouldBindJSON(&req) != nil {
response.BadRequest(c, "游戏白名单用户参数不正确")
return
}
userID, err := strconv.ParseInt(strings.TrimSpace(req.UserID), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "用户 ID 参数不正确")
return
}
profile, err := h.user.GetUser(c.Request.Context(), userclient.GetUserRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", UserID: userID,
})
if err != nil || profile == nil {
response.BadRequest(c, "用户不存在")
return
}
resp, err := h.game.AddGameWhitelistUser(c.Request.Context(), &gamev1.AddGameWhitelistUserRequest{
Meta: requestMeta(c), GameId: gameID, UserId: userID,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "add-game-whitelist-user", "game_user_whitelist", gameID+":"+req.UserID, "added")
response.OK(c, gameWhitelistUserFromProto(resp.GetUser(), profile))
}
func (h *Handler) DeleteGameWhitelistUser(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
if gameID == "" || err != nil || userID <= 0 {
response.BadRequest(c, "游戏白名单用户参数不正确")
return
}
if _, err := h.game.DeleteGameWhitelistUser(c.Request.Context(), &gamev1.DeleteGameWhitelistUserRequest{
Meta: requestMeta(c), GameId: gameID, UserId: userID,
}); err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "delete-game-whitelist-user", "game_user_whitelist", gameID+":"+strconv.FormatInt(userID, 10), "deleted")
response.OK(c, gin.H{"userId": strconv.FormatInt(userID, 10)})
}
func gameWhitelistUserFromProto(item *gamev1.GameWhitelistUser, profile *userclient.User) gameWhitelistUserDTO {
if item == nil {
return gameWhitelistUserDTO{}
}
dto := gameWhitelistUserDTO{
UserID: strconv.FormatInt(item.GetUserId(), 10), CreatedByAdminID: item.GetCreatedByAdminId(), CreatedAtMS: item.GetCreatedAtMs(),
}
if profile != nil {
dto.DisplayUserID = profile.DisplayUserID
dto.Username = profile.Username
dto.Avatar = profile.Avatar
dto.Status = profile.Status
}
return dto
}

View File

@ -20,6 +20,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
protected.PATCH("/admin/game/games/:game_id/whitelist", middleware.RequirePermission("game:update"), h.SetGameWhitelistEnabled)
protected.GET("/admin/game/games/:game_id/whitelist/users", middleware.RequirePermission("game:view"), h.ListGameWhitelistUsers)
protected.POST("/admin/game/games/:game_id/whitelist/users", middleware.RequirePermission("game:update"), h.AddGameWhitelistUser)
protected.DELETE("/admin/game/games/:game_id/whitelist/users/:user_id", middleware.RequirePermission("game:update"), h.DeleteGameWhitelistUser)
protected.GET("/admin/game/self-games", middleware.RequirePermission("game:view"), h.ListSelfGames)
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequirePermission("game:update"), h.UpdateDiceConfig)
protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:view"), h.GetSelfGameNewUserPolicy)

View File

@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS game_catalog (
safe_height INT NOT NULL DEFAULT 0 COMMENT '游戏安全高',
min_coin BIGINT NOT NULL DEFAULT 0 COMMENT '最小金币',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否仅对白名单用户展示并允许启动',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
tags JSON NULL COMMENT '标签',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -45,6 +46,27 @@ CREATE TABLE IF NOT EXISTS game_catalog (
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏目录表';
-- 白名单关闭时不读取本表;开启时所有在线校验均命中复合主键,后台列表使用时间覆盖索引做有限读取。
CREATE TABLE IF NOT EXISTS game_user_whitelist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID',
user_id BIGINT NOT NULL COMMENT '允许访问的内部用户 ID',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '添加成员的管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '添加时间UTC epoch ms',
PRIMARY KEY(app_code, game_id, user_id),
KEY idx_game_whitelist_game_time(app_code, game_id, created_at_ms, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏用户白名单';
-- 已有库的 game_catalog 不会因 CREATE TABLE IF NOT EXISTS 自动补列;目录行很少,幂等 DDL 的锁影响可控且只执行一次。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_catalog' AND COLUMN_NAME = 'whitelist_enabled') = 0,
'ALTER TABLE game_catalog ADD COLUMN whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否仅对白名单用户展示并允许启动'' AFTER status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS game_display_rules (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
rule_id VARCHAR(96) NOT NULL COMMENT '规则 ID',

View File

@ -78,6 +78,17 @@ type CatalogItem struct {
Tags []string
CreatedAtMS int64
UpdatedAtMS int64
// WhitelistEnabled 默认 false保证迁移后旧游戏仍面向原有用户集合开放。
WhitelistEnabled bool
}
// WhitelistUser 保存目录访问成员和后台操作来源;用户昵称、头像等主数据仍由 user-service 持有。
type WhitelistUser struct {
AppCode string
GameID string
UserID int64
CreatedByAdminID int64
CreatedAtMS int64
}
// AppGame 是 App 语音房内看到的跨平台合并游戏卡片。

View File

@ -57,6 +57,11 @@ type Repository interface {
UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error)
SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error)
DeleteCatalog(ctx context.Context, appCode string, gameID string) error
CanUserAccessGame(ctx context.Context, appCode string, gameID string, userID int64) (bool, error)
SetGameWhitelistEnabled(ctx context.Context, appCode string, gameID string, enabled bool, nowMS int64) (gamedomain.CatalogItem, error)
ListGameWhitelistUsers(ctx context.Context, appCode string, gameID string, pageSize int32) ([]gamedomain.WhitelistUser, error)
AddGameWhitelistUser(ctx context.Context, item gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error)
DeleteGameWhitelistUser(ctx context.Context, appCode string, gameID string, userID int64) error
}
// WalletClient 是 game-service 调 wallet-service 的最小改账依赖。
@ -276,6 +281,14 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
}
// 列表过滤只改善客户端体验,不能作为访问控制;启动链路必须再次按真实登录 user_id 校验,阻止猜 game_id 绕过白名单。
allowed, err := s.repository.CanUserAccessGame(ctx, command.AppCode, game.GameID, command.UserID)
if err != nil {
return LaunchResult{}, err
}
if !allowed {
return LaunchResult{}, xerr.New(xerr.NotFound, "game not found")
}
regionID, countryID := command.RegionID, command.CountryID
if regionID <= 0 || countryID <= 0 {
var err error

View File

@ -69,6 +69,26 @@ func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) {
}
}
func TestLaunchGameRejectsUserOutsideEnabledWhitelist(t *testing.T) {
allowed := false
repo := &fakeRepository{
accessAllowed: &allowed,
launchable: gamedomain.LaunchableGame{
CatalogItem: gamedomain.CatalogItem{AppCode: "lalu", GameID: "private_slot", PlatformCode: "demo", ProviderGameID: "private_1", GameName: "Private", Status: gamedomain.StatusActive},
PlatformStatus: gamedomain.StatusActive,
},
}
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
_, err := svc.LaunchGame(context.Background(), LaunchCommand{AppCode: "lalu", UserID: 42, GameID: "private_slot"})
if !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("user outside enabled whitelist must not discover or launch game, got %v", err)
}
if len(repo.sessions) != 0 {
t.Fatalf("denied launch must not create a session: %+v", repo.sessions)
}
}
func TestListRecentGamesNormalizesQueryAndDefaultsLimit(t *testing.T) {
repo := &fakeRepository{
recentGames: []gamedomain.AppGame{
@ -1980,6 +2000,7 @@ type fakeRepository struct {
lastListPlatformsStatus string
deliveredEvents []string
failedEvents []string
accessAllowed *bool
}
func (f *fakeRepository) Ping(context.Context) error { return nil }
@ -2139,6 +2160,24 @@ func (f *fakeRepository) SetGameStatus(context.Context, string, string, string,
func (f *fakeRepository) DeleteCatalog(context.Context, string, string) error {
return nil
}
func (f *fakeRepository) CanUserAccessGame(context.Context, string, string, int64) (bool, error) {
if f.accessAllowed != nil {
return *f.accessAllowed, nil
}
return true, nil
}
func (f *fakeRepository) SetGameWhitelistEnabled(context.Context, string, string, bool, int64) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, nil
}
func (f *fakeRepository) ListGameWhitelistUsers(context.Context, string, string, int32) ([]gamedomain.WhitelistUser, error) {
return nil, nil
}
func (f *fakeRepository) AddGameWhitelistUser(_ context.Context, item gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error) {
return item, nil
}
func (f *fakeRepository) DeleteGameWhitelistUser(context.Context, string, string, int64) error {
return nil
}
type fakeWallet struct {
lastApply *walletv1.ApplyGameCoinChangeRequest

View File

@ -93,6 +93,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
safe_height INT NOT NULL DEFAULT 0,
min_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
tags JSON NULL,
created_at_ms BIGINT NOT NULL,
@ -101,6 +102,15 @@ func (r *Repository) Migrate(ctx context.Context) error {
UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_user_whitelist (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id, user_id),
KEY idx_game_whitelist_game_time(app_code, game_id, created_at_ms, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_display_rules (
app_code VARCHAR(32) NOT NULL,
rule_id VARCHAR(96) NOT NULL,
@ -494,6 +504,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
return err
}
}
// game_catalog 线上已有数据量很小;新增带默认值的布尔列不会回填业务数据,默认关闭也保持历史可见范围。
if err := r.ensureColumn(ctx, "game_catalog", "whitelist_enabled", "whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER status"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT '' AFTER api_base_url"); err != nil {
return err
}
@ -702,10 +716,15 @@ func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQ
AND (r.region_id = 0 OR r.region_id = ?)
AND (r.language = '' OR r.language = ?)
AND (r.platform = '' OR r.platform = ?)
AND (c.whitelist_enabled = 0 OR EXISTS (
SELECT 1
FROM game_user_whitelist w
WHERE w.app_code = c.app_code AND w.game_id = c.game_id AND w.user_id = ?
))
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status, c.sort_order, p.sort_order
ORDER BY display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC`,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, query.UserID,
)
if err != nil {
return nil, err
@ -758,13 +777,18 @@ func (r *Repository) ListRecentGames(ctx context.Context, query gameservice.List
AND (r.region_id = 0 OR r.region_id = ?)
AND (r.language = '' OR r.language = ?)
AND (r.platform = '' OR r.platform = ?)
AND (c.whitelist_enabled = 0 OR EXISTS (
SELECT 1
FROM game_user_whitelist w
WHERE w.app_code = c.app_code AND w.game_id = c.game_id AND w.user_id = ?
))
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
c.sort_order, p.sort_order, recent.last_played_at_ms
ORDER BY recent.last_played_at_ms DESC, display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC
LIMIT ?`,
appcode.Normalize(query.AppCode), query.UserID, query.Scene,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, pageSize,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, query.UserID, pageSize,
)
if err != nil {
return nil, err
@ -816,6 +840,31 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game
return item, nil
}
// CanUserAccessGame 在真正创建启动会话前做精确授权;关闭白名单时直接放行,开启时只命中复合主键,不扫描成员表。
func (r *Repository) CanUserAccessGame(ctx context.Context, appCode string, gameID string, userID int64) (bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT CASE
WHEN c.whitelist_enabled = 0 THEN 1
WHEN EXISTS (
SELECT 1 FROM game_user_whitelist w
WHERE w.app_code = c.app_code AND w.game_id = c.game_id AND w.user_id = ?
) THEN 1
ELSE 0
END
FROM game_catalog c
WHERE c.app_code = ? AND c.game_id = ?`,
userID, appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
var allowed bool
if err := row.Scan(&allowed); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, xerr.New(xerr.NotFound, "game not found")
}
return false, err
}
return allowed, nil
}
func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) {
// 回调入口只知道 platform_code必须按 app_code 隔离查询,防止多租户平台编码冲突。
row := r.db.QueryRowContext(ctx,
@ -1598,7 +1647,8 @@ func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Pla
func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
sqlText := `SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, safe_height, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
launch_mode, orientation, safe_height, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms,
whitelist_enabled
FROM game_catalog WHERE app_code = ?`
args := []any{appcode.Normalize(query.AppCode)}
if strings.TrimSpace(query.PlatformCode) != "" {
@ -1668,7 +1718,11 @@ func (r *Repository) UpsertCatalog(ctx context.Context, item gamedomain.CatalogI
); err != nil {
return gamedomain.CatalogItem{}, err
}
return item, tx.Commit()
if err := tx.Commit(); err != nil {
return gamedomain.CatalogItem{}, err
}
// whitelist_enabled 只允许专用管理接口修改;更新目录后重新读取真实行,避免响应把既有白名单状态误报为 false。
return r.getCatalog(ctx, item.AppCode, item.GameID)
}
func defaultVoiceRoomRuleID(gameID string) string {
@ -1690,6 +1744,96 @@ func (r *Repository) SetGameStatus(ctx context.Context, appCode string, gameID s
return r.getCatalog(ctx, appCode, gameID)
}
func (r *Repository) SetGameWhitelistEnabled(ctx context.Context, appCode string, gameID string, enabled bool, nowMS int64) (gamedomain.CatalogItem, error) {
result, err := r.db.ExecContext(ctx,
`UPDATE game_catalog SET whitelist_enabled = ?, updated_at_ms = ? WHERE app_code = ? AND game_id = ?`,
enabled, nowMS, appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
if err != nil {
return gamedomain.CatalogItem{}, err
}
if affected, err := result.RowsAffected(); err != nil {
return gamedomain.CatalogItem{}, err
} else if affected == 0 {
return gamedomain.CatalogItem{}, xerr.New(xerr.NotFound, "game not found")
}
return r.getCatalog(ctx, appCode, gameID)
}
func (r *Repository) ListGameWhitelistUsers(ctx context.Context, appCode string, gameID string, pageSize int32) ([]gamedomain.WhitelistUser, error) {
if pageSize <= 0 || pageSize > 200 {
pageSize = 200
}
// 管理页默认展示当前游戏成员;覆盖索引按 game 范围读取并硬限制数量,避免成员增长后出现无界查询。
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, game_id, user_id, created_by_admin_id, created_at_ms
FROM game_user_whitelist FORCE INDEX (idx_game_whitelist_game_time)
WHERE app_code = ? AND game_id = ?
ORDER BY created_at_ms DESC, user_id DESC
LIMIT ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID), pageSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]gamedomain.WhitelistUser, 0)
for rows.Next() {
var item gamedomain.WhitelistUser
if err := rows.Scan(&item.AppCode, &item.GameID, &item.UserID, &item.CreatedByAdminID, &item.CreatedAtMS); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) AddGameWhitelistUser(ctx context.Context, item gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error) {
item.AppCode = appcode.Normalize(item.AppCode)
item.GameID = strings.TrimSpace(item.GameID)
if item.UserID <= 0 || item.GameID == "" {
return gamedomain.WhitelistUser{}, xerr.New(xerr.InvalidArgument, "game whitelist user is incomplete")
}
if item.CreatedAtMS <= 0 {
item.CreatedAtMS = time.Now().UnixMilli()
}
// INSERT ... SELECT 同时验证目录存在;重复添加只更新操作来源和时间,复合主键保证不会产生重复成员。
result, err := r.db.ExecContext(ctx,
`INSERT INTO game_user_whitelist (app_code, game_id, user_id, created_by_admin_id, created_at_ms)
SELECT app_code, game_id, ?, ?, ? FROM game_catalog WHERE app_code = ? AND game_id = ?
ON DUPLICATE KEY UPDATE created_by_admin_id = VALUES(created_by_admin_id), created_at_ms = VALUES(created_at_ms)`,
item.UserID, item.CreatedByAdminID, item.CreatedAtMS, item.AppCode, item.GameID,
)
if err != nil {
return gamedomain.WhitelistUser{}, err
}
if affected, err := result.RowsAffected(); err != nil {
return gamedomain.WhitelistUser{}, err
} else if affected == 0 {
return gamedomain.WhitelistUser{}, xerr.New(xerr.NotFound, "game not found")
}
return item, nil
}
func (r *Repository) DeleteGameWhitelistUser(ctx context.Context, appCode string, gameID string, userID int64) error {
if userID <= 0 {
return xerr.New(xerr.InvalidArgument, "user_id is required")
}
result, err := r.db.ExecContext(ctx,
`DELETE FROM game_user_whitelist WHERE app_code = ? AND game_id = ? AND user_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID), userID,
)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil {
return err
} else if affected == 0 {
return xerr.New(xerr.NotFound, "game whitelist user not found")
}
return nil
}
func (r *Repository) DeleteCatalog(ctx context.Context, appCode string, gameID string) error {
app := appcode.Normalize(appCode)
id := strings.TrimSpace(gameID)
@ -1719,13 +1863,17 @@ func (r *Repository) DeleteCatalog(ctx context.Context, appCode string, gameID s
if _, err := tx.ExecContext(ctx, `DELETE FROM game_display_rules WHERE app_code = ? AND game_id = ?`, app, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM game_user_whitelist WHERE app_code = ? AND game_id = ?`, app, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) getCatalog(ctx context.Context, appCode string, gameID string) (gamedomain.CatalogItem, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, safe_height, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
launch_mode, orientation, safe_height, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms,
whitelist_enabled
FROM game_catalog WHERE app_code = ? AND game_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
@ -1790,7 +1938,7 @@ func levelEventOutboxSelectSQL() string {
func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
var item gamedomain.CatalogItem
var tagsJSON string
if err := scanner.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if err := scanner.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.WhitelistEnabled); err != nil {
return gamedomain.CatalogItem{}, err
}
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)

View File

@ -362,6 +362,66 @@ func TestAppGameListsExcludeBuiltInSelfGames(t *testing.T) {
}
}
func TestGameWhitelistFiltersCatalogAndRecentListsAndControlsLaunchAccess(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
nowMS := int64(1781200000000)
if _, err := repo.UpsertPlatform(ctx, gamedomain.Platform{AppCode: "lalu", PlatformCode: "demo", PlatformName: "Demo", Status: gamedomain.StatusActive, AdapterType: gamedomain.AdapterDemo, CreatedAtMS: nowMS, UpdatedAtMS: nowMS}); err != nil {
t.Fatalf("upsert platform failed: %v", err)
}
item := gamedomain.CatalogItem{AppCode: "lalu", GameID: "private_slot", PlatformCode: "demo", ProviderGameID: "private_1", GameName: "Private Slot", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive}
if _, err := repo.UpsertCatalog(ctx, item); err != nil {
t.Fatalf("upsert catalog failed: %v", err)
}
if err := repo.CreateLaunchSession(ctx, gamedomain.LaunchSession{AppCode: "lalu", SessionID: "private_session", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "demo", GameID: item.GameID, ProviderGameID: item.ProviderGameID, LaunchTokenHash: "private_hash", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS, UpdatedAtMS: nowMS}); err != nil {
t.Fatalf("create launch session failed: %v", err)
}
if _, err := repo.SetGameWhitelistEnabled(ctx, "lalu", item.GameID, true, nowMS); err != nil {
t.Fatalf("enable whitelist failed: %v", err)
}
listQuery := gameservice.ListGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom}
if games, err := repo.ListGames(ctx, listQuery); err != nil || len(games) != 0 {
t.Fatalf("non-member catalog list must hide game, games=%+v err=%v", games, err)
}
if recent, err := repo.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PageSize: 10}); err != nil || len(recent) != 0 {
t.Fatalf("non-member recent list must hide game, games=%+v err=%v", recent, err)
}
if allowed, err := repo.CanUserAccessGame(ctx, "lalu", item.GameID, 42); err != nil || allowed {
t.Fatalf("non-member launch access mismatch: allowed=%v err=%v", allowed, err)
}
member, err := repo.AddGameWhitelistUser(ctx, gamedomain.WhitelistUser{AppCode: "lalu", GameID: item.GameID, UserID: 42, CreatedByAdminID: 7, CreatedAtMS: nowMS + 1})
if err != nil || member.UserID != 42 {
t.Fatalf("add whitelist member failed: member=%+v err=%v", member, err)
}
if games, err := repo.ListGames(ctx, listQuery); err != nil || len(games) != 1 || games[0].GameID != item.GameID {
t.Fatalf("member catalog list mismatch: games=%+v err=%v", games, err)
}
if allowed, err := repo.CanUserAccessGame(ctx, "lalu", item.GameID, 42); err != nil || !allowed {
t.Fatalf("member launch access mismatch: allowed=%v err=%v", allowed, err)
}
members, err := repo.ListGameWhitelistUsers(ctx, "lalu", item.GameID, 200)
if err != nil || len(members) != 1 || members[0].CreatedByAdminID != 7 {
t.Fatalf("whitelist member list mismatch: members=%+v err=%v", members, err)
}
if err := repo.DeleteGameWhitelistUser(ctx, "lalu", item.GameID, 42); err != nil {
t.Fatalf("delete whitelist member failed: %v", err)
}
if allowed, err := repo.CanUserAccessGame(ctx, "lalu", item.GameID, 42); err != nil || allowed {
t.Fatalf("removed member must lose launch access: allowed=%v err=%v", allowed, err)
}
}
func TestMarkOrderSucceededCreatesClaimableLevelOutboxEvent(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{

View File

@ -493,6 +493,57 @@ func (s *Server) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogReq
return &gamev1.DeleteCatalogResponse{ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) SetGameWhitelistEnabled(ctx context.Context, req *gamev1.SetGameWhitelistEnabledRequest) (*gamev1.CatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, err := s.svcRepository().SetGameWhitelistEnabled(
ctx,
req.GetMeta().GetAppCode(),
strings.TrimSpace(req.GetGameId()),
req.GetEnabled(),
time.Now().UnixMilli(),
)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CatalogResponse{Game: catalogToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) ListGameWhitelistUsers(ctx context.Context, req *gamev1.ListGameWhitelistUsersRequest) (*gamev1.ListGameWhitelistUsersResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, err := s.svcRepository().ListGameWhitelistUsers(ctx, req.GetMeta().GetAppCode(), strings.TrimSpace(req.GetGameId()), req.GetPageSize())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListGameWhitelistUsersResponse{Users: make([]*gamev1.GameWhitelistUser, 0, len(items)), ServerTimeMs: time.Now().UnixMilli()}
for _, item := range items {
resp.Users = append(resp.Users, whitelistUserToProto(item))
}
return resp, nil
}
func (s *Server) AddGameWhitelistUser(ctx context.Context, req *gamev1.AddGameWhitelistUserRequest) (*gamev1.GameWhitelistUserResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, err := s.svcRepository().AddGameWhitelistUser(ctx, gamedomain.WhitelistUser{
AppCode: req.GetMeta().GetAppCode(),
GameID: strings.TrimSpace(req.GetGameId()),
UserID: req.GetUserId(),
CreatedByAdminID: req.GetMeta().GetActorUserId(),
CreatedAtMS: time.Now().UnixMilli(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.GameWhitelistUserResponse{User: whitelistUserToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) DeleteGameWhitelistUser(ctx context.Context, req *gamev1.DeleteGameWhitelistUserRequest) (*gamev1.DeleteGameWhitelistUserResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if err := s.svcRepository().DeleteGameWhitelistUser(ctx, req.GetMeta().GetAppCode(), strings.TrimSpace(req.GetGameId()), req.GetUserId()); err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.DeleteGameWhitelistUserResponse{ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
if s == nil || s.diceSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
@ -731,6 +782,21 @@ func (unavailableRepository) SetGameStatus(context.Context, string, string, stri
func (unavailableRepository) DeleteCatalog(context.Context, string, string) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CanUserAccessGame(context.Context, string, string, int64) (bool, error) {
return false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) SetGameWhitelistEnabled(context.Context, string, string, bool, int64) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListGameWhitelistUsers(context.Context, string, string, int32) ([]gamedomain.WhitelistUser, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) AddGameWhitelistUser(context.Context, gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error) {
return gamedomain.WhitelistUser{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) DeleteGameWhitelistUser(context.Context, string, string, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame {
return &gamev1.AppGame{
@ -1051,23 +1117,34 @@ func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform {
func catalogToProto(item gamedomain.CatalogItem) *gamev1.GameCatalogItem {
return &gamev1.GameCatalogItem{
AppCode: item.AppCode,
GameId: item.GameID,
PlatformCode: item.PlatformCode,
ProviderGameId: item.ProviderGameID,
GameName: item.GameName,
Category: item.Category,
IconUrl: item.IconURL,
CoverUrl: item.CoverURL,
LaunchMode: item.LaunchMode,
Orientation: item.Orientation,
SafeHeight: item.SafeHeight,
MinCoin: item.MinCoin,
Status: item.Status,
SortOrder: item.SortOrder,
Tags: item.Tags,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
AppCode: item.AppCode,
GameId: item.GameID,
PlatformCode: item.PlatformCode,
ProviderGameId: item.ProviderGameID,
GameName: item.GameName,
Category: item.Category,
IconUrl: item.IconURL,
CoverUrl: item.CoverURL,
LaunchMode: item.LaunchMode,
Orientation: item.Orientation,
SafeHeight: item.SafeHeight,
MinCoin: item.MinCoin,
Status: item.Status,
SortOrder: item.SortOrder,
Tags: item.Tags,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
WhitelistEnabled: item.WhitelistEnabled,
}
}
func whitelistUserToProto(item gamedomain.WhitelistUser) *gamev1.GameWhitelistUser {
return &gamev1.GameWhitelistUser{
AppCode: item.AppCode,
GameId: item.GameID,
UserId: item.UserID,
CreatedByAdminId: item.CreatedByAdminID,
CreatedAtMs: item.CreatedAtMS,
}
}
@ -1076,21 +1153,22 @@ func catalogFromProto(item *gamev1.GameCatalogItem) gamedomain.CatalogItem {
return gamedomain.CatalogItem{}
}
return gamedomain.CatalogItem{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
SafeHeight: item.GetSafeHeight(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
SafeHeight: item.GetSafeHeight(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
WhitelistEnabled: item.GetWhitelistEnabled(),
}
}