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;
}
// 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 {
RequestMeta meta = 1;
int64 agency_id = 2;
@ -709,6 +725,7 @@ service UserHostService {
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
rpc GetAgency(GetAgencyRequest) returns (GetAgencyResponse);
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
rpc GetRoleScopePolicy(GetRoleScopePolicyRequest) returns (GetRoleScopePolicyResponse);
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
rpc UpdateAgencyProfile(UpdateAgencyProfileRequest) returns (UpdateAgencyProfileResponse);

View File

@ -45,6 +45,7 @@ const (
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
UserHostService_GetAgency_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgency"
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_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
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)
GetAgency(ctx context.Context, in *GetAgencyRequest, opts ...grpc.CallOption) (*GetAgencyResponse, 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)
GetAgencyApplications(ctx context.Context, in *GetAgencyApplicationsRequest, opts ...grpc.CallOption) (*GetAgencyApplicationsResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetAgencyMembersResponse)
@ -419,6 +431,7 @@ type UserHostServiceServer interface {
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
GetAgency(context.Context, *GetAgencyRequest) (*GetAgencyResponse, error)
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
GetRoleScopePolicy(context.Context, *GetRoleScopePolicyRequest) (*GetRoleScopePolicyResponse, error)
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
GetAgencyApplications(context.Context, *GetAgencyApplicationsRequest) (*GetAgencyApplicationsResponse, 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) {
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) {
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)
}
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) {
in := new(GetAgencyMembersRequest)
if err := dec(in); err != nil {
@ -1173,6 +1207,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CheckBusinessCapability",
Handler: _UserHostService_CheckBusinessCapability_Handler,
},
{
MethodName: "GetRoleScopePolicy",
Handler: _UserHostService_GetRoleScopePolicy_Handler,
},
{
MethodName: "GetAgencyMembers",
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 复用。
经理中心创建 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
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 (
"context"
"fmt"
"time"
"hyapp-admin-server/internal/appctx"
@ -41,6 +42,7 @@ type Client interface {
ReviewCoinSellerSubApplication(ctx context.Context, req ReviewCoinSellerSubApplicationRequest) (*CoinSellerSubApplicationReviewResult, error)
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, 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)
AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error)
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
@ -111,6 +113,18 @@ type ChangeUserCountryResult struct {
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 {
User *User `json:"user"`
RevokedSessionCount int64 `json:"revokedSessionCount"`
@ -817,6 +831,25 @@ func (c *GRPCClient) CreateBDLeader(ctx context.Context, req CreateBDLeaderReque
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) {
resp, err := c.hostAdminClient.CreateBD(ctx, &userv1.CreateBDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),

View File

@ -68,12 +68,15 @@ func (h *Handler) ListManagers(c *gin.Context) {
if !ok {
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 {
response.ServerError(c, "获取经理列表失败")
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) {

View File

@ -68,28 +68,45 @@ type UserIdentity struct {
}
type ManagerListItem struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
Contact string `json:"contact"`
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanGrantBadge bool `json:"canGrantBadge"`
CanGrantVIP bool `json:"canGrantVip"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddAdmin bool `json:"canAddAdmin"`
CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanBlockUser bool `json:"canBlockUser"`
CanTransferCountry bool `json:"canTransferUserCountry"`
BDLeaderCount int64 `json:"bdLeaderCount"`
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
Contact string `json:"contact"`
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanGrantBadge bool `json:"canGrantBadge"`
CanGrantVIP bool `json:"canGrantVip"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion bool `json:"canAddBdLeaderInRegion"`
BDLeaderInviteBaseScope string `json:"bdLeaderInviteBaseScope"`
BDLeaderInviteEffectiveScope string `json:"bdLeaderInviteEffectiveScope"`
BDLeaderRegionExpansionConfigurable bool `json:"bdLeaderRegionExpansionConfigurable"`
CanAddAdmin bool `json:"canAddAdmin"`
CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanBlockUser bool `json:"canBlockUser"`
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 {
@ -645,9 +662,9 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles (
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
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = 'active',
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_update_user_level = VALUES(can_update_user_level),
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_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms)
`, 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 {
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")+`,
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_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),
mp.created_at_ms, mp.updated_at_ms
%s
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,
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
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
LIMIT ? OFFSET ?
@ -806,6 +824,7 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.CanGrantVIP,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddBDLeaderInRegion,
&item.CanAddAdmin,
&item.CanAddSuperadmin,
&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`).
WillReturnRows(managerListRows().
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)))
item, err := reader.UpdateManagerProfile(ctx, 1003, 7, updateManagerRequest{Status: &status})
@ -75,6 +75,7 @@ func managerListRows() *sqlmock.Rows {
"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",

View File

@ -69,33 +69,35 @@ type createBDLeaderRequest struct {
}
type createManagerRequest struct {
Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
CanTransferCountry *bool `json:"canTransferUserCountry"`
Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
CanTransferCountry *bool `json:"canTransferUserCountry"`
}
type updateManagerRequest struct {
Contact *string `json:"contact"`
Status *string `json:"status"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
CanTransferCountry *bool `json:"canTransferUserCountry"`
Contact *string `json:"contact"`
Status *string `json:"status"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
CanTransferCountry *bool `json:"canTransferUserCountry"`
}
func (r updateManagerRequest) appendStatusAssignment(assignments *[]string, args *[]any) error {
@ -116,30 +118,32 @@ func (r updateManagerRequest) appendStatusAssignment(assignments *[]string, args
}
type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddBDLeaderInRegion bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
}
func (r createManagerRequest) permissionsWithDefault() managerPermissions {
return managerPermissions{
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
CanTransferCountry: boolDefaultTrue(r.CanTransferCountry),
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddBDLeaderInRegion: boolDefaultFalse(r.CanAddBDLeaderInRegion),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
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_update_user_level", r.CanUpdateUserLevel)
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_superadmin", r.CanAddSuperadmin)
appendBoolAssignment(assignments, args, "can_block_user", r.CanBlockUser)
@ -168,6 +173,10 @@ func boolDefaultTrue(value *bool) bool {
return value == nil || *value
}
func boolDefaultFalse(value *bool) bool {
return value != nil && *value
}
type createBDRequest struct {
CommandID string `json:"commandId" 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"
bdLeaderPositionAliasMaxRunes = 64
hostOrgInternalUserIDMinDigits = 15
managerAddBDLeaderScopeScene = "manager_add_bd_leader"
)
type Service struct {
@ -56,22 +57,87 @@ func (s *Service) ListAgencies(ctx context.Context, query listQuery) ([]*usercli
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)
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) {
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())
if err != nil {
return nil, err
}
// 经理身份只落 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) {
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) {

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 状态仍然写独立负责人表。
func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) {
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)
UpdateAgencyProfile(ctx context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, 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)
GetAgencyApplications(ctx context.Context, req *userv1.GetAgencyApplicationsRequest) (*userv1.GetAgencyApplicationsResponse, 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)
}
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) {
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{
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
{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"},
},
}
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.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) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{}
@ -751,9 +807,10 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(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: 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"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
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) {
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, 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"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
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 managerUpdateUserLevelCapability = "manager_update_user_level"
const managerAddBDLeaderCapability = "manager_add_bd_leader"
const managerAddBDLeaderInRegionCapability = "manager_add_bd_leader_in_region"
const managerAddAdminCapability = "manager_add_admin"
const managerAddSuperadminCapability = "manager_add_superadmin"
const managerBlockUserCapability = "manager_block_user"
@ -33,7 +34,10 @@ const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
const managerGrantVIPDurationMS int64 = 30 * managerGrantDayMS
const managerGrantMaxResourceIDs = 20
const managerGrantMaxVIPLevel int32 = 5
const huwaaAppCode = "huwaa"
const managerRoleScopeCountry = "country"
const managerRoleScopeRegion = "region"
const managerRoleScopeGlobal = "global"
type managerGrantResourceData struct {
ResourceID string `json:"resource_id"`
@ -168,19 +172,24 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
return
}
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和等级投影做最终过滤。
// 国家转移依赖显式 scope=all 和权限Huwaa 的 add-bd-leader 是唯一无需该权限即可跨国家选人的角色例外
// BD Leader 的 App 差异来自 user-service 策略handler 不直接判断 app_code
actor, ok := h.requireManagerCenterActor(writer, request)
if !ok {
return
}
scope := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("scope")))
requestedAllCountries := scope == "all"
// Huwaa 的 BD Leader 拓展不受区域/国家边界限制;例外只绑定 add-bd-leader 场景,资源发放等高风险动作仍保持同国家。
bdLeaderCrossRegion := huwaaBDLeaderSearchRegionUnrestricted(request)
if requestedAllCountries && !bdLeaderCrossRegion && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
bdLeaderSearch := managerSearchScene(request) == managerAddBDLeaderCapability
requestedAllCountries := scope == "all" && !bdLeaderSearch
if requestedAllCountries && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
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)
if !ok {
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))
for _, userID := range userIDs {
user := usersResp.GetUsers()[userID]
// 默认搜索必须和写入口一样收敛到同国家;国家转移已单独校验权限,允许在这里跨国家挑选目标。
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || (!allCountries && !sameCountry(actor.GetCountry(), user.GetCountry())) {
// 搜索与最终创建共用 scope evaluator避免同一 App 的规则在两个 handler 分叉。
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE ||
(!requestedAllCountries && !managerTargetMatchesScope(managerTargetScope, actor, user)) {
continue
}
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")
return
}
// 写入口必须重新读取 active 目标用户Huwaa 只取消区域/国家匹配,经理身份和 add-bd-leader 能力仍在上方强制校验。
var target *userv1.User
if huwaaRoleRegionUnrestricted(request) {
target, ok = h.requireActiveTarget(writer, request, targetUserID)
} else {
target, ok = h.requireSameCountryTargetWithActor(writer, request, actor, targetUserID)
}
// 写入口重新解析策略并读取 active 目标,不能信任搜索结果或客户端自报范围。
target, ok := h.requireManagerBDLeaderTarget(writer, request, actor, targetUserID)
if !ok {
return
}
@ -945,6 +950,39 @@ func (h *Handler) requireManagerCapability(writer http.ResponseWriter, request *
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) {
actor, ok := h.requireManagerCenterActor(writer, request)
if !ok {
@ -965,6 +1003,22 @@ func (h *Handler) requireSameCountryTargetWithActor(writer http.ResponseWriter,
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 用户校验。
func (h *Handler) requireActiveTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
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
}
func huwaaRoleRegionUnrestricted(request *http.Request) bool {
return request != nil && appcode.FromContext(request.Context()) == huwaaAppCode
func validManagerRoleScope(scope string) bool {
switch scope {
case managerRoleScopeCountry, managerRoleScopeRegion, managerRoleScopeGlobal:
return true
default:
return false
}
}
func huwaaBDLeaderSearchRegionUnrestricted(request *http.Request) bool {
return huwaaRoleRegionUnrestricted(request) && managerSearchScene(request) == managerAddBDLeaderCapability
// managerTargetMatchesScope 是搜索和写入口共享的唯一匹配器。region 表示在同国家基础上扩到同区域,
// 因而国家映射短暂异常时,打开扩区开关也不会反而失去原本可邀请的同国家用户。
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 {
permissions := map[string]bool{
"can_grant_avatar_frame": false,
"can_grant_vehicle": false,
"can_grant_badge": false,
"can_grant_vip": false,
"can_update_user_level": false,
"can_add_bd_leader": false,
"can_add_admin": false,
"can_add_superadmin": false,
"can_block_user": false,
"can_transfer_user_country": false,
"can_grant_avatar_frame": false,
"can_grant_vehicle": false,
"can_grant_badge": false,
"can_grant_vip": false,
"can_update_user_level": false,
"can_add_bd_leader": false,
"can_add_bd_leader_in_region": false,
"can_add_admin": false,
"can_add_superadmin": false,
"can_block_user": false,
"can_transfer_user_country": false,
}
for key, capability := range map[string]string{
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability,
"can_grant_badge": managerGrantBadgeCapability,
"can_grant_vip": managerGrantVIPCapability,
"can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability,
"can_transfer_user_country": managerTransferUserCountryCapability,
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability,
"can_grant_badge": managerGrantBadgeCapability,
"can_grant_vip": managerGrantVIPCapability,
"can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_bd_leader_in_region": managerAddBDLeaderInRegionCapability,
"can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability,
"can_transfer_user_country": managerTransferUserCountryCapability,
} {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
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) != ""
}
func sameRegion(left int64, right int64) bool {
return left > 0 && left == right
}
func managerGrantResourceFromProto(resource *walletv1.Resource) managerGrantResourceData {
if resource == nil {
return managerGrantResourceData{}

View File

@ -552,8 +552,12 @@ type fakeUserHostClient struct {
kickErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilities []string
capabilityAllowed map[string]bool
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
lastRoleScopePolicy *userv1.GetRoleScopePolicyRequest
roleScopePolicyResp *userv1.GetRoleScopePolicyResponse
roleScopePolicyErr error
}
type fakeUserHostAdminClient struct {
@ -1972,9 +1976,27 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
if 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
}
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 {
for _, item := range f.capabilities {
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_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_in_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许把 BD Leader 邀请范围从本国家扩展到经理所属区域',
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',

View File

@ -231,23 +231,24 @@ type BDProfile struct {
// ManagerProfile 是经理身份事实。
// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。
type ManagerProfile struct {
UserID int64
RegionID int64
Status string
Contact string
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
CreatedByAdminID int64
CreatedAtMs int64
UpdatedAtMs int64
UserID int64
RegionID int64
Status string
Contact string
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddBDLeaderInRegion bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
CreatedByAdminID int64
CreatedAtMs int64
UpdatedAtMs int64
}
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。

View File

@ -8,18 +8,19 @@ import (
)
const (
CapabilityManagerCenter = "manager_center"
CapabilityManagerResourceGrant = "manager_resource_grant"
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
CapabilityManagerGrantBadge = "manager_grant_badge"
CapabilityManagerGrantVIP = "manager_grant_vip"
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
CapabilityManagerBlockUser = "manager_block_user"
CapabilityManagerTransferCountry = "manager_transfer_user_country"
CapabilityManagerCenter = "manager_center"
CapabilityManagerResourceGrant = "manager_resource_grant"
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
CapabilityManagerGrantBadge = "manager_grant_badge"
CapabilityManagerGrantVIP = "manager_grant_vip"
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
CapabilityManagerAddBDLeaderInRegion = "manager_add_bd_leader_in_region"
CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
CapabilityManagerBlockUser = "manager_block_user"
CapabilityManagerTransferCountry = "manager_transfer_user_country"
)
// CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。
@ -49,6 +50,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
CapabilityManagerGrantVIP,
CapabilityManagerUpdateUserLevel,
CapabilityManagerAddBDLeader,
CapabilityManagerAddBDLeaderInRegion,
CapabilityManagerAddAdmin,
CapabilityManagerAddSuperadmin,
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)
}
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)
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerGrantVIP)
if err != nil {

View File

@ -373,6 +373,8 @@ func (r *Repository) HasActiveManagerCapability(ctx context.Context, userID int6
columnSQL = "can_update_user_level = 1"
case hostservice.CapabilityManagerAddBDLeader:
columnSQL = "can_add_bd_leader = 1"
case hostservice.CapabilityManagerAddBDLeaderInRegion:
columnSQL = "can_add_bd_leader_in_region = 1"
case hostservice.CapabilityManagerAddAdmin:
columnSQL = "can_add_admin = 1"
case hostservice.CapabilityManagerAddSuperadmin:

View File

@ -4,12 +4,12 @@ import (
"context"
"hyapp/pkg/appcode"
hostservice "hyapp/services/user-service/internal/service/host"
)
const huwaaAppCode = "huwaa"
// roleRegionMatchRequired 只控制 Agency 角色邀请、Host 主动申请和 BD 邀请的区域匹配。
// Huwaa 的这些组织拓展允许跨区域,但角色事实仍归属目标用户当前区域Agency 主动邀请 Host、币商列表和子币商关系不得复用该例外。
// App 差异由统一策略注册表解析Agency 主动邀请 Host、币商列表和子币商关系不得复用该例外。
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(), `
INSERT INTO manager_profiles (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
contact_info = VALUES(contact_info),
@ -841,6 +841,7 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
can_grant_vip = VALUES(can_grant_vip),
can_update_user_level = VALUES(can_update_user_level),
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_superadmin = VALUES(can_add_superadmin),
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)
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
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),
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
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) {
r.t.Helper()

View File

@ -3,6 +3,7 @@ package grpc
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
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
}
// 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 成员关系事实列表。
func (s *Server) GetAgencyMembers(ctx context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
if s.hostSvc == nil {