feat: add manager BD Leader scope policy

This commit is contained in:
zhx 2026-07-11 18:27:19 +08:00
parent 1fe23326c6
commit 2444a7a108
27 changed files with 1750 additions and 974 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -82,6 +82,10 @@ type ManagerListItem struct {
CanGrantVIP bool `json:"canGrantVip"` CanGrantVIP bool `json:"canGrantVip"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"` CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"` CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion bool `json:"canAddBdLeaderInRegion"`
BDLeaderInviteBaseScope string `json:"bdLeaderInviteBaseScope"`
BDLeaderInviteEffectiveScope string `json:"bdLeaderInviteEffectiveScope"`
BDLeaderRegionExpansionConfigurable bool `json:"bdLeaderRegionExpansionConfigurable"`
CanAddAdmin bool `json:"canAddAdmin"` CanAddAdmin bool `json:"canAddAdmin"`
CanAddSuperadmin bool `json:"canAddSuperadmin"` CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanBlockUser bool `json:"canBlockUser"` CanBlockUser bool `json:"canBlockUser"`
@ -92,6 +96,19 @@ type ManagerListItem struct {
UpdatedAtMs int64 `json:"updatedAtMs"` 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 {
RegionID int64 `json:"regionId,string"` RegionID int64 `json:"regionId,string"`
MinUSDMinor int64 `json:"minUsdMinor"` MinUSDMinor int64 `json:"minUsdMinor"`
@ -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,

View File

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

View File

@ -77,6 +77,7 @@ type createManagerRequest struct {
CanGrantVIP *bool `json:"canGrantVip"` CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"` CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"` CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanAddAdmin *bool `json:"canAddAdmin"` CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"` CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"` CanBlockUser *bool `json:"canBlockUser"`
@ -92,6 +93,7 @@ type updateManagerRequest struct {
CanGrantVIP *bool `json:"canGrantVip"` CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"` CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"` CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanAddAdmin *bool `json:"canAddAdmin"` CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"` CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"` CanBlockUser *bool `json:"canBlockUser"`
@ -122,6 +124,7 @@ type managerPermissions struct {
CanGrantVIP bool CanGrantVIP bool
CanUpdateUserLevel bool CanUpdateUserLevel bool
CanAddBDLeader bool CanAddBDLeader bool
CanAddBDLeaderInRegion bool
CanAddAdmin bool CanAddAdmin bool
CanAddSuperadmin bool CanAddSuperadmin bool
CanBlockUser bool CanBlockUser bool
@ -136,6 +139,7 @@ func (r createManagerRequest) permissionsWithDefault() managerPermissions {
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP), CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel), CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader), CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddBDLeaderInRegion: boolDefaultFalse(r.CanAddBDLeaderInRegion),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin), CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin), CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
CanBlockUser: boolDefaultTrue(r.CanBlockUser), CanBlockUser: boolDefaultTrue(r.CanBlockUser),
@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,12 +1037,31 @@ 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 {
@ -999,6 +1072,7 @@ func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bo
"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_bd_leader_in_region": false,
"can_add_admin": false, "can_add_admin": false,
"can_add_superadmin": false, "can_add_superadmin": false,
"can_block_user": false, "can_block_user": false,
@ -1011,6 +1085,7 @@ func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bo
"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_bd_leader_in_region": managerAddBDLeaderInRegionCapability,
"can_add_admin": managerAddAdminCapability, "can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability, "can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability, "can_block_user": managerBlockUserCapability,
@ -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{}

View File

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

View File

@ -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 '是否允许在经理中心封禁用户',

View File

@ -241,6 +241,7 @@ type ManagerProfile struct {
CanGrantVIP bool CanGrantVIP bool
CanUpdateUserLevel bool CanUpdateUserLevel bool
CanAddBDLeader bool CanAddBDLeader bool
CanAddBDLeaderInRegion bool
CanAddAdmin bool CanAddAdmin bool
CanAddSuperadmin bool CanAddSuperadmin bool
CanBlockUser bool CanBlockUser bool

View File

@ -16,6 +16,7 @@ const (
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"
CapabilityManagerAddBDLeaderInRegion = "manager_add_bd_leader_in_region"
CapabilityManagerAddAdmin = "manager_add_admin" CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin" CapabilityManagerAddSuperadmin = "manager_add_superadmin"
CapabilityManagerBlockUser = "manager_block_user" CapabilityManagerBlockUser = "manager_block_user"
@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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