feat: add manager BD Leader scope policy
This commit is contained in:
parent
1fe23326c6
commit
2444a7a108
File diff suppressed because it is too large
Load Diff
@ -482,6 +482,22 @@ message CheckBusinessCapabilityResponse {
|
|||||||
string reason = 2;
|
string reason = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoleScopePolicy 是 App 级角色拓展边界;调用方只消费 scope,不直接判断具体 app_code。
|
||||||
|
message RoleScopePolicy {
|
||||||
|
string scene = 1;
|
||||||
|
string base_scope = 2;
|
||||||
|
bool region_expansion_configurable = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoleScopePolicyRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string scene = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoleScopePolicyResponse {
|
||||||
|
RoleScopePolicy policy = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message GetAgencyMembersRequest {
|
message GetAgencyMembersRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
int64 agency_id = 2;
|
int64 agency_id = 2;
|
||||||
@ -709,6 +725,7 @@ service UserHostService {
|
|||||||
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
||||||
rpc GetAgency(GetAgencyRequest) returns (GetAgencyResponse);
|
rpc GetAgency(GetAgencyRequest) returns (GetAgencyResponse);
|
||||||
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
|
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
|
||||||
|
rpc GetRoleScopePolicy(GetRoleScopePolicyRequest) returns (GetRoleScopePolicyResponse);
|
||||||
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
|
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
|
||||||
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
|
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
|
||||||
rpc UpdateAgencyProfile(UpdateAgencyProfileRequest) returns (UpdateAgencyProfileResponse);
|
rpc UpdateAgencyProfile(UpdateAgencyProfileRequest) returns (UpdateAgencyProfileResponse);
|
||||||
|
|||||||
@ -45,6 +45,7 @@ const (
|
|||||||
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
||||||
UserHostService_GetAgency_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgency"
|
UserHostService_GetAgency_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgency"
|
||||||
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
|
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
|
||||||
|
UserHostService_GetRoleScopePolicy_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleScopePolicy"
|
||||||
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
|
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
|
||||||
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
|
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
|
||||||
UserHostService_UpdateAgencyProfile_FullMethodName = "/hyapp.user.v1.UserHostService/UpdateAgencyProfile"
|
UserHostService_UpdateAgencyProfile_FullMethodName = "/hyapp.user.v1.UserHostService/UpdateAgencyProfile"
|
||||||
@ -83,6 +84,7 @@ type UserHostServiceClient interface {
|
|||||||
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
|
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
|
||||||
GetAgency(ctx context.Context, in *GetAgencyRequest, opts ...grpc.CallOption) (*GetAgencyResponse, error)
|
GetAgency(ctx context.Context, in *GetAgencyRequest, opts ...grpc.CallOption) (*GetAgencyResponse, error)
|
||||||
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
|
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
|
||||||
|
GetRoleScopePolicy(ctx context.Context, in *GetRoleScopePolicyRequest, opts ...grpc.CallOption) (*GetRoleScopePolicyResponse, error)
|
||||||
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
|
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
|
||||||
GetAgencyApplications(ctx context.Context, in *GetAgencyApplicationsRequest, opts ...grpc.CallOption) (*GetAgencyApplicationsResponse, error)
|
GetAgencyApplications(ctx context.Context, in *GetAgencyApplicationsRequest, opts ...grpc.CallOption) (*GetAgencyApplicationsResponse, error)
|
||||||
UpdateAgencyProfile(ctx context.Context, in *UpdateAgencyProfileRequest, opts ...grpc.CallOption) (*UpdateAgencyProfileResponse, error)
|
UpdateAgencyProfile(ctx context.Context, in *UpdateAgencyProfileRequest, opts ...grpc.CallOption) (*UpdateAgencyProfileResponse, error)
|
||||||
@ -356,6 +358,16 @@ func (c *userHostServiceClient) CheckBusinessCapability(ctx context.Context, in
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *userHostServiceClient) GetRoleScopePolicy(ctx context.Context, in *GetRoleScopePolicyRequest, opts ...grpc.CallOption) (*GetRoleScopePolicyResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetRoleScopePolicyResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserHostService_GetRoleScopePolicy_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *userHostServiceClient) GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error) {
|
func (c *userHostServiceClient) GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(GetAgencyMembersResponse)
|
out := new(GetAgencyMembersResponse)
|
||||||
@ -419,6 +431,7 @@ type UserHostServiceServer interface {
|
|||||||
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
||||||
GetAgency(context.Context, *GetAgencyRequest) (*GetAgencyResponse, error)
|
GetAgency(context.Context, *GetAgencyRequest) (*GetAgencyResponse, error)
|
||||||
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
|
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
|
||||||
|
GetRoleScopePolicy(context.Context, *GetRoleScopePolicyRequest) (*GetRoleScopePolicyResponse, error)
|
||||||
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
|
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
|
||||||
GetAgencyApplications(context.Context, *GetAgencyApplicationsRequest) (*GetAgencyApplicationsResponse, error)
|
GetAgencyApplications(context.Context, *GetAgencyApplicationsRequest) (*GetAgencyApplicationsResponse, error)
|
||||||
UpdateAgencyProfile(context.Context, *UpdateAgencyProfileRequest) (*UpdateAgencyProfileResponse, error)
|
UpdateAgencyProfile(context.Context, *UpdateAgencyProfileRequest) (*UpdateAgencyProfileResponse, error)
|
||||||
@ -510,6 +523,9 @@ func (UnimplementedUserHostServiceServer) GetAgency(context.Context, *GetAgencyR
|
|||||||
func (UnimplementedUserHostServiceServer) CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error) {
|
func (UnimplementedUserHostServiceServer) CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CheckBusinessCapability not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CheckBusinessCapability not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedUserHostServiceServer) GetRoleScopePolicy(context.Context, *GetRoleScopePolicyRequest) (*GetRoleScopePolicyResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetRoleScopePolicy not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedUserHostServiceServer) GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error) {
|
func (UnimplementedUserHostServiceServer) GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyMembers not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyMembers not implemented")
|
||||||
}
|
}
|
||||||
@ -1008,6 +1024,24 @@ func _UserHostService_CheckBusinessCapability_Handler(srv interface{}, ctx conte
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _UserHostService_GetRoleScopePolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetRoleScopePolicyRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserHostServiceServer).GetRoleScopePolicy(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserHostService_GetRoleScopePolicy_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserHostServiceServer).GetRoleScopePolicy(ctx, req.(*GetRoleScopePolicyRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _UserHostService_GetAgencyMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _UserHostService_GetAgencyMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(GetAgencyMembersRequest)
|
in := new(GetAgencyMembersRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -1173,6 +1207,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "CheckBusinessCapability",
|
MethodName: "CheckBusinessCapability",
|
||||||
Handler: _UserHostService_CheckBusinessCapability_Handler,
|
Handler: _UserHostService_CheckBusinessCapability_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetRoleScopePolicy",
|
||||||
|
Handler: _UserHostService_GetRoleScopePolicy_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GetAgencyMembers",
|
MethodName: "GetAgencyMembers",
|
||||||
Handler: _UserHostService_GetAgencyMembers_Handler,
|
Handler: _UserHostService_GetAgencyMembers_Handler,
|
||||||
|
|||||||
@ -79,7 +79,7 @@ graph LR
|
|||||||
|
|
||||||
当前 `user_id` 是内部稳定主键,后台仍必须在所有 host/Agency/BD 查询、幂等和审计 detail 中带上 `app_code`。如果某些现有表还使用全局 `PRIMARY KEY(user_id)`、`PRIMARY KEY(command_id)` 或 `PRIMARY KEY(event_id)`,这只能说明当前实现暂时要求这些 ID 全局唯一;不能据此省略 `app_code`,也不能在多 App 写入前声称同一个 `command_id` 可以跨 App 复用。
|
当前 `user_id` 是内部稳定主键,后台仍必须在所有 host/Agency/BD 查询、幂等和审计 detail 中带上 `app_code`。如果某些现有表还使用全局 `PRIMARY KEY(user_id)`、`PRIMARY KEY(command_id)` 或 `PRIMARY KEY(event_id)`,这只能说明当前实现暂时要求这些 ID 全局唯一;不能据此省略 `app_code`,也不能在多 App 写入前声称同一个 `command_id` 可以跨 App 复用。
|
||||||
|
|
||||||
经理中心创建 BD Leader 时,`app_code` 必须来自已验证的 App token,不接受客户端自报租户。Huwaa 的 `manager_add_bd_leader` 搜索和创建可跨国家/区域;其他 App 仍要求经理和目标用户同国家。两类路径都必须保留 active Manager 和 `manager_add_bd_leader` 能力校验。
|
经理中心创建 BD Leader 时,`app_code` 必须来自已验证的 App token,不接受客户端自报租户。App 差异统一由 user-service 的角色范围策略解析:Huwaa 的基础范围是 `global`;其他 App 的基础范围是 `country`,且只有策略声明可配置时,经理的区域邀请开关才能把有效范围扩展为 `region`。搜索和创建入口只消费 `country|region|global` 策略结果,不直接判断具体 App;两类路径都必须保留 active Manager 和 `manager_add_bd_leader` 能力校验。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
admin_operation_logs(
|
admin_operation_logs(
|
||||||
|
|||||||
13
scripts/mysql/064_manager_bd_leader_region_scope.sql
Normal file
13
scripts/mysql/064_manager_bd_leader_region_scope.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_user;
|
||||||
|
|
||||||
|
-- 区域邀请是经理个人的显式扩权;默认 0 保证现有和新建经理继续使用 App 基础范围。
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_bd_leader_in_region') = 0,
|
||||||
|
'ALTER TABLE manager_profiles ADD COLUMN can_add_bd_leader_in_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否允许把 BD Leader 邀请范围从本国家扩展到经理所属区域'' AFTER can_add_bd_leader',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -2,6 +2,7 @@ package userclient
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp-admin-server/internal/appctx"
|
"hyapp-admin-server/internal/appctx"
|
||||||
@ -41,6 +42,7 @@ type Client interface {
|
|||||||
ReviewCoinSellerSubApplication(ctx context.Context, req ReviewCoinSellerSubApplicationRequest) (*CoinSellerSubApplicationReviewResult, error)
|
ReviewCoinSellerSubApplication(ctx context.Context, req ReviewCoinSellerSubApplicationRequest) (*CoinSellerSubApplicationReviewResult, error)
|
||||||
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
||||||
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
||||||
|
GetRoleScopePolicy(ctx context.Context, req GetRoleScopePolicyRequest) (*RoleScopePolicy, error)
|
||||||
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
||||||
AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error)
|
AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error)
|
||||||
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
|
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
|
||||||
@ -111,6 +113,18 @@ type ChangeUserCountryResult struct {
|
|||||||
RegionChanged bool `json:"regionChanged"`
|
RegionChanged bool `json:"regionChanged"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetRoleScopePolicyRequest struct {
|
||||||
|
RequestID string
|
||||||
|
Caller string
|
||||||
|
Scene string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleScopePolicy struct {
|
||||||
|
Scene string `json:"scene"`
|
||||||
|
BaseScope string `json:"baseScope"`
|
||||||
|
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
|
||||||
|
}
|
||||||
|
|
||||||
type SetUserStatusResult struct {
|
type SetUserStatusResult struct {
|
||||||
User *User `json:"user"`
|
User *User `json:"user"`
|
||||||
RevokedSessionCount int64 `json:"revokedSessionCount"`
|
RevokedSessionCount int64 `json:"revokedSessionCount"`
|
||||||
@ -817,6 +831,25 @@ func (c *GRPCClient) CreateBDLeader(ctx context.Context, req CreateBDLeaderReque
|
|||||||
return fromProtoBDProfile(resp.GetBdProfile()), nil
|
return fromProtoBDProfile(resp.GetBdProfile()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) GetRoleScopePolicy(ctx context.Context, req GetRoleScopePolicyRequest) (*RoleScopePolicy, error) {
|
||||||
|
resp, err := c.hostClient.GetRoleScopePolicy(ctx, &userv1.GetRoleScopePolicyRequest{
|
||||||
|
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||||
|
Scene: req.Scene,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
policy := resp.GetPolicy()
|
||||||
|
if policy == nil {
|
||||||
|
return nil, fmt.Errorf("role scope policy missing")
|
||||||
|
}
|
||||||
|
return &RoleScopePolicy{
|
||||||
|
Scene: policy.GetScene(),
|
||||||
|
BaseScope: policy.GetBaseScope(),
|
||||||
|
RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) CreateBD(ctx context.Context, req CreateBDRequest) (*BDProfile, error) {
|
func (c *GRPCClient) CreateBD(ctx context.Context, req CreateBDRequest) (*BDProfile, error) {
|
||||||
resp, err := c.hostAdminClient.CreateBD(ctx, &userv1.CreateBDRequest{
|
resp, err := c.hostAdminClient.CreateBD(ctx, &userv1.CreateBDRequest{
|
||||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||||
|
|||||||
@ -68,12 +68,15 @@ func (h *Handler) ListManagers(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, total, err := h.service.ListManagers(c.Request.Context(), query)
|
items, total, policy, err := h.service.ListManagers(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取经理列表失败")
|
response.ServerError(c, "获取经理列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
response.OK(c, ManagerPageResponse{
|
||||||
|
Items: items, Page: query.Page, PageSize: query.PageSize, Total: total,
|
||||||
|
BDLeaderInviteScopePolicy: policy,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CreateManager(c *gin.Context) {
|
func (h *Handler) CreateManager(c *gin.Context) {
|
||||||
|
|||||||
@ -68,28 +68,45 @@ type UserIdentity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ManagerListItem struct {
|
type ManagerListItem struct {
|
||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
DisplayUserID string `json:"displayUserId"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
RegionID int64 `json:"regionId"`
|
RegionID int64 `json:"regionId"`
|
||||||
RegionName string `json:"regionName"`
|
RegionName string `json:"regionName"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Contact string `json:"contact"`
|
Contact string `json:"contact"`
|
||||||
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
|
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
|
||||||
CanGrantVehicle bool `json:"canGrantVehicle"`
|
CanGrantVehicle bool `json:"canGrantVehicle"`
|
||||||
CanGrantBadge bool `json:"canGrantBadge"`
|
CanGrantBadge bool `json:"canGrantBadge"`
|
||||||
CanGrantVIP bool `json:"canGrantVip"`
|
CanGrantVIP bool `json:"canGrantVip"`
|
||||||
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
|
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
|
||||||
CanAddBDLeader bool `json:"canAddBdLeader"`
|
CanAddBDLeader bool `json:"canAddBdLeader"`
|
||||||
CanAddAdmin bool `json:"canAddAdmin"`
|
CanAddBDLeaderInRegion bool `json:"canAddBdLeaderInRegion"`
|
||||||
CanAddSuperadmin bool `json:"canAddSuperadmin"`
|
BDLeaderInviteBaseScope string `json:"bdLeaderInviteBaseScope"`
|
||||||
CanBlockUser bool `json:"canBlockUser"`
|
BDLeaderInviteEffectiveScope string `json:"bdLeaderInviteEffectiveScope"`
|
||||||
CanTransferCountry bool `json:"canTransferUserCountry"`
|
BDLeaderRegionExpansionConfigurable bool `json:"bdLeaderRegionExpansionConfigurable"`
|
||||||
BDLeaderCount int64 `json:"bdLeaderCount"`
|
CanAddAdmin bool `json:"canAddAdmin"`
|
||||||
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
|
CanAddSuperadmin bool `json:"canAddSuperadmin"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CanBlockUser bool `json:"canBlockUser"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
CanTransferCountry bool `json:"canTransferUserCountry"`
|
||||||
|
BDLeaderCount int64 `json:"bdLeaderCount"`
|
||||||
|
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
|
||||||
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagerRoleScopePolicy struct {
|
||||||
|
BaseScope string `json:"baseScope"`
|
||||||
|
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagerPageResponse struct {
|
||||||
|
Items []*ManagerListItem `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
BDLeaderInviteScopePolicy ManagerRoleScopePolicy `json:"bdLeaderInviteScopePolicy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoinSellerSalaryRateTier struct {
|
type CoinSellerSalaryRateTier struct {
|
||||||
@ -645,9 +662,9 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
|
|||||||
if _, err := r.db.ExecContext(ctx, `
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO manager_profiles (
|
INSERT INTO manager_profiles (
|
||||||
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
||||||
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
|
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_bd_leader_in_region, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
|
||||||
created_by_admin_id, created_at_ms, updated_at_ms
|
created_by_admin_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
status = 'active',
|
status = 'active',
|
||||||
contact_info = VALUES(contact_info),
|
contact_info = VALUES(contact_info),
|
||||||
@ -657,13 +674,14 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
|
|||||||
can_grant_vip = VALUES(can_grant_vip),
|
can_grant_vip = VALUES(can_grant_vip),
|
||||||
can_update_user_level = VALUES(can_update_user_level),
|
can_update_user_level = VALUES(can_update_user_level),
|
||||||
can_add_bd_leader = VALUES(can_add_bd_leader),
|
can_add_bd_leader = VALUES(can_add_bd_leader),
|
||||||
|
can_add_bd_leader_in_region = VALUES(can_add_bd_leader_in_region),
|
||||||
can_add_admin = VALUES(can_add_admin),
|
can_add_admin = VALUES(can_add_admin),
|
||||||
can_add_superadmin = VALUES(can_add_superadmin),
|
can_add_superadmin = VALUES(can_add_superadmin),
|
||||||
can_block_user = VALUES(can_block_user),
|
can_block_user = VALUES(can_block_user),
|
||||||
can_transfer_user_country = VALUES(can_transfer_user_country),
|
can_transfer_user_country = VALUES(can_transfer_user_country),
|
||||||
updated_at_ms = VALUES(updated_at_ms)
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
|
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
|
||||||
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
|
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddBDLeaderInRegion, permissions.CanAddAdmin,
|
||||||
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
|
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -771,14 +789,14 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
|
|||||||
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
|
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
|
||||||
mp.status, COALESCE(mp.contact_info, ''),
|
mp.status, COALESCE(mp.contact_info, ''),
|
||||||
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
|
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
|
||||||
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
|
mp.can_add_bd_leader, mp.can_add_bd_leader_in_region, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
|
||||||
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
|
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
|
||||||
mp.created_at_ms, mp.updated_at_ms
|
mp.created_at_ms, mp.updated_at_ms
|
||||||
%s
|
%s
|
||||||
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
|
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
|
||||||
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
|
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
|
||||||
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
|
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
|
||||||
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
|
mp.can_add_bd_leader, mp.can_add_bd_leader_in_region, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
|
||||||
mp.created_at_ms, mp.updated_at_ms
|
mp.created_at_ms, mp.updated_at_ms
|
||||||
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
|
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
@ -806,6 +824,7 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
|
|||||||
&item.CanGrantVIP,
|
&item.CanGrantVIP,
|
||||||
&item.CanUpdateUserLevel,
|
&item.CanUpdateUserLevel,
|
||||||
&item.CanAddBDLeader,
|
&item.CanAddBDLeader,
|
||||||
|
&item.CanAddBDLeaderInRegion,
|
||||||
&item.CanAddAdmin,
|
&item.CanAddAdmin,
|
||||||
&item.CanAddSuperadmin,
|
&item.CanAddSuperadmin,
|
||||||
&item.CanBlockUser,
|
&item.CanBlockUser,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ func TestUpdateManagerProfilePersistsStatus(t *testing.T) {
|
|||||||
mock.ExpectQuery(`(?s)SELECT mp\.user_id, COALESCE\(manager\.current_display_user_id, ''\).*FROM manager_profiles mp`).
|
mock.ExpectQuery(`(?s)SELECT mp\.user_id, COALESCE\(manager\.current_display_user_id, ''\).*FROM manager_profiles mp`).
|
||||||
WillReturnRows(managerListRows().
|
WillReturnRows(managerListRows().
|
||||||
AddRow(int64(1003), "1003", "manager", "avatar.png", int64(7), "西亚区", "disabled", "+63",
|
AddRow(int64(1003), "1003", "manager", "avatar.png", int64(7), "西亚区", "disabled", "+63",
|
||||||
true, true, true, true, true, true, true, true, true, true,
|
true, true, true, true, true, true, false, true, true, true, true,
|
||||||
int64(0), int64(0), int64(1710000000000), int64(1710000001000)))
|
int64(0), int64(0), int64(1710000000000), int64(1710000001000)))
|
||||||
|
|
||||||
item, err := reader.UpdateManagerProfile(ctx, 1003, 7, updateManagerRequest{Status: &status})
|
item, err := reader.UpdateManagerProfile(ctx, 1003, 7, updateManagerRequest{Status: &status})
|
||||||
@ -75,6 +75,7 @@ func managerListRows() *sqlmock.Rows {
|
|||||||
"can_grant_vip",
|
"can_grant_vip",
|
||||||
"can_update_user_level",
|
"can_update_user_level",
|
||||||
"can_add_bd_leader",
|
"can_add_bd_leader",
|
||||||
|
"can_add_bd_leader_in_region",
|
||||||
"can_add_admin",
|
"can_add_admin",
|
||||||
"can_add_superadmin",
|
"can_add_superadmin",
|
||||||
"can_block_user",
|
"can_block_user",
|
||||||
|
|||||||
@ -69,33 +69,35 @@ type createBDLeaderRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createManagerRequest struct {
|
type createManagerRequest struct {
|
||||||
Contact string `json:"contact"`
|
Contact string `json:"contact"`
|
||||||
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
||||||
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
||||||
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
||||||
CanGrantBadge *bool `json:"canGrantBadge"`
|
CanGrantBadge *bool `json:"canGrantBadge"`
|
||||||
CanGrantVIP *bool `json:"canGrantVip"`
|
CanGrantVIP *bool `json:"canGrantVip"`
|
||||||
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
||||||
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
||||||
CanAddAdmin *bool `json:"canAddAdmin"`
|
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
|
||||||
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
|
CanAddAdmin *bool `json:"canAddAdmin"`
|
||||||
CanBlockUser *bool `json:"canBlockUser"`
|
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
|
||||||
CanTransferCountry *bool `json:"canTransferUserCountry"`
|
CanBlockUser *bool `json:"canBlockUser"`
|
||||||
|
CanTransferCountry *bool `json:"canTransferUserCountry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateManagerRequest struct {
|
type updateManagerRequest struct {
|
||||||
Contact *string `json:"contact"`
|
Contact *string `json:"contact"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
||||||
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
||||||
CanGrantBadge *bool `json:"canGrantBadge"`
|
CanGrantBadge *bool `json:"canGrantBadge"`
|
||||||
CanGrantVIP *bool `json:"canGrantVip"`
|
CanGrantVIP *bool `json:"canGrantVip"`
|
||||||
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
||||||
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
||||||
CanAddAdmin *bool `json:"canAddAdmin"`
|
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
|
||||||
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
|
CanAddAdmin *bool `json:"canAddAdmin"`
|
||||||
CanBlockUser *bool `json:"canBlockUser"`
|
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
|
||||||
CanTransferCountry *bool `json:"canTransferUserCountry"`
|
CanBlockUser *bool `json:"canBlockUser"`
|
||||||
|
CanTransferCountry *bool `json:"canTransferUserCountry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r updateManagerRequest) appendStatusAssignment(assignments *[]string, args *[]any) error {
|
func (r updateManagerRequest) appendStatusAssignment(assignments *[]string, args *[]any) error {
|
||||||
@ -116,30 +118,32 @@ func (r updateManagerRequest) appendStatusAssignment(assignments *[]string, args
|
|||||||
}
|
}
|
||||||
|
|
||||||
type managerPermissions struct {
|
type managerPermissions struct {
|
||||||
CanGrantAvatarFrame bool
|
CanGrantAvatarFrame bool
|
||||||
CanGrantVehicle bool
|
CanGrantVehicle bool
|
||||||
CanGrantBadge bool
|
CanGrantBadge bool
|
||||||
CanGrantVIP bool
|
CanGrantVIP bool
|
||||||
CanUpdateUserLevel bool
|
CanUpdateUserLevel bool
|
||||||
CanAddBDLeader bool
|
CanAddBDLeader bool
|
||||||
CanAddAdmin bool
|
CanAddBDLeaderInRegion bool
|
||||||
CanAddSuperadmin bool
|
CanAddAdmin bool
|
||||||
CanBlockUser bool
|
CanAddSuperadmin bool
|
||||||
CanTransferCountry bool
|
CanBlockUser bool
|
||||||
|
CanTransferCountry bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r createManagerRequest) permissionsWithDefault() managerPermissions {
|
func (r createManagerRequest) permissionsWithDefault() managerPermissions {
|
||||||
return managerPermissions{
|
return managerPermissions{
|
||||||
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
|
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
|
||||||
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
|
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
|
||||||
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
|
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
|
||||||
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
|
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
|
||||||
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
|
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
|
||||||
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
|
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
|
||||||
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
|
CanAddBDLeaderInRegion: boolDefaultFalse(r.CanAddBDLeaderInRegion),
|
||||||
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
|
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
|
||||||
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
|
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
|
||||||
CanTransferCountry: boolDefaultTrue(r.CanTransferCountry),
|
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
|
||||||
|
CanTransferCountry: boolDefaultTrue(r.CanTransferCountry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,6 +154,7 @@ func (r updateManagerRequest) appendPermissionAssignments(assignments *[]string,
|
|||||||
appendBoolAssignment(assignments, args, "can_grant_vip", r.CanGrantVIP)
|
appendBoolAssignment(assignments, args, "can_grant_vip", r.CanGrantVIP)
|
||||||
appendBoolAssignment(assignments, args, "can_update_user_level", r.CanUpdateUserLevel)
|
appendBoolAssignment(assignments, args, "can_update_user_level", r.CanUpdateUserLevel)
|
||||||
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
|
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
|
||||||
|
appendBoolAssignment(assignments, args, "can_add_bd_leader_in_region", r.CanAddBDLeaderInRegion)
|
||||||
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
|
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
|
||||||
appendBoolAssignment(assignments, args, "can_add_superadmin", r.CanAddSuperadmin)
|
appendBoolAssignment(assignments, args, "can_add_superadmin", r.CanAddSuperadmin)
|
||||||
appendBoolAssignment(assignments, args, "can_block_user", r.CanBlockUser)
|
appendBoolAssignment(assignments, args, "can_block_user", r.CanBlockUser)
|
||||||
@ -168,6 +173,10 @@ func boolDefaultTrue(value *bool) bool {
|
|||||||
return value == nil || *value
|
return value == nil || *value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolDefaultFalse(value *bool) bool {
|
||||||
|
return value != nil && *value
|
||||||
|
}
|
||||||
|
|
||||||
type createBDRequest struct {
|
type createBDRequest struct {
|
||||||
CommandID string `json:"commandId" binding:"required"`
|
CommandID string `json:"commandId" binding:"required"`
|
||||||
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
||||||
|
|||||||
@ -29,3 +29,21 @@ func TestFlexibleInt64AcceptsStringAndNumber(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagerRegionExpansionDefaultsClosedAndPreservesExplicitEnable(t *testing.T) {
|
||||||
|
var omitted createManagerRequest
|
||||||
|
if err := json.Unmarshal([]byte(`{"targetUserId":"1003"}`), &omitted); err != nil {
|
||||||
|
t.Fatalf("unmarshal omitted permission: %v", err)
|
||||||
|
}
|
||||||
|
if omitted.permissionsWithDefault().CanAddBDLeaderInRegion {
|
||||||
|
t.Fatalf("region expansion must default closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var enabled createManagerRequest
|
||||||
|
if err := json.Unmarshal([]byte(`{"targetUserId":"1003","canAddBdLeaderInRegion":true}`), &enabled); err != nil {
|
||||||
|
t.Fatalf("unmarshal enabled permission: %v", err)
|
||||||
|
}
|
||||||
|
if !enabled.permissionsWithDefault().CanAddBDLeaderInRegion {
|
||||||
|
t.Fatalf("explicit region expansion must be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const (
|
|||||||
sortByTotalRechargeUSDT = "total_recharge_usdt"
|
sortByTotalRechargeUSDT = "total_recharge_usdt"
|
||||||
bdLeaderPositionAliasMaxRunes = 64
|
bdLeaderPositionAliasMaxRunes = 64
|
||||||
hostOrgInternalUserIDMinDigits = 15
|
hostOrgInternalUserIDMinDigits = 15
|
||||||
|
managerAddBDLeaderScopeScene = "manager_add_bd_leader"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@ -56,22 +57,87 @@ func (s *Service) ListAgencies(ctx context.Context, query listQuery) ([]*usercli
|
|||||||
return s.reader.ListAgencies(ctx, query)
|
return s.reader.ListAgencies(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) {
|
func (s *Service) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, ManagerRoleScopePolicy, error) {
|
||||||
query = normalizeListQuery(query)
|
query = normalizeListQuery(query)
|
||||||
return s.reader.ListManagers(ctx, query)
|
items, total, err := s.reader.ListManagers(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, ManagerRoleScopePolicy{}, err
|
||||||
|
}
|
||||||
|
policy, err := s.managerRoleScopePolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, ManagerRoleScopePolicy{}, err
|
||||||
|
}
|
||||||
|
decorateManagerScopePolicy(items, policy)
|
||||||
|
return items, total, ManagerRoleScopePolicy{
|
||||||
|
BaseScope: policy.BaseScope,
|
||||||
|
RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID string, req createManagerRequest) (*ManagerListItem, error) {
|
func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID string, req createManagerRequest) (*ManagerListItem, error) {
|
||||||
|
policy, err := s.managerRoleScopePolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.CanAddBDLeaderInRegion = normalizedManagerRegionPermission(req.CanAddBDLeaderInRegion, policy.RegionExpansionConfigurable)
|
||||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
|
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 经理身份只落 manager_profiles 表;权限开关同时写入 manager_profiles,供 H5 写入口逐项校验。
|
// 经理身份只落 manager_profiles 表;权限开关同时写入 manager_profiles,供 H5 写入口逐项校验。
|
||||||
return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req)
|
item, err := s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decorateManagerScopePolicy([]*ManagerListItem{item}, policy)
|
||||||
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64, req updateManagerRequest) (*ManagerListItem, error) {
|
func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64, req updateManagerRequest) (*ManagerListItem, error) {
|
||||||
return s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
|
policy, err := s.managerRoleScopePolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.CanAddBDLeaderInRegion = normalizedManagerRegionPermission(req.CanAddBDLeaderInRegion, policy.RegionExpansionConfigurable)
|
||||||
|
item, err := s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decorateManagerScopePolicy([]*ManagerListItem{item}, policy)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) managerRoleScopePolicy(ctx context.Context) (*userclient.RoleScopePolicy, error) {
|
||||||
|
return s.userClient.GetRoleScopePolicy(ctx, userclient.GetRoleScopePolicyRequest{
|
||||||
|
Caller: "hyapp-admin-server",
|
||||||
|
Scene: managerAddBDLeaderScopeScene,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizedManagerRegionPermission 让 App 基础策略决定开关是否可配置;global App 即使收到篡改请求也只保存 false。
|
||||||
|
func normalizedManagerRegionPermission(value *bool, configurable bool) *bool {
|
||||||
|
if configurable {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
disabled := false
|
||||||
|
return &disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func decorateManagerScopePolicy(items []*ManagerListItem, policy *userclient.RoleScopePolicy) {
|
||||||
|
if policy == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.BDLeaderInviteBaseScope = policy.BaseScope
|
||||||
|
item.BDLeaderRegionExpansionConfigurable = policy.RegionExpansionConfigurable
|
||||||
|
item.BDLeaderInviteEffectiveScope = policy.BaseScope
|
||||||
|
if policy.RegionExpansionConfigurable && item.CanAddBDLeaderInRegion {
|
||||||
|
item.BDLeaderInviteEffectiveScope = "region"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) {
|
func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) {
|
||||||
|
|||||||
@ -129,6 +129,33 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecorateManagerScopePolicyUsesPolicyMetadataWithoutAppBranches(t *testing.T) {
|
||||||
|
items := []*ManagerListItem{{CanAddBDLeaderInRegion: true}}
|
||||||
|
decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{
|
||||||
|
BaseScope: "country",
|
||||||
|
RegionExpansionConfigurable: true,
|
||||||
|
})
|
||||||
|
if items[0].BDLeaderInviteEffectiveScope != "region" || !items[0].BDLeaderRegionExpansionConfigurable {
|
||||||
|
t.Fatalf("configurable policy decoration mismatch: %+v", items[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
items[0].CanAddBDLeaderInRegion = true
|
||||||
|
decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{
|
||||||
|
BaseScope: "global",
|
||||||
|
RegionExpansionConfigurable: false,
|
||||||
|
})
|
||||||
|
if items[0].BDLeaderInviteEffectiveScope != "global" || items[0].BDLeaderRegionExpansionConfigurable {
|
||||||
|
t.Fatalf("global policy decoration mismatch: %+v", items[0])
|
||||||
|
}
|
||||||
|
if got := normalizedManagerRegionPermission(pointerBool(true), false); got == nil || *got {
|
||||||
|
t.Fatalf("non-configurable app must persist region expansion as false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerBool(value bool) *bool {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
// TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix 锁定线上真实 FullPath 带 /api/v1 前缀时,BD Leader 状态仍然写独立负责人表。
|
// TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix 锁定线上真实 FullPath 带 /api/v1 前缀时,BD Leader 状态仍然写独立负责人表。
|
||||||
func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) {
|
func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) {
|
||||||
role, target := bdRoleFromStatusPath("/api/v1/admin/bd-leaders/:user_id/status")
|
role, target := bdRoleFromStatusPath("/api/v1/admin/bd-leaders/:user_id/status")
|
||||||
|
|||||||
@ -130,6 +130,7 @@ type UserHostClient interface {
|
|||||||
GetAgency(ctx context.Context, req *userv1.GetAgencyRequest) (*userv1.GetAgencyResponse, error)
|
GetAgency(ctx context.Context, req *userv1.GetAgencyRequest) (*userv1.GetAgencyResponse, error)
|
||||||
UpdateAgencyProfile(ctx context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, error)
|
UpdateAgencyProfile(ctx context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, error)
|
||||||
CheckBusinessCapability(ctx context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error)
|
CheckBusinessCapability(ctx context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error)
|
||||||
|
GetRoleScopePolicy(ctx context.Context, req *userv1.GetRoleScopePolicyRequest) (*userv1.GetRoleScopePolicyResponse, error)
|
||||||
GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error)
|
GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error)
|
||||||
GetAgencyApplications(ctx context.Context, req *userv1.GetAgencyApplicationsRequest) (*userv1.GetAgencyApplicationsResponse, error)
|
GetAgencyApplications(ctx context.Context, req *userv1.GetAgencyApplicationsRequest) (*userv1.GetAgencyApplicationsResponse, error)
|
||||||
ReviewAgencyApplication(ctx context.Context, req *userv1.ReviewAgencyApplicationRequest) (*userv1.ReviewAgencyApplicationResponse, error)
|
ReviewAgencyApplication(ctx context.Context, req *userv1.ReviewAgencyApplicationRequest) (*userv1.ReviewAgencyApplicationResponse, error)
|
||||||
@ -614,6 +615,10 @@ func (c *grpcUserHostClient) CheckBusinessCapability(ctx context.Context, req *u
|
|||||||
return c.client.CheckBusinessCapability(ctx, req)
|
return c.client.CheckBusinessCapability(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *grpcUserHostClient) GetRoleScopePolicy(ctx context.Context, req *userv1.GetRoleScopePolicyRequest) (*userv1.GetRoleScopePolicyResponse, error) {
|
||||||
|
return c.client.GetRoleScopePolicy(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *grpcUserHostClient) GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
|
func (c *grpcUserHostClient) GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
|
||||||
return c.client.GetAgencyMembers(ctx, req)
|
return c.client.GetAgencyMembers(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -306,7 +306,7 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchManagerUsersAllowsCrossCountryOnlyForHuwaaBDLeaderAction(t *testing.T) {
|
func TestSearchManagerUsersUsesResolvedGlobalPolicyForHuwaaBDLeaderAction(t *testing.T) {
|
||||||
profileClient := &fakeUserProfileClient{
|
profileClient := &fakeUserProfileClient{
|
||||||
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
|
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
|
||||||
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
|
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
|
||||||
@ -318,7 +318,10 @@ func TestSearchManagerUsersAllowsCrossCountryOnlyForHuwaaBDLeaderAction(t *testi
|
|||||||
900002: {UserId: 900002, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA"},
|
900002: {UserId: 900002, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
router := newManagerCenterTestRouter(&fakeWalletClient{}, &fakeUserHostClient{}, profileClient)
|
hostClient := &fakeUserHostClient{roleScopePolicyResp: &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
|
||||||
|
Scene: "manager_add_bd_leader", BaseScope: "global", RegionExpansionConfigurable: false,
|
||||||
|
}}}
|
||||||
|
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
|
||||||
|
|
||||||
bdLeaderRequest := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=900&action=add-bd-leader", nil)
|
bdLeaderRequest := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=900&action=add-bd-leader", nil)
|
||||||
bdLeaderRequest.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
bdLeaderRequest.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
||||||
@ -365,6 +368,59 @@ func TestSearchManagerUsersAllowsCrossCountryOnlyForHuwaaBDLeaderAction(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchManagerUsersAppliesManagerRegionExpansionWithoutCrossingRegion(t *testing.T) {
|
||||||
|
profileClient := &fakeUserProfileClient{
|
||||||
|
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
|
||||||
|
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
|
||||||
|
{UserId: 900002, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
|
||||||
|
{UserId: 900003, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
|
||||||
|
}},
|
||||||
|
usersByID: map[int64]*userv1.User{
|
||||||
|
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
||||||
|
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
||||||
|
900002: {UserId: 900002, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA", RegionId: 1001},
|
||||||
|
900003: {UserId: 900003, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "MX", RegionId: 2002},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
regionOpen bool
|
||||||
|
wantVisible int
|
||||||
|
}{
|
||||||
|
{name: "closed keeps country", regionOpen: false, wantVisible: 1},
|
||||||
|
{name: "open expands within region", regionOpen: true, wantVisible: 2},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{
|
||||||
|
"manager_add_bd_leader_in_region": testCase.regionOpen,
|
||||||
|
}}
|
||||||
|
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=900&scope=all&action=add-bd-leader", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-manager-region-search")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Data struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if envelope.Data.Total != testCase.wantVisible {
|
||||||
|
t.Fatalf("visible total = %d, want %d", envelope.Data.Total, testCase.wantVisible)
|
||||||
|
}
|
||||||
|
if hostClient.sawCapability("manager_transfer_user_country") {
|
||||||
|
t.Fatalf("scope=all must not override add-bd-leader policy")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
|
func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
|
||||||
walletClient := &fakeWalletClient{}
|
walletClient := &fakeWalletClient{}
|
||||||
hostClient := &fakeUserHostClient{}
|
hostClient := &fakeUserHostClient{}
|
||||||
@ -751,9 +807,10 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(t *testing.T) {
|
|||||||
hostAdminClient := &fakeUserHostAdminClient{}
|
hostAdminClient := &fakeUserHostAdminClient{}
|
||||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||||
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
||||||
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA", RegionId: 2002},
|
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA", RegionId: 1001},
|
||||||
}}
|
}}
|
||||||
router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, &fakeUserHostClient{}, hostAdminClient, profileClient)
|
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_add_bd_leader_in_region": false}}
|
||||||
|
router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, hostClient, hostAdminClient, profileClient)
|
||||||
body := []byte(`{"command_id":"mgr-bdleader-cross","target_user_id":"900001"}`)
|
body := []byte(`{"command_id":"mgr-bdleader-cross","target_user_id":"900001"}`)
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
@ -768,13 +825,39 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateManagerBDLeaderAllowsCrossCountryTargetWhenRegionExpansionEnabled(t *testing.T) {
|
||||||
|
hostAdminClient := &fakeUserHostAdminClient{}
|
||||||
|
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||||
|
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
||||||
|
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA", RegionId: 1001},
|
||||||
|
}}
|
||||||
|
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_add_bd_leader_in_region": true}}
|
||||||
|
router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, hostClient, hostAdminClient, profileClient)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader([]byte(`{"command_id":"mgr-bdleader-region","target_user_id":"900001"}`)))
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-manager-bdleader-region")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("same-region BD Leader status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if req := hostAdminClient.lastCreateBDLeader; req == nil || req.GetTargetUserId() != 900001 {
|
||||||
|
t.Fatalf("same-region target must reach CreateBDLeader: %+v", req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateManagerBDLeaderAllowsCrossCountryTargetForHuwaa(t *testing.T) {
|
func TestCreateManagerBDLeaderAllowsCrossCountryTargetForHuwaa(t *testing.T) {
|
||||||
hostAdminClient := &fakeUserHostAdminClient{}
|
hostAdminClient := &fakeUserHostAdminClient{}
|
||||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||||
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
|
||||||
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "163006", Country: "CA", RegionId: 2002},
|
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "163006", Country: "CA", RegionId: 2002},
|
||||||
}}
|
}}
|
||||||
router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, &fakeUserHostClient{}, hostAdminClient, profileClient)
|
hostClient := &fakeUserHostClient{roleScopePolicyResp: &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
|
||||||
|
Scene: "manager_add_bd_leader", BaseScope: "global", RegionExpansionConfigurable: false,
|
||||||
|
}}}
|
||||||
|
router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, hostClient, hostAdminClient, profileClient)
|
||||||
body := []byte(`{"command_id":"huwaa-mgr-bdleader-cross","target_user_id":"900001"}`)
|
body := []byte(`{"command_id":"huwaa-mgr-bdleader-cross","target_user_id":"900001"}`)
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const managerGrantBadgeCapability = "manager_grant_badge"
|
|||||||
const managerGrantVIPCapability = "manager_grant_vip"
|
const managerGrantVIPCapability = "manager_grant_vip"
|
||||||
const managerUpdateUserLevelCapability = "manager_update_user_level"
|
const managerUpdateUserLevelCapability = "manager_update_user_level"
|
||||||
const managerAddBDLeaderCapability = "manager_add_bd_leader"
|
const managerAddBDLeaderCapability = "manager_add_bd_leader"
|
||||||
|
const managerAddBDLeaderInRegionCapability = "manager_add_bd_leader_in_region"
|
||||||
const managerAddAdminCapability = "manager_add_admin"
|
const managerAddAdminCapability = "manager_add_admin"
|
||||||
const managerAddSuperadminCapability = "manager_add_superadmin"
|
const managerAddSuperadminCapability = "manager_add_superadmin"
|
||||||
const managerBlockUserCapability = "manager_block_user"
|
const managerBlockUserCapability = "manager_block_user"
|
||||||
@ -33,7 +34,10 @@ const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
|
|||||||
const managerGrantVIPDurationMS int64 = 30 * managerGrantDayMS
|
const managerGrantVIPDurationMS int64 = 30 * managerGrantDayMS
|
||||||
const managerGrantMaxResourceIDs = 20
|
const managerGrantMaxResourceIDs = 20
|
||||||
const managerGrantMaxVIPLevel int32 = 5
|
const managerGrantMaxVIPLevel int32 = 5
|
||||||
const huwaaAppCode = "huwaa"
|
|
||||||
|
const managerRoleScopeCountry = "country"
|
||||||
|
const managerRoleScopeRegion = "region"
|
||||||
|
const managerRoleScopeGlobal = "global"
|
||||||
|
|
||||||
type managerGrantResourceData struct {
|
type managerGrantResourceData struct {
|
||||||
ResourceID string `json:"resource_id"`
|
ResourceID string `json:"resource_id"`
|
||||||
@ -168,19 +172,24 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和等级投影做最终过滤。
|
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和等级投影做最终过滤。
|
||||||
// 国家转移依赖显式 scope=all 和权限;Huwaa 的 add-bd-leader 是唯一无需该权限即可跨国家选人的角色例外。
|
// BD Leader 的 App 差异来自 user-service 策略,handler 不直接判断 app_code。
|
||||||
actor, ok := h.requireManagerCenterActor(writer, request)
|
actor, ok := h.requireManagerCenterActor(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scope := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("scope")))
|
scope := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("scope")))
|
||||||
requestedAllCountries := scope == "all"
|
bdLeaderSearch := managerSearchScene(request) == managerAddBDLeaderCapability
|
||||||
// Huwaa 的 BD Leader 拓展不受区域/国家边界限制;例外只绑定 add-bd-leader 场景,资源发放等高风险动作仍保持同国家。
|
requestedAllCountries := scope == "all" && !bdLeaderSearch
|
||||||
bdLeaderCrossRegion := huwaaBDLeaderSearchRegionUnrestricted(request)
|
if requestedAllCountries && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
|
||||||
if requestedAllCountries && !bdLeaderCrossRegion && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allCountries := requestedAllCountries || bdLeaderCrossRegion
|
managerTargetScope := managerRoleScopeCountry
|
||||||
|
if bdLeaderSearch {
|
||||||
|
managerTargetScope, ok = h.managerBDLeaderEffectiveScope(writer, request)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||||||
if !ok {
|
if !ok {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
@ -225,8 +234,9 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
|
|||||||
visibleUserIDs := make([]int64, 0, len(userIDs))
|
visibleUserIDs := make([]int64, 0, len(userIDs))
|
||||||
for _, userID := range userIDs {
|
for _, userID := range userIDs {
|
||||||
user := usersResp.GetUsers()[userID]
|
user := usersResp.GetUsers()[userID]
|
||||||
// 默认搜索必须和写入口一样收敛到同国家;国家转移已单独校验权限,允许在这里跨国家挑选目标。
|
// 搜索与最终创建共用 scope evaluator,避免同一 App 的规则在两个 handler 分叉。
|
||||||
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || (!allCountries && !sameCountry(actor.GetCountry(), user.GetCountry())) {
|
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE ||
|
||||||
|
(!requestedAllCountries && !managerTargetMatchesScope(managerTargetScope, actor, user)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := managerUserFromProto(user, levels[userID])
|
item := managerUserFromProto(user, levels[userID])
|
||||||
@ -772,13 +782,8 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
|
|||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 写入口必须重新读取 active 目标用户;Huwaa 只取消区域/国家匹配,经理身份和 add-bd-leader 能力仍在上方强制校验。
|
// 写入口重新解析策略并读取 active 目标,不能信任搜索结果或客户端自报范围。
|
||||||
var target *userv1.User
|
target, ok := h.requireManagerBDLeaderTarget(writer, request, actor, targetUserID)
|
||||||
if huwaaRoleRegionUnrestricted(request) {
|
|
||||||
target, ok = h.requireActiveTarget(writer, request, targetUserID)
|
|
||||||
} else {
|
|
||||||
target, ok = h.requireSameCountryTargetWithActor(writer, request, actor, targetUserID)
|
|
||||||
}
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -945,6 +950,39 @@ func (h *Handler) requireManagerCapability(writer http.ResponseWriter, request *
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) managerBDLeaderEffectiveScope(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||||
|
resp, err := h.userHostClient.GetRoleScopePolicy(request.Context(), &userv1.GetRoleScopePolicyRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
Scene: managerAddBDLeaderCapability,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
policy := resp.GetPolicy()
|
||||||
|
if policy == nil || !validManagerRoleScope(policy.GetBaseScope()) {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
scope := policy.GetBaseScope()
|
||||||
|
if !policy.GetRegionExpansionConfigurable() {
|
||||||
|
return scope, true
|
||||||
|
}
|
||||||
|
capability, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
ActorUserId: auth.UserIDFromContext(request.Context()),
|
||||||
|
Capability: managerAddBDLeaderInRegionCapability,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if capability.GetAllowed() {
|
||||||
|
scope = managerRoleScopeRegion
|
||||||
|
}
|
||||||
|
return scope, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) requireSameCountryTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
|
func (h *Handler) requireSameCountryTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
|
||||||
actor, ok := h.requireManagerCenterActor(writer, request)
|
actor, ok := h.requireManagerCenterActor(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -965,6 +1003,22 @@ func (h *Handler) requireSameCountryTargetWithActor(writer http.ResponseWriter,
|
|||||||
return target, true
|
return target, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) requireManagerBDLeaderTarget(writer http.ResponseWriter, request *http.Request, actor *userv1.User, targetUserID int64) (*userv1.User, bool) {
|
||||||
|
scope, ok := h.managerBDLeaderEffectiveScope(writer, request)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
target, ok := h.requireActiveTarget(writer, request, targetUserID)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !managerTargetMatchesScope(scope, actor, target) {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return target, true
|
||||||
|
}
|
||||||
|
|
||||||
// requireActiveTarget 是经理写入口共同的目标用户真实性门禁;区域策略只能决定后续是否比较国家,不能绕过 active 用户校验。
|
// requireActiveTarget 是经理写入口共同的目标用户真实性门禁;区域策略只能决定后续是否比较国家,不能绕过 active 用户校验。
|
||||||
func (h *Handler) requireActiveTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
|
func (h *Handler) requireActiveTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
|
||||||
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||||
@ -983,38 +1037,59 @@ func (h *Handler) requireActiveTarget(writer http.ResponseWriter, request *http.
|
|||||||
return target, true
|
return target, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func huwaaRoleRegionUnrestricted(request *http.Request) bool {
|
func validManagerRoleScope(scope string) bool {
|
||||||
return request != nil && appcode.FromContext(request.Context()) == huwaaAppCode
|
switch scope {
|
||||||
|
case managerRoleScopeCountry, managerRoleScopeRegion, managerRoleScopeGlobal:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func huwaaBDLeaderSearchRegionUnrestricted(request *http.Request) bool {
|
// managerTargetMatchesScope 是搜索和写入口共享的唯一匹配器。region 表示在同国家基础上扩到同区域,
|
||||||
return huwaaRoleRegionUnrestricted(request) && managerSearchScene(request) == managerAddBDLeaderCapability
|
// 因而国家映射短暂异常时,打开扩区开关也不会反而失去原本可邀请的同国家用户。
|
||||||
|
func managerTargetMatchesScope(scope string, actor *userv1.User, target *userv1.User) bool {
|
||||||
|
if actor == nil || target == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch scope {
|
||||||
|
case managerRoleScopeGlobal:
|
||||||
|
return true
|
||||||
|
case managerRoleScopeRegion:
|
||||||
|
return sameCountry(actor.GetCountry(), target.GetCountry()) || sameRegion(actor.GetRegionId(), target.GetRegionId())
|
||||||
|
case managerRoleScopeCountry:
|
||||||
|
return sameCountry(actor.GetCountry(), target.GetCountry())
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bool {
|
func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bool {
|
||||||
permissions := map[string]bool{
|
permissions := map[string]bool{
|
||||||
"can_grant_avatar_frame": false,
|
"can_grant_avatar_frame": false,
|
||||||
"can_grant_vehicle": false,
|
"can_grant_vehicle": false,
|
||||||
"can_grant_badge": false,
|
"can_grant_badge": false,
|
||||||
"can_grant_vip": false,
|
"can_grant_vip": false,
|
||||||
"can_update_user_level": false,
|
"can_update_user_level": false,
|
||||||
"can_add_bd_leader": false,
|
"can_add_bd_leader": false,
|
||||||
"can_add_admin": false,
|
"can_add_bd_leader_in_region": false,
|
||||||
"can_add_superadmin": false,
|
"can_add_admin": false,
|
||||||
"can_block_user": false,
|
"can_add_superadmin": false,
|
||||||
"can_transfer_user_country": false,
|
"can_block_user": false,
|
||||||
|
"can_transfer_user_country": false,
|
||||||
}
|
}
|
||||||
for key, capability := range map[string]string{
|
for key, capability := range map[string]string{
|
||||||
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
|
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
|
||||||
"can_grant_vehicle": managerGrantVehicleCapability,
|
"can_grant_vehicle": managerGrantVehicleCapability,
|
||||||
"can_grant_badge": managerGrantBadgeCapability,
|
"can_grant_badge": managerGrantBadgeCapability,
|
||||||
"can_grant_vip": managerGrantVIPCapability,
|
"can_grant_vip": managerGrantVIPCapability,
|
||||||
"can_update_user_level": managerUpdateUserLevelCapability,
|
"can_update_user_level": managerUpdateUserLevelCapability,
|
||||||
"can_add_bd_leader": managerAddBDLeaderCapability,
|
"can_add_bd_leader": managerAddBDLeaderCapability,
|
||||||
"can_add_admin": managerAddAdminCapability,
|
"can_add_bd_leader_in_region": managerAddBDLeaderInRegionCapability,
|
||||||
"can_add_superadmin": managerAddSuperadminCapability,
|
"can_add_admin": managerAddAdminCapability,
|
||||||
"can_block_user": managerBlockUserCapability,
|
"can_add_superadmin": managerAddSuperadminCapability,
|
||||||
"can_transfer_user_country": managerTransferUserCountryCapability,
|
"can_block_user": managerBlockUserCapability,
|
||||||
|
"can_transfer_user_country": managerTransferUserCountryCapability,
|
||||||
} {
|
} {
|
||||||
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
|
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
|
||||||
Meta: httpkit.UserMeta(request, ""),
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
@ -1048,6 +1123,10 @@ func sameCountry(left string, right string) bool {
|
|||||||
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) && strings.TrimSpace(left) != ""
|
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) && strings.TrimSpace(left) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sameRegion(left int64, right int64) bool {
|
||||||
|
return left > 0 && left == right
|
||||||
|
}
|
||||||
|
|
||||||
func managerGrantResourceFromProto(resource *walletv1.Resource) managerGrantResourceData {
|
func managerGrantResourceFromProto(resource *walletv1.Resource) managerGrantResourceData {
|
||||||
if resource == nil {
|
if resource == nil {
|
||||||
return managerGrantResourceData{}
|
return managerGrantResourceData{}
|
||||||
|
|||||||
@ -552,8 +552,12 @@ type fakeUserHostClient struct {
|
|||||||
kickErr error
|
kickErr error
|
||||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||||
capabilities []string
|
capabilities []string
|
||||||
|
capabilityAllowed map[string]bool
|
||||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||||
capabilityErr error
|
capabilityErr error
|
||||||
|
lastRoleScopePolicy *userv1.GetRoleScopePolicyRequest
|
||||||
|
roleScopePolicyResp *userv1.GetRoleScopePolicyResponse
|
||||||
|
roleScopePolicyErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeUserHostAdminClient struct {
|
type fakeUserHostAdminClient struct {
|
||||||
@ -1972,9 +1976,27 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
|
|||||||
if f.capabilityResp != nil {
|
if f.capabilityResp != nil {
|
||||||
return f.capabilityResp, nil
|
return f.capabilityResp, nil
|
||||||
}
|
}
|
||||||
|
if allowed, configured := f.capabilityAllowed[req.GetCapability()]; configured {
|
||||||
|
return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed}, nil
|
||||||
|
}
|
||||||
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
|
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserHostClient) GetRoleScopePolicy(_ context.Context, req *userv1.GetRoleScopePolicyRequest) (*userv1.GetRoleScopePolicyResponse, error) {
|
||||||
|
f.lastRoleScopePolicy = req
|
||||||
|
if f.roleScopePolicyErr != nil {
|
||||||
|
return nil, f.roleScopePolicyErr
|
||||||
|
}
|
||||||
|
if f.roleScopePolicyResp != nil {
|
||||||
|
return f.roleScopePolicyResp, nil
|
||||||
|
}
|
||||||
|
return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
|
||||||
|
Scene: "manager_add_bd_leader",
|
||||||
|
BaseScope: "country",
|
||||||
|
RegionExpansionConfigurable: true,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeUserHostClient) sawCapability(capability string) bool {
|
func (f *fakeUserHostClient) sawCapability(capability string) bool {
|
||||||
for _, item := range f.capabilities {
|
for _, item := range f.capabilities {
|
||||||
if item == capability {
|
if item == capability {
|
||||||
|
|||||||
@ -886,6 +886,7 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
|
|||||||
can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送 VIP',
|
can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送 VIP',
|
||||||
can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
|
can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
|
||||||
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
|
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
|
||||||
|
can_add_bd_leader_in_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许把 BD Leader 邀请范围从本国家扩展到经理所属区域',
|
||||||
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
|
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
|
||||||
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
|
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
|
||||||
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',
|
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',
|
||||||
|
|||||||
@ -231,23 +231,24 @@ type BDProfile struct {
|
|||||||
// ManagerProfile 是经理身份事实。
|
// ManagerProfile 是经理身份事实。
|
||||||
// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。
|
// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。
|
||||||
type ManagerProfile struct {
|
type ManagerProfile struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
RegionID int64
|
RegionID int64
|
||||||
Status string
|
Status string
|
||||||
Contact string
|
Contact string
|
||||||
CanGrantAvatarFrame bool
|
CanGrantAvatarFrame bool
|
||||||
CanGrantVehicle bool
|
CanGrantVehicle bool
|
||||||
CanGrantBadge bool
|
CanGrantBadge bool
|
||||||
CanGrantVIP bool
|
CanGrantVIP bool
|
||||||
CanUpdateUserLevel bool
|
CanUpdateUserLevel bool
|
||||||
CanAddBDLeader bool
|
CanAddBDLeader bool
|
||||||
CanAddAdmin bool
|
CanAddBDLeaderInRegion bool
|
||||||
CanAddSuperadmin bool
|
CanAddAdmin bool
|
||||||
CanBlockUser bool
|
CanAddSuperadmin bool
|
||||||
CanTransferCountry bool
|
CanBlockUser bool
|
||||||
CreatedByAdminID int64
|
CanTransferCountry bool
|
||||||
CreatedAtMs int64
|
CreatedByAdminID int64
|
||||||
UpdatedAtMs int64
|
CreatedAtMs int64
|
||||||
|
UpdatedAtMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。
|
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。
|
||||||
|
|||||||
@ -8,18 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CapabilityManagerCenter = "manager_center"
|
CapabilityManagerCenter = "manager_center"
|
||||||
CapabilityManagerResourceGrant = "manager_resource_grant"
|
CapabilityManagerResourceGrant = "manager_resource_grant"
|
||||||
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
|
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
|
||||||
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
|
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
|
||||||
CapabilityManagerGrantBadge = "manager_grant_badge"
|
CapabilityManagerGrantBadge = "manager_grant_badge"
|
||||||
CapabilityManagerGrantVIP = "manager_grant_vip"
|
CapabilityManagerGrantVIP = "manager_grant_vip"
|
||||||
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
|
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
|
||||||
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
|
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
|
||||||
CapabilityManagerAddAdmin = "manager_add_admin"
|
CapabilityManagerAddBDLeaderInRegion = "manager_add_bd_leader_in_region"
|
||||||
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
|
CapabilityManagerAddAdmin = "manager_add_admin"
|
||||||
CapabilityManagerBlockUser = "manager_block_user"
|
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
|
||||||
CapabilityManagerTransferCountry = "manager_transfer_user_country"
|
CapabilityManagerBlockUser = "manager_block_user"
|
||||||
|
CapabilityManagerTransferCountry = "manager_transfer_user_country"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。
|
// CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。
|
||||||
@ -49,6 +50,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
|
|||||||
CapabilityManagerGrantVIP,
|
CapabilityManagerGrantVIP,
|
||||||
CapabilityManagerUpdateUserLevel,
|
CapabilityManagerUpdateUserLevel,
|
||||||
CapabilityManagerAddBDLeader,
|
CapabilityManagerAddBDLeader,
|
||||||
|
CapabilityManagerAddBDLeaderInRegion,
|
||||||
CapabilityManagerAddAdmin,
|
CapabilityManagerAddAdmin,
|
||||||
CapabilityManagerAddSuperadmin,
|
CapabilityManagerAddSuperadmin,
|
||||||
CapabilityManagerBlockUser,
|
CapabilityManagerBlockUser,
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
package host
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleScope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleScopeCountry RoleScope = "country"
|
||||||
|
RoleScopeRegion RoleScope = "region"
|
||||||
|
RoleScopeGlobal RoleScope = "global"
|
||||||
|
|
||||||
|
RoleScopeSceneOrganizationExpansion = "organization_role_expansion"
|
||||||
|
RoleScopeSceneManagerAddBDLeader = "manager_add_bd_leader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleScopePolicy 把 App 固有边界和可运营扩展能力分开;业务入口只根据解析结果判断目标范围。
|
||||||
|
type RoleScopePolicy struct {
|
||||||
|
Scene string
|
||||||
|
BaseScope RoleScope
|
||||||
|
RegionExpansionConfigurable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultRoleScopePolicies = map[string]RoleScopePolicy{
|
||||||
|
RoleScopeSceneOrganizationExpansion: {
|
||||||
|
Scene: RoleScopeSceneOrganizationExpansion,
|
||||||
|
BaseScope: RoleScopeRegion,
|
||||||
|
},
|
||||||
|
RoleScopeSceneManagerAddBDLeader: {
|
||||||
|
Scene: RoleScopeSceneManagerAddBDLeader,
|
||||||
|
BaseScope: RoleScopeCountry,
|
||||||
|
RegionExpansionConfigurable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var appRoleScopePolicyOverrides = map[string]map[string]RoleScopePolicy{
|
||||||
|
"huwaa": {
|
||||||
|
RoleScopeSceneOrganizationExpansion: {
|
||||||
|
Scene: RoleScopeSceneOrganizationExpansion,
|
||||||
|
BaseScope: RoleScopeGlobal,
|
||||||
|
},
|
||||||
|
RoleScopeSceneManagerAddBDLeader: {
|
||||||
|
Scene: RoleScopeSceneManagerAddBDLeader,
|
||||||
|
BaseScope: RoleScopeGlobal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRoleScopePolicy 是 App 差异的唯一解析入口;新增 App 只扩展注册表,不修改搜索或写入 handler。
|
||||||
|
func ResolveRoleScopePolicy(rawAppCode string, rawScene string) (RoleScopePolicy, error) {
|
||||||
|
scene := strings.ToLower(strings.TrimSpace(rawScene))
|
||||||
|
policy, ok := defaultRoleScopePolicies[scene]
|
||||||
|
if !ok {
|
||||||
|
return RoleScopePolicy{}, xerr.New(xerr.InvalidArgument, "role scope scene is invalid")
|
||||||
|
}
|
||||||
|
if overrides := appRoleScopePolicyOverrides[appcode.Normalize(rawAppCode)]; overrides != nil {
|
||||||
|
if override, exists := overrides[scene]; exists {
|
||||||
|
policy = override
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveScope 只允许声明为 configurable 的策略接受经理个人扩区开关。
|
||||||
|
func (p RoleScopePolicy) EffectiveScope(regionExpansionEnabled bool) RoleScope {
|
||||||
|
if p.RegionExpansionConfigurable && regionExpansionEnabled {
|
||||||
|
return RoleScopeRegion
|
||||||
|
}
|
||||||
|
return p.BaseScope
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package host
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveRoleScopePolicyKeepsAppDifferencesInRegistry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appCode string
|
||||||
|
baseScope RoleScope
|
||||||
|
configurable bool
|
||||||
|
expanded RoleScope
|
||||||
|
}{
|
||||||
|
{name: "default app", appCode: "lalu", baseScope: RoleScopeCountry, configurable: true, expanded: RoleScopeRegion},
|
||||||
|
{name: "normalized huwaa", appCode: " HUWAa ", baseScope: RoleScopeGlobal, configurable: false, expanded: RoleScopeGlobal},
|
||||||
|
}
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
policy, err := ResolveRoleScopePolicy(testCase.appCode, RoleScopeSceneManagerAddBDLeader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve policy: %v", err)
|
||||||
|
}
|
||||||
|
if policy.BaseScope != testCase.baseScope || policy.RegionExpansionConfigurable != testCase.configurable {
|
||||||
|
t.Fatalf("policy mismatch: %+v", policy)
|
||||||
|
}
|
||||||
|
if got := policy.EffectiveScope(true); got != testCase.expanded {
|
||||||
|
t.Fatalf("expanded scope = %q, want %q", got, testCase.expanded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -135,6 +135,16 @@ func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) {
|
|||||||
t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason)
|
t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerAddBDLeaderInRegion)
|
||||||
|
if err != nil || allowed || reason != "manager_capability_required" {
|
||||||
|
t.Fatalf("region expansion must default closed: allowed=%v reason=%q err=%v", allowed, reason, err)
|
||||||
|
}
|
||||||
|
repository.SetManagerAddBDLeaderInRegion(101, true)
|
||||||
|
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerAddBDLeaderInRegion)
|
||||||
|
if err != nil || !allowed || reason != "" {
|
||||||
|
t.Fatalf("enabled region expansion should be allowed: allowed=%v reason=%q err=%v", allowed, reason, err)
|
||||||
|
}
|
||||||
|
|
||||||
repository.SetManagerGrantVIP(101, false)
|
repository.SetManagerGrantVIP(101, false)
|
||||||
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerGrantVIP)
|
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerGrantVIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -373,6 +373,8 @@ func (r *Repository) HasActiveManagerCapability(ctx context.Context, userID int6
|
|||||||
columnSQL = "can_update_user_level = 1"
|
columnSQL = "can_update_user_level = 1"
|
||||||
case hostservice.CapabilityManagerAddBDLeader:
|
case hostservice.CapabilityManagerAddBDLeader:
|
||||||
columnSQL = "can_add_bd_leader = 1"
|
columnSQL = "can_add_bd_leader = 1"
|
||||||
|
case hostservice.CapabilityManagerAddBDLeaderInRegion:
|
||||||
|
columnSQL = "can_add_bd_leader_in_region = 1"
|
||||||
case hostservice.CapabilityManagerAddAdmin:
|
case hostservice.CapabilityManagerAddAdmin:
|
||||||
columnSQL = "can_add_admin = 1"
|
columnSQL = "can_add_admin = 1"
|
||||||
case hostservice.CapabilityManagerAddSuperadmin:
|
case hostservice.CapabilityManagerAddSuperadmin:
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
|
hostservice "hyapp/services/user-service/internal/service/host"
|
||||||
)
|
)
|
||||||
|
|
||||||
const huwaaAppCode = "huwaa"
|
|
||||||
|
|
||||||
// roleRegionMatchRequired 只控制 Agency 角色邀请、Host 主动申请和 BD 邀请的区域匹配。
|
// roleRegionMatchRequired 只控制 Agency 角色邀请、Host 主动申请和 BD 邀请的区域匹配。
|
||||||
// Huwaa 的这些组织拓展允许跨区域,但角色事实仍归属目标用户当前区域;Agency 主动邀请 Host、币商列表和子币商关系不得复用该例外。
|
// App 差异由统一策略注册表解析;Agency 主动邀请 Host、币商列表和子币商关系不得复用该例外。
|
||||||
func roleRegionMatchRequired(ctx context.Context) bool {
|
func roleRegionMatchRequired(ctx context.Context) bool {
|
||||||
return appcode.FromContext(ctx) != huwaaAppCode
|
policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), hostservice.RoleScopeSceneOrganizationExpansion)
|
||||||
|
return err != nil || policy.BaseScope != hostservice.RoleScopeGlobal
|
||||||
}
|
}
|
||||||
|
|||||||
@ -829,9 +829,9 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
|
|||||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||||
INSERT INTO manager_profiles (
|
INSERT INTO manager_profiles (
|
||||||
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
||||||
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
|
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_bd_leader_in_region, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
|
||||||
created_by_admin_id, created_at_ms, updated_at_ms
|
created_by_admin_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
contact_info = VALUES(contact_info),
|
contact_info = VALUES(contact_info),
|
||||||
@ -841,6 +841,7 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
|
|||||||
can_grant_vip = VALUES(can_grant_vip),
|
can_grant_vip = VALUES(can_grant_vip),
|
||||||
can_update_user_level = VALUES(can_update_user_level),
|
can_update_user_level = VALUES(can_update_user_level),
|
||||||
can_add_bd_leader = VALUES(can_add_bd_leader),
|
can_add_bd_leader = VALUES(can_add_bd_leader),
|
||||||
|
can_add_bd_leader_in_region = VALUES(can_add_bd_leader_in_region),
|
||||||
can_add_admin = VALUES(can_add_admin),
|
can_add_admin = VALUES(can_add_admin),
|
||||||
can_add_superadmin = VALUES(can_add_superadmin),
|
can_add_superadmin = VALUES(can_add_superadmin),
|
||||||
can_block_user = VALUES(can_block_user),
|
can_block_user = VALUES(can_block_user),
|
||||||
@ -848,7 +849,7 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
|
|||||||
updated_at_ms = VALUES(updated_at_ms)
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
|
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
|
||||||
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanGrantVIP),
|
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanGrantVIP),
|
||||||
defaultTrue(profile.CanUpdateUserLevel), defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
|
defaultTrue(profile.CanUpdateUserLevel), defaultTrue(profile.CanAddBDLeader), profile.CanAddBDLeaderInRegion, defaultTrue(profile.CanAddAdmin),
|
||||||
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
|
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
|
||||||
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
|
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -856,6 +857,19 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SetManagerAddBDLeaderInRegion(userID int64, enabled bool) {
|
||||||
|
r.t.Helper()
|
||||||
|
|
||||||
|
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||||
|
UPDATE manager_profiles
|
||||||
|
SET can_add_bd_leader_in_region = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
`, enabled, userID)
|
||||||
|
if err != nil {
|
||||||
|
r.t.Fatalf("set manager region BD Leader permission %d failed: %v", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) SetManagerGrantVIP(userID int64, enabled bool) {
|
func (r *Repository) SetManagerGrantVIP(userID int64, enabled bool) {
|
||||||
r.t.Helper()
|
r.t.Helper()
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package grpc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
hostservice "hyapp/services/user-service/internal/service/host"
|
hostservice "hyapp/services/user-service/internal/service/host"
|
||||||
|
|
||||||
@ -539,6 +540,20 @@ func (s *Server) CheckBusinessCapability(ctx context.Context, req *userv1.CheckB
|
|||||||
return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed, Reason: reason}, nil
|
return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed, Reason: reason}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRoleScopePolicy 暴露 App 级角色范围元数据,让 gateway/admin 只消费策略结果而不复制 app_code 分支。
|
||||||
|
func (s *Server) GetRoleScopePolicy(ctx context.Context, req *userv1.GetRoleScopePolicyRequest) (*userv1.GetRoleScopePolicyResponse, error) {
|
||||||
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), req.GetScene())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
|
||||||
|
Scene: policy.Scene,
|
||||||
|
BaseScope: string(policy.BaseScope),
|
||||||
|
RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAgencyMembers 返回 Agency 成员关系事实列表。
|
// GetAgencyMembers 返回 Agency 成员关系事实列表。
|
||||||
func (s *Server) GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
|
func (s *Server) GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
|
||||||
if s.hostSvc == nil {
|
if s.hostSvc == nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user