feat: generalize manager cross-region scope

This commit is contained in:
zhx 2026-07-11 20:36:42 +08:00
parent 2444a7a108
commit f19f212e54
28 changed files with 1026 additions and 820 deletions

File diff suppressed because it is too large Load Diff

View File

@ -487,6 +487,8 @@ message RoleScopePolicy {
string scene = 1;
string base_scope = 2;
bool region_expansion_configurable = 3;
// expanded_scope region
string expanded_scope = 4;
}
message GetRoleScopePolicyRequest {

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不接受客户端自报租户。App 差异统一由 user-service 的角色范围策略解析Huwaa 的基础范围是 `global`;其他 App 的基础范围是 `country`且只有策略声明可配置时,经理的区域邀请开关才能把有效范围扩展为 `region`。搜索和创建入口只消费 `country|region|global` 策略结果,不直接判断具体 App两类路径都必须保留 active Manager 和 `manager_add_bd_leader` 能力校验。
经理中心所有目标用户操作都必须从已验证的 App token 取得 `app_code`不接受客户端自报租户。App 差异统一由 user-service 的 `manager_operations` 范围策略解析Huwaa 的基础范围是 `global`;其他 App 的基础范围是 `country`后台开启“跨区经理”后有效范围扩展为同一有效 `region_id`仍禁止跨区域。头像框、座驾、徽章、VIP、升级等级、BD Leader/Admin/Superadmin、封禁和转移国家的搜索与写入口共用同一个范围匹配器不能各自判断具体 App 或让 `scope=all` 绕过数据范围;各动作原有功能权限仍独立校验。
```sql
admin_operation_logs(
@ -526,5 +526,5 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
- BD/BD Leader base is downstream host salary only.
- Leader direct Agency salary source must be de-duplicated.
- Every admin mutation must be audited and idempotent.
- Huwaa 跨区例外适用于 Agency 邀请、Host 主动申请、BD 邀请和经理创建 BD LeaderCoin Seller 仍为同区域。
- Huwaa 跨区例外适用于 Agency 邀请、Host 主动申请、BD 邀请和全部经理中心目标用户操作Coin Seller 仍为同区域。
- Redis cannot be the source of truth for policies, salary, relationships, or audit.

View File

@ -0,0 +1,23 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 通用跨区经理替代上一版 BD Leader 专用扩区开关;默认关闭,不扩大任何现有经理的数据范围。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_manage_cross_region') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_manage_cross_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否允许经理中心所有目标用户操作跨区域执行'' AFTER can_add_bd_leader',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 若 064 已落库,则继承显式开启值;未执行 064 的环境保持默认关闭。
SET @copy_legacy := 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,
'UPDATE manager_profiles SET can_manage_cross_region = can_add_bd_leader_in_region WHERE can_add_bd_leader_in_region = 1',
'SELECT 1'
);
PREPARE stmt FROM @copy_legacy;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -122,6 +122,7 @@ type GetRoleScopePolicyRequest struct {
type RoleScopePolicy struct {
Scene string `json:"scene"`
BaseScope string `json:"baseScope"`
ExpandedScope string `json:"expandedScope"`
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
}
@ -846,6 +847,7 @@ func (c *GRPCClient) GetRoleScopePolicy(ctx context.Context, req GetRoleScopePol
return &RoleScopePolicy{
Scene: policy.GetScene(),
BaseScope: policy.GetBaseScope(),
ExpandedScope: policy.GetExpandedScope(),
RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(),
}, nil
}

View File

@ -75,7 +75,7 @@ func (h *Handler) ListManagers(c *gin.Context) {
}
response.OK(c, ManagerPageResponse{
Items: items, Page: query.Page, PageSize: query.PageSize, Total: total,
BDLeaderInviteScopePolicy: policy,
ManagerOperationScopePolicy: policy,
})
}

View File

@ -68,45 +68,47 @@ 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"`
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"`
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"`
CanManageCrossRegion bool `json:"canManageCrossRegion"`
LegacyBDLeaderInRegion bool `json:"canAddBdLeaderInRegion"`
ManagerOperationBaseScope string `json:"managerOperationBaseScope"`
ManagerOperationEffectiveScope string `json:"managerOperationEffectiveScope"`
ManagerCrossRegionConfigurable bool `json:"managerCrossRegionConfigurable"`
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"`
ExpandedScope string `json:"expandedScope"`
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"`
Items []*ManagerListItem `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ManagerOperationScopePolicy ManagerRoleScopePolicy `json:"managerOperationScopePolicy"`
}
type CoinSellerSalaryRateTier struct {
@ -662,7 +664,7 @@ 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_bd_leader_in_region, 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_manage_cross_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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
@ -674,14 +676,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_manage_cross_region = VALUES(can_manage_cross_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.CanAddBDLeaderInRegion, permissions.CanAddAdmin,
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanManageCrossRegion, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
return nil, err
}
@ -789,14 +791,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_bd_leader_in_region, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
mp.can_add_bd_leader, mp.can_manage_cross_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_bd_leader_in_region, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
mp.can_add_bd_leader, mp.can_manage_cross_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 ?
@ -824,7 +826,7 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.CanGrantVIP,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddBDLeaderInRegion,
&item.CanManageCrossRegion,
&item.CanAddAdmin,
&item.CanAddSuperadmin,
&item.CanBlockUser,

View File

@ -75,7 +75,7 @@ func managerListRows() *sqlmock.Rows {
"can_grant_vip",
"can_update_user_level",
"can_add_bd_leader",
"can_add_bd_leader_in_region",
"can_manage_cross_region",
"can_add_admin",
"can_add_superadmin",
"can_block_user",

View File

@ -77,7 +77,8 @@ type createManagerRequest struct {
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanManageCrossRegion *bool `json:"canManageCrossRegion"`
LegacyBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
@ -93,7 +94,8 @@ type updateManagerRequest struct {
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanManageCrossRegion *bool `json:"canManageCrossRegion"`
LegacyBDLeaderInRegion *bool `json:"canAddBdLeaderInRegion"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
@ -118,32 +120,32 @@ func (r updateManagerRequest) appendStatusAssignment(assignments *[]string, args
}
type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddBDLeaderInRegion bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanManageCrossRegion 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),
CanAddBDLeaderInRegion: boolDefaultFalse(r.CanAddBDLeaderInRegion),
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),
CanManageCrossRegion: boolDefaultFalse(firstBool(r.CanManageCrossRegion, r.LegacyBDLeaderInRegion)),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
CanTransferCountry: boolDefaultTrue(r.CanTransferCountry),
}
}
@ -154,7 +156,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_manage_cross_region", firstBool(r.CanManageCrossRegion, r.LegacyBDLeaderInRegion))
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
appendBoolAssignment(assignments, args, "can_add_superadmin", r.CanAddSuperadmin)
appendBoolAssignment(assignments, args, "can_block_user", r.CanBlockUser)
@ -177,6 +179,13 @@ func boolDefaultFalse(value *bool) bool {
return value != nil && *value
}
func firstBool(primary *bool, fallback *bool) *bool {
if primary != nil {
return primary
}
return fallback
}
type createBDRequest struct {
CommandID string `json:"commandId" binding:"required"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`

View File

@ -30,20 +30,28 @@ func TestFlexibleInt64AcceptsStringAndNumber(t *testing.T) {
}
}
func TestManagerRegionExpansionDefaultsClosedAndPreservesExplicitEnable(t *testing.T) {
func TestManagerCrossRegionDefaultsClosedAndAcceptsLegacyField(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")
if omitted.permissionsWithDefault().CanManageCrossRegion {
t.Fatalf("cross-region manager must default closed")
}
var enabled createManagerRequest
if err := json.Unmarshal([]byte(`{"targetUserId":"1003","canAddBdLeaderInRegion":true}`), &enabled); err != nil {
if err := json.Unmarshal([]byte(`{"targetUserId":"1003","canManageCrossRegion":true}`), &enabled); err != nil {
t.Fatalf("unmarshal enabled permission: %v", err)
}
if !enabled.permissionsWithDefault().CanAddBDLeaderInRegion {
t.Fatalf("explicit region expansion must be preserved")
if !enabled.permissionsWithDefault().CanManageCrossRegion {
t.Fatalf("explicit cross-region permission must be preserved")
}
var legacy createManagerRequest
if err := json.Unmarshal([]byte(`{"targetUserId":"1003","canAddBdLeaderInRegion":true}`), &legacy); err != nil {
t.Fatalf("unmarshal legacy permission: %v", err)
}
if !legacy.permissionsWithDefault().CanManageCrossRegion {
t.Fatalf("legacy region field must map to cross-region manager during rolling upgrade")
}
}

View File

@ -29,7 +29,7 @@ const (
sortByTotalRechargeUSDT = "total_recharge_usdt"
bdLeaderPositionAliasMaxRunes = 64
hostOrgInternalUserIDMinDigits = 15
managerAddBDLeaderScopeScene = "manager_add_bd_leader"
managerOperationScopeScene = "manager_operations"
)
type Service struct {
@ -70,6 +70,7 @@ func (s *Service) ListManagers(ctx context.Context, query listQuery) ([]*Manager
decorateManagerScopePolicy(items, policy)
return items, total, ManagerRoleScopePolicy{
BaseScope: policy.BaseScope,
ExpandedScope: policy.ExpandedScope,
RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
}, nil
}
@ -79,7 +80,8 @@ func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID st
if err != nil {
return nil, err
}
req.CanAddBDLeaderInRegion = normalizedManagerRegionPermission(req.CanAddBDLeaderInRegion, policy.RegionExpansionConfigurable)
req.CanManageCrossRegion = normalizedManagerCrossRegionPermission(firstBool(req.CanManageCrossRegion, req.LegacyBDLeaderInRegion), policy.RegionExpansionConfigurable)
req.LegacyBDLeaderInRegion = nil
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil {
return nil, err
@ -98,7 +100,8 @@ func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64
if err != nil {
return nil, err
}
req.CanAddBDLeaderInRegion = normalizedManagerRegionPermission(req.CanAddBDLeaderInRegion, policy.RegionExpansionConfigurable)
req.CanManageCrossRegion = normalizedManagerCrossRegionPermission(firstBool(req.CanManageCrossRegion, req.LegacyBDLeaderInRegion), policy.RegionExpansionConfigurable)
req.LegacyBDLeaderInRegion = nil
item, err := s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
if err != nil {
return nil, err
@ -110,12 +113,12 @@ func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64
func (s *Service) managerRoleScopePolicy(ctx context.Context) (*userclient.RoleScopePolicy, error) {
return s.userClient.GetRoleScopePolicy(ctx, userclient.GetRoleScopePolicyRequest{
Caller: "hyapp-admin-server",
Scene: managerAddBDLeaderScopeScene,
Scene: managerOperationScopeScene,
})
}
// normalizedManagerRegionPermission 让 App 基础策略决定开关是否可配置global App 即使收到篡改请求也只保存 false。
func normalizedManagerRegionPermission(value *bool, configurable bool) *bool {
// normalizedManagerCrossRegionPermission 让 App 基础策略决定开关是否可配置global App 即使收到篡改请求也只保存 false。
func normalizedManagerCrossRegionPermission(value *bool, configurable bool) *bool {
if configurable {
return value
}
@ -131,11 +134,12 @@ func decorateManagerScopePolicy(items []*ManagerListItem, policy *userclient.Rol
if item == nil {
continue
}
item.BDLeaderInviteBaseScope = policy.BaseScope
item.BDLeaderRegionExpansionConfigurable = policy.RegionExpansionConfigurable
item.BDLeaderInviteEffectiveScope = policy.BaseScope
if policy.RegionExpansionConfigurable && item.CanAddBDLeaderInRegion {
item.BDLeaderInviteEffectiveScope = "region"
item.LegacyBDLeaderInRegion = item.CanManageCrossRegion
item.ManagerOperationBaseScope = policy.BaseScope
item.ManagerCrossRegionConfigurable = policy.RegionExpansionConfigurable
item.ManagerOperationEffectiveScope = policy.BaseScope
if policy.RegionExpansionConfigurable && item.CanManageCrossRegion {
item.ManagerOperationEffectiveScope = policy.ExpandedScope
}
}
}

View File

@ -130,24 +130,26 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
}
func TestDecorateManagerScopePolicyUsesPolicyMetadataWithoutAppBranches(t *testing.T) {
items := []*ManagerListItem{{CanAddBDLeaderInRegion: true}}
items := []*ManagerListItem{{CanManageCrossRegion: true}}
decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{
BaseScope: "country",
ExpandedScope: "region",
RegionExpansionConfigurable: true,
})
if items[0].BDLeaderInviteEffectiveScope != "region" || !items[0].BDLeaderRegionExpansionConfigurable {
if items[0].ManagerOperationEffectiveScope != "region" || !items[0].ManagerCrossRegionConfigurable || !items[0].LegacyBDLeaderInRegion {
t.Fatalf("configurable policy decoration mismatch: %+v", items[0])
}
items[0].CanAddBDLeaderInRegion = true
items[0].CanManageCrossRegion = true
decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{
BaseScope: "global",
ExpandedScope: "global",
RegionExpansionConfigurable: false,
})
if items[0].BDLeaderInviteEffectiveScope != "global" || items[0].BDLeaderRegionExpansionConfigurable {
if items[0].ManagerOperationEffectiveScope != "global" || items[0].ManagerCrossRegionConfigurable {
t.Fatalf("global policy decoration mismatch: %+v", items[0])
}
if got := normalizedManagerRegionPermission(pointerBool(true), false); got == nil || *got {
if got := normalizedManagerCrossRegionPermission(pointerBool(true), false); got == nil || *got {
t.Fatalf("non-configurable app must persist region expansion as false")
}
}

View File

@ -161,7 +161,7 @@ func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) {
}
}
func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *testing.T) {
func TestSearchManagerUsersDefaultsSameCountryForEveryAction(t *testing.T) {
profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{
Users: []*userv1.BusinessUserLookupItem{
@ -298,15 +298,15 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
if err := json.NewDecoder(allRecorder.Body).Decode(&allEnvelope); err != nil {
t.Fatalf("decode all-country response failed: %v", err)
}
if allEnvelope.Data.Total != 2 || allEnvelope.Data.Items[1].UserID != "900002" {
t.Fatalf("country transfer search must include cross-country users: %+v", allEnvelope.Data)
if allEnvelope.Data.Total != 1 || allEnvelope.Data.Items[0].UserID != "900001" {
t.Fatalf("scope=all must not bypass manager data scope: %+v", allEnvelope.Data)
}
if !hostClient.sawCapability("manager_transfer_user_country") {
t.Fatalf("scope=all search must check transfer-country capability, saw %#v", hostClient.capabilities)
if hostClient.sawCapability("manager_transfer_user_country") {
t.Fatalf("HTTP scope must not become a second authorization path, saw %#v", hostClient.capabilities)
}
}
func TestSearchManagerUsersUsesResolvedGlobalPolicyForHuwaaBDLeaderAction(t *testing.T) {
func TestSearchManagerUsersUsesResolvedGlobalPolicyForAllHuwaaActions(t *testing.T) {
profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
@ -319,7 +319,7 @@ func TestSearchManagerUsersUsesResolvedGlobalPolicyForHuwaaBDLeaderAction(t *tes
},
}
hostClient := &fakeUserHostClient{roleScopePolicyResp: &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
Scene: "manager_add_bd_leader", BaseScope: "global", RegionExpansionConfigurable: false,
Scene: "manager_operations", BaseScope: "global", ExpandedScope: "global", RegionExpansionConfigurable: false,
}}}
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
@ -363,12 +363,12 @@ func TestSearchManagerUsersUsesResolvedGlobalPolicyForHuwaaBDLeaderAction(t *tes
if err := json.NewDecoder(adminRecorder.Body).Decode(&adminEnvelope); err != nil {
t.Fatalf("decode Huwaa admin search response failed: %v", err)
}
if adminEnvelope.Data.Total != 1 {
t.Fatalf("Huwaa non-role manager actions must remain same-country: %+v", adminEnvelope.Data)
if adminEnvelope.Data.Total != 2 {
t.Fatalf("Huwaa manager actions must all use global scope: %+v", adminEnvelope.Data)
}
}
func TestSearchManagerUsersAppliesManagerRegionExpansionWithoutCrossingRegion(t *testing.T) {
func TestSearchManagerUsersAppliesGeneralCrossRegionScopeToEveryAction(t *testing.T) {
profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
@ -384,38 +384,37 @@ func TestSearchManagerUsersAppliesManagerRegionExpansionWithoutCrossingRegion(t
}
for _, testCase := range []struct {
name string
regionOpen bool
crossRegion bool
wantVisible int
}{
{name: "closed keeps country", regionOpen: false, wantVisible: 1},
{name: "open expands within region", regionOpen: true, wantVisible: 2},
{name: "closed keeps country", crossRegion: false, wantVisible: 1},
{name: "open expands inside region", crossRegion: 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,
"manager_cross_region": testCase.crossRegion,
}}
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")
for _, action := range []string{"send-avatar-frame", "send-vehicle", "send-badge", "send-vip", "update-user-level", "add-bd-leader", "add-admin", "add-superadmin", "block-user", "transfer-user-country"} {
request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=900&scope=all&action="+action, nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-cross-region-search")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s status mismatch: got %d body=%s", action, 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 %s response: %v", action, err)
}
if envelope.Data.Total != testCase.wantVisible {
t.Fatalf("%s visible total = %d, want %d", action, envelope.Data.Total, testCase.wantVisible)
}
}
})
}
@ -423,8 +422,8 @@ func TestSearchManagerUsersAppliesManagerRegionExpansionWithoutCrossingRegion(t
func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{}
profileClient := &fakeUserProfileClient{}
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}
profileClient := crossRegionManagerProfileClient()
router := newManagerCenterTestRouter(walletClient, hostClient, profileClient)
body := []byte(`{"command_id":"mgr-grant-1","target_user_id":"900001","resource_id":"101"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body))
@ -541,8 +540,11 @@ func TestGrantManagerResourceChecksCapabilityByWalletResourceType(t *testing.T)
if walletClient.lastGetResource == nil || walletClient.lastGetResource.GetResourceId() != 202 {
t.Fatalf("resource lookup mismatch: %+v", walletClient.lastGetResource)
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_center" {
t.Fatalf("last manager capability should be final same-country actor check, got %+v", hostClient.lastCapability)
if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_cross_region" {
t.Fatalf("last manager capability should resolve the actor's effective target scope, got %+v", hostClient.lastCapability)
}
if !hostClient.sawCapability("manager_center") {
t.Fatalf("resource grant must still validate the active manager entry permission, saw %#v", hostClient.capabilities)
}
if !hostClient.sawCapability("manager_grant_vehicle") {
t.Fatalf("vehicle grant must check manager_grant_vehicle, saw %#v", hostClient.capabilities)
@ -640,7 +642,7 @@ func TestGrantManagerVIPRequiresManagerVIPCapability(t *testing.T) {
func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) {
walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}, crossRegionManagerProfileClient())
body := []byte(`{"command_id":"mgr-vip-5","target_user_id":"900001","level":5}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -707,8 +709,8 @@ func TestGrantManagerVIPUsesTrialCardForConfiguredApp(t *testing.T) {
}
func TestManagerBlockAndUnblockCallUserService(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := newManagerCenterTestRouter(&fakeWalletClient{}, &fakeUserHostClient{}, profileClient)
profileClient := crossRegionManagerProfileClient()
router := newManagerCenterTestRouter(&fakeWalletClient{}, &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}, profileClient)
body := []byte(`{"command_id":"block-163006","target_user_id":"900001","blocked_until_ms":1780000000000}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/blocks", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -741,7 +743,7 @@ func TestManagerBlockAndUnblockCallUserService(t *testing.T) {
func TestSetManagerUserLevelCallsGrowthService(t *testing.T) {
growthClient := &fakeGrowthLevelClient{}
router := newManagerCenterTestRouterWithGrowth(&fakeWalletClient{}, &fakeUserHostClient{}, &fakeUserProfileClient{}, growthClient)
router := newManagerCenterTestRouterWithGrowth(&fakeWalletClient{}, &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}, crossRegionManagerProfileClient(), growthClient)
body := []byte(`{"command_id":"level-wealth-5","target_user_id":"900001","track":"wealth","level":5}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/levels", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -809,7 +811,7 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(t *testing.T) {
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": false}}
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_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))
@ -825,13 +827,13 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(t *testing.T) {
}
}
func TestCreateManagerBDLeaderAllowsCrossCountryTargetWhenRegionExpansionEnabled(t *testing.T) {
func TestCreateManagerBDLeaderAllowsSameRegionTargetForCrossRegionManager(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}}
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_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))
@ -848,6 +850,27 @@ func TestCreateManagerBDLeaderAllowsCrossCountryTargetWhenRegionExpansionEnabled
}
}
func TestCreateManagerBDLeaderRejectsDifferentRegionForCrossRegionManager(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},
}}
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_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-other-region","target_user_id":"900001"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-bdleader-other-region")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodePermissionDenied, "req-manager-bdleader-other-region")
if hostAdminClient.lastCreateBDLeader != nil {
t.Fatalf("different-region target must not reach CreateBDLeader: %+v", hostAdminClient.lastCreateBDLeader)
}
}
func TestCreateManagerBDLeaderAllowsCrossCountryTargetForHuwaa(t *testing.T) {
hostAdminClient := &fakeUserHostAdminClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
@ -855,7 +878,7 @@ func TestCreateManagerBDLeaderAllowsCrossCountryTargetForHuwaa(t *testing.T) {
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "163006", Country: "CA", RegionId: 2002},
}}
hostClient := &fakeUserHostClient{roleScopePolicyResp: &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
Scene: "manager_add_bd_leader", BaseScope: "global", RegionExpansionConfigurable: false,
Scene: "manager_operations", BaseScope: "global", ExpandedScope: "global", RegionExpansionConfigurable: false,
}}}
router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, hostClient, hostAdminClient, profileClient)
body := []byte(`{"command_id":"huwaa-mgr-bdleader-cross","target_user_id":"900001"}`)
@ -874,11 +897,12 @@ func TestCreateManagerBDLeaderAllowsCrossCountryTargetForHuwaa(t *testing.T) {
}
}
func TestTransferManagerUserCountryUsesAdminCountryChangeWithoutSameCountryFilter(t *testing.T) {
func TestTransferManagerUserCountryUsesCrossRegionManagerScope(t *testing.T) {
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: "MX", RegionId: 1001},
}}
hostClient := &fakeUserHostClient{}
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
body := []byte(`{"command_id":"mgr-country-1","target_user_id":"900001","country":"CA"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/users/country", bytes.NewReader(body))
@ -900,6 +924,13 @@ func TestTransferManagerUserCountryUsesAdminCountryChangeWithoutSameCountryFilte
}
}
func crossRegionManagerProfileClient() *fakeUserProfileClient {
return &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: "MX", RegionId: 1001},
}}
}
// TestGetManagerOverviewIncludesTeamSalaryDashboard 验证经理概览聚合团队工资卡片:
// user-service 组织树 Agency owner 去重后传给 wallet-service按本月/上月两个 UTC 周期取回预收入并计算环比。
func TestGetManagerOverviewIncludesTeamSalaryDashboard(t *testing.T) {

View File

@ -24,7 +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 managerCrossRegionCapability = "manager_cross_region"
const managerAddAdminCapability = "manager_add_admin"
const managerAddSuperadminCapability = "manager_add_superadmin"
const managerBlockUserCapability = "manager_block_user"
@ -38,6 +38,7 @@ const managerGrantMaxVIPLevel int32 = 5
const managerRoleScopeCountry = "country"
const managerRoleScopeRegion = "region"
const managerRoleScopeGlobal = "global"
const managerOperationScopeScene = "manager_operations"
type managerGrantResourceData struct {
ResourceID string `json:"resource_id"`
@ -171,25 +172,16 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和等级投影做最终过滤。
// BD Leader 的 App 差异来自 user-service 策略handler 不直接判断 app_code。
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和区域投影做最终过滤。
// 所有经理动作的 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")))
bdLeaderSearch := managerSearchScene(request) == managerAddBDLeaderCapability
requestedAllCountries := scope == "all" && !bdLeaderSearch
if requestedAllCountries && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
managerTargetScope, ok := h.managerEffectiveScope(writer, request)
if !ok {
return
}
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")
@ -235,8 +227,7 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
for _, userID := range userIDs {
user := usersResp.GetUsers()[userID]
// 搜索与最终创建共用 scope evaluator避免同一 App 的规则在两个 handler 分叉。
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE ||
(!requestedAllCountries && !managerTargetMatchesScope(managerTargetScope, actor, user)) {
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || !managerTargetMatchesScope(managerTargetScope, actor, user) {
continue
}
item := managerUserFromProto(user, levels[userID])
@ -391,8 +382,8 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
}
resources = append(resources, resource)
}
// 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
// 所有经理目标用户动作共用同一有效范围wallet-service 还会在事务内复验资源白名单。
if _, ok := h.requireManagerTarget(writer, request, targetUserID); !ok {
return
}
reason := strings.TrimSpace(body.Reason)
@ -477,7 +468,7 @@ func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Requ
}
// 经理中心先读取 App 级 program再选择直接会员或体验卡专用命令。钱包会在写事务内再次校验
// 因此配置在两次 RPC 之间切换时只会明确失败,不会绕过当前发放语义。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
if _, ok := h.requireManagerTarget(writer, request, targetUserID); !ok {
return
}
reason := strings.TrimSpace(body.Reason)
@ -647,8 +638,8 @@ func (h *Handler) createManagerBlock(writer http.ResponseWriter, request *http.R
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// user-service 会再次读取双方用户并校验同国家,这里保留 gateway 早拒绝以减少无效封禁事务。
if _, ok := h.requireSameCountryTargetWithActor(writer, request, actor, targetUserID); !ok {
// user-service 会按同一 App 策略复验gateway 先拒绝越界目标以减少无效封禁事务。
if _, ok := h.requireManagerTargetWithActor(writer, request, actor, targetUserID); !ok {
return
}
resp, err := h.userProfileClient.CreateManagerUserBlock(request.Context(), &userv1.CreateManagerUserBlockRequest{
@ -732,7 +723,7 @@ func (h *Handler) setManagerUserLevel(writer http.ResponseWriter, request *http.
return
}
// 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
if _, ok := h.requireManagerTarget(writer, request, targetUserID); !ok {
return
}
resp, err := h.growthLevelClient.SetUserLevel(request.Context(), &activityv1.SetUserLevelRequest{
@ -783,7 +774,7 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
return
}
// 写入口重新解析策略并读取 active 目标,不能信任搜索结果或客户端自报范围。
target, ok := h.requireManagerBDLeaderTarget(writer, request, actor, targetUserID)
target, ok := h.requireManagerTargetWithActor(writer, request, actor, targetUserID)
if !ok {
return
}
@ -847,7 +838,10 @@ func (h *Handler) transferManagerUserCountry(writer http.ResponseWriter, request
if reason == "" {
reason = "manager_center_country_transfer"
}
// 国家转移是治理动作,不复用同国家目标校验;权限由 manager_profiles 独立开关控制,实际国家/区域事务由 user-service 统一落库和发 outbox。
// 国家转移也属于经理数据范围;拥有功能权限不代表可以越过 country/region/global 有效范围选择任意目标。
if _, ok := h.requireManagerTargetWithActor(writer, request, actor, targetUserID); !ok {
return
}
resp, err := h.userProfileClient.AdminChangeUserCountry(request.Context(), &userv1.AdminChangeUserCountryRequest{
Meta: httpkit.UserMeta(request, ""),
TargetUserId: targetUserID,
@ -950,10 +944,10 @@ func (h *Handler) requireManagerCapability(writer http.ResponseWriter, request *
return true
}
func (h *Handler) managerBDLeaderEffectiveScope(writer http.ResponseWriter, request *http.Request) (string, bool) {
func (h *Handler) managerEffectiveScope(writer http.ResponseWriter, request *http.Request) (string, bool) {
resp, err := h.userHostClient.GetRoleScopePolicy(request.Context(), &userv1.GetRoleScopePolicyRequest{
Meta: httpkit.UserMeta(request, ""),
Scene: managerAddBDLeaderCapability,
Scene: managerOperationScopeScene,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -968,43 +962,40 @@ func (h *Handler) managerBDLeaderEffectiveScope(writer http.ResponseWriter, requ
if !policy.GetRegionExpansionConfigurable() {
return scope, true
}
expandedScope := policy.GetExpandedScope()
if expandedScope == "" {
// 兼容上一版 user-service旧协议只支持扩到同区域滚动升级期间保持旧边界而不是放大到 global。
expandedScope = managerRoleScopeRegion
}
if !validManagerRoleScope(expandedScope) {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return "", false
}
capability, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: managerAddBDLeaderInRegionCapability,
Capability: managerCrossRegionCapability,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return "", false
}
if capability.GetAllowed() {
scope = managerRoleScopeRegion
scope = expandedScope
}
return scope, true
}
func (h *Handler) requireSameCountryTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
func (h *Handler) requireManagerTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
actor, ok := h.requireManagerCenterActor(writer, request)
if !ok {
return nil, false
}
return h.requireSameCountryTargetWithActor(writer, request, actor, targetUserID)
return h.requireManagerTargetWithActor(writer, request, actor, targetUserID)
}
func (h *Handler) requireSameCountryTargetWithActor(writer http.ResponseWriter, request *http.Request, actor *userv1.User, targetUserID int64) (*userv1.User, bool) {
target, ok := h.requireActiveTarget(writer, request, targetUserID)
if !ok {
return nil, false
}
if !sameCountry(actor.GetCountry(), target.GetCountry()) {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return nil, false
}
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)
func (h *Handler) requireManagerTargetWithActor(writer http.ResponseWriter, request *http.Request, actor *userv1.User, targetUserID int64) (*userv1.User, bool) {
scope, ok := h.managerEffectiveScope(writer, request)
if !ok {
return nil, false
}
@ -1019,7 +1010,7 @@ func (h *Handler) requireManagerBDLeaderTarget(writer http.ResponseWriter, reque
return target, true
}
// requireActiveTarget 是经理写入口共同的目标用户真实性门禁;区域策略只能决定后续是否比较国家,不能绕过 active 用户校验。
// 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{
Meta: httpkit.UserMeta(request, ""),
@ -1066,30 +1057,30 @@ func managerTargetMatchesScope(scope string, actor *userv1.User, target *userv1.
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_bd_leader_in_region": 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_manage_cross_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_bd_leader_in_region": managerAddBDLeaderInRegionCapability,
"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_manage_cross_region": managerCrossRegionCapability,
"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, ""),

View File

@ -1979,6 +1979,9 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
if allowed, configured := f.capabilityAllowed[req.GetCapability()]; configured {
return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed}, nil
}
if req.GetCapability() == "manager_cross_region" {
return &userv1.CheckBusinessCapabilityResponse{Allowed: false}, nil
}
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
}
@ -1991,8 +1994,9 @@ func (f *fakeUserHostClient) GetRoleScopePolicy(_ context.Context, req *userv1.G
return f.roleScopePolicyResp, nil
}
return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
Scene: "manager_add_bd_leader",
Scene: "manager_operations",
BaseScope: "country",
ExpandedScope: "region",
RegionExpansionConfigurable: true,
}}, nil
}

View File

@ -887,6 +887,7 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
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_manage_cross_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许经理中心所有目标用户操作跨区域执行',
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 '是否允许在经理中心封禁用户',
@ -1341,6 +1342,8 @@ CREATE TABLE IF NOT EXISTS login_audit (
provider VARCHAR(64) NULL COMMENT '提供方',
channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '渠道',
platform VARCHAR(16) NOT NULL DEFAULT '' COMMENT '平台',
app_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端版本',
build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端构建号',
result VARCHAR(32) NOT NULL COMMENT '结果',
failure_code VARCHAR(64) NULL COMMENT '失败编码',
client_ip VARCHAR(64) NULL COMMENT '客户端IP',
@ -1353,6 +1356,7 @@ CREATE TABLE IF NOT EXISTS login_audit (
KEY idx_login_audit_request_id (app_code, request_id),
KEY idx_login_audit_created_at (app_code, created_at_ms),
KEY idx_login_audit_user_created (app_code, user_id, created_at_ms),
KEY idx_login_audit_latest_success (app_code, user_id, result, blocked, login_type, created_at_ms, id),
KEY idx_login_audit_blocked (app_code, blocked, created_at_ms),
KEY idx_login_audit_ip_country (app_code, ip_country_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录审计表';

View File

@ -231,24 +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
CanAddBDLeaderInRegion 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
CanManageCrossRegion bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
CreatedByAdminID int64
CreatedAtMs int64
UpdatedAtMs int64
}
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。

View File

@ -8,14 +8,16 @@ 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"
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"
CapabilityManagerCrossRegion = "manager_cross_region"
// CapabilityManagerAddBDLeaderInRegion 兼容上一版 gateway底层读取同一个通用跨区经理开关。
CapabilityManagerAddBDLeaderInRegion = "manager_add_bd_leader_in_region"
CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
@ -50,6 +52,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
CapabilityManagerGrantVIP,
CapabilityManagerUpdateUserLevel,
CapabilityManagerAddBDLeader,
CapabilityManagerCrossRegion,
CapabilityManagerAddBDLeaderInRegion,
CapabilityManagerAddAdmin,
CapabilityManagerAddSuperadmin,

View File

@ -15,24 +15,29 @@ const (
RoleScopeGlobal RoleScope = "global"
RoleScopeSceneOrganizationExpansion = "organization_role_expansion"
RoleScopeSceneManagerAddBDLeader = "manager_add_bd_leader"
RoleScopeSceneManagerOperations = "manager_operations"
// RoleScopeSceneManagerAddBDLeader 兼容上一版 gateway/admin 的策略查询,解析后统一返回 manager_operations。
RoleScopeSceneManagerAddBDLeader = "manager_add_bd_leader"
)
// RoleScopePolicy 把 App 固有边界和可运营扩展能力分开;业务入口只根据解析结果判断目标范围。
type RoleScopePolicy struct {
Scene string
BaseScope RoleScope
ExpandedScope RoleScope
RegionExpansionConfigurable bool
}
var defaultRoleScopePolicies = map[string]RoleScopePolicy{
RoleScopeSceneOrganizationExpansion: {
Scene: RoleScopeSceneOrganizationExpansion,
BaseScope: RoleScopeRegion,
Scene: RoleScopeSceneOrganizationExpansion,
BaseScope: RoleScopeRegion,
ExpandedScope: RoleScopeRegion,
},
RoleScopeSceneManagerAddBDLeader: {
Scene: RoleScopeSceneManagerAddBDLeader,
RoleScopeSceneManagerOperations: {
Scene: RoleScopeSceneManagerOperations,
BaseScope: RoleScopeCountry,
ExpandedScope: RoleScopeRegion,
RegionExpansionConfigurable: true,
},
}
@ -40,12 +45,14 @@ var defaultRoleScopePolicies = map[string]RoleScopePolicy{
var appRoleScopePolicyOverrides = map[string]map[string]RoleScopePolicy{
"huwaa": {
RoleScopeSceneOrganizationExpansion: {
Scene: RoleScopeSceneOrganizationExpansion,
BaseScope: RoleScopeGlobal,
Scene: RoleScopeSceneOrganizationExpansion,
BaseScope: RoleScopeGlobal,
ExpandedScope: RoleScopeGlobal,
},
RoleScopeSceneManagerAddBDLeader: {
Scene: RoleScopeSceneManagerAddBDLeader,
BaseScope: RoleScopeGlobal,
RoleScopeSceneManagerOperations: {
Scene: RoleScopeSceneManagerOperations,
BaseScope: RoleScopeGlobal,
ExpandedScope: RoleScopeGlobal,
},
},
}
@ -53,6 +60,9 @@ var appRoleScopePolicyOverrides = map[string]map[string]RoleScopePolicy{
// ResolveRoleScopePolicy 是 App 差异的唯一解析入口;新增 App 只扩展注册表,不修改搜索或写入 handler。
func ResolveRoleScopePolicy(rawAppCode string, rawScene string) (RoleScopePolicy, error) {
scene := strings.ToLower(strings.TrimSpace(rawScene))
if scene == RoleScopeSceneManagerAddBDLeader {
scene = RoleScopeSceneManagerOperations
}
policy, ok := defaultRoleScopePolicies[scene]
if !ok {
return RoleScopePolicy{}, xerr.New(xerr.InvalidArgument, "role scope scene is invalid")
@ -68,7 +78,7 @@ func ResolveRoleScopePolicy(rawAppCode string, rawScene string) (RoleScopePolicy
// EffectiveScope 只允许声明为 configurable 的策略接受经理个人扩区开关。
func (p RoleScopePolicy) EffectiveScope(regionExpansionEnabled bool) RoleScope {
if p.RegionExpansionConfigurable && regionExpansionEnabled {
return RoleScopeRegion
return p.ExpandedScope
}
return p.BaseScope
}

View File

@ -15,7 +15,7 @@ func TestResolveRoleScopePolicyKeepsAppDifferencesInRegistry(t *testing.T) {
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
policy, err := ResolveRoleScopePolicy(testCase.appCode, RoleScopeSceneManagerAddBDLeader)
policy, err := ResolveRoleScopePolicy(testCase.appCode, RoleScopeSceneManagerOperations)
if err != nil {
t.Fatalf("resolve policy: %v", err)
}
@ -28,3 +28,13 @@ func TestResolveRoleScopePolicyKeepsAppDifferencesInRegistry(t *testing.T) {
})
}
}
func TestResolveRoleScopePolicyKeepsLegacyManagerSceneCompatible(t *testing.T) {
policy, err := ResolveRoleScopePolicy("lalu", RoleScopeSceneManagerAddBDLeader)
if err != nil {
t.Fatalf("resolve legacy manager scene: %v", err)
}
if policy.Scene != RoleScopeSceneManagerOperations || policy.ExpandedScope != RoleScopeRegion {
t.Fatalf("legacy scene must resolve to general manager policy: %+v", policy)
}
}

View File

@ -135,12 +135,12 @@ 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)
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
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)
repository.SetManagerCrossRegion(101, true)
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
if err != nil || !allowed || reason != "" {
t.Fatalf("enabled region expansion should be allowed: allowed=%v reason=%q err=%v", allowed, reason, err)
}

View File

@ -10,6 +10,7 @@ import (
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
hostservice "hyapp/services/user-service/internal/service/host"
)
const (
@ -160,7 +161,7 @@ func (s *Service) statusResultAfterPersistence(ctx context.Context, command User
}
// CreateManagerUserBlock 创建经理封禁记录并把目标用户状态置为 banned。
// 国家和 active 状态在 service 层校验,保证任何 gateway/H5 入口都不能跨国家或重复封禁非 active 用户
// 目标范围由 App 策略与经理通用跨区开关共同决定user-service 必须复验,不能只依赖 gateway 早拒绝
func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand) (ManagerUserBlock, UserStatusResult, error) {
if s.userRepository == nil || s.moderationRepository == nil {
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "user repository is not configured")
@ -187,8 +188,20 @@ func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUse
if err != nil {
return ManagerUserBlock{}, UserStatusResult{}, err
}
if manager.Country == "" || target.Country == "" || !strings.EqualFold(manager.Country, target.Country) {
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.PermissionDenied, "target user is outside manager country")
policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), hostservice.RoleScopeSceneManagerOperations)
if err != nil {
return ManagerUserBlock{}, UserStatusResult{}, err
}
effectiveScope := policy.BaseScope
if policy.RegionExpansionConfigurable {
crossRegionEnabled, err := s.moderationRepository.ManagerCrossRegionEnabled(ctx, command.ManagerUserID)
if err != nil {
return ManagerUserBlock{}, UserStatusResult{}, err
}
effectiveScope = policy.EffectiveScope(crossRegionEnabled)
}
if !managerModerationTargetAllowed(effectiveScope, manager, target) {
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.PermissionDenied, "target user is outside manager scope")
}
if target.Status != userdomain.StatusActive {
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "target user is not active")
@ -213,6 +226,19 @@ func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUse
return block, status, nil
}
func managerModerationTargetAllowed(scope hostservice.RoleScope, manager userdomain.User, target userdomain.User) bool {
switch scope {
case hostservice.RoleScopeGlobal:
return true
case hostservice.RoleScopeRegion:
return manager.RegionID > 0 && manager.RegionID == target.RegionID
case hostservice.RoleScopeCountry:
return manager.Country != "" && target.Country != "" && strings.EqualFold(manager.Country, target.Country)
default:
return false
}
}
func (s *Service) ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error) {
if s.moderationRepository == nil {
return nil, xerr.New(xerr.Unavailable, "moderation repository is not configured")

View File

@ -103,6 +103,43 @@ func TestSetUserStatusSurfacesAccessDenylistError(t *testing.T) {
}
}
func TestCreateManagerUserBlockUsesGeneralCrossRegionScope(t *testing.T) {
for _, testCase := range []struct {
name string
appCode string
crossRegion bool
targetRegion int64
wantAllowed bool
}{
{name: "default manager denied", appCode: "lalu", targetRegion: 1, wantAllowed: false},
{name: "cross-region manager allowed inside region", appCode: "lalu", crossRegion: true, targetRegion: 1, wantAllowed: true},
{name: "cross-region manager denied outside region", appCode: "lalu", crossRegion: true, targetRegion: 2, wantAllowed: false},
{name: "huwaa globally allowed", appCode: "huwaa", targetRegion: 2, wantAllowed: true},
} {
t.Run(testCase.name, func(t *testing.T) {
repository := &fakeModerationRepository{
managerCrossRegionEnabled: testCase.crossRegion,
users: map[int64]userdomain.User{
100: {UserID: 100, Country: "US", RegionID: 1, Status: userdomain.StatusActive},
200: {UserID: 200, Country: "CA", RegionID: testCase.targetRegion, Status: userdomain.StatusActive},
},
}
svc := New(repository, WithModerationRepository(repository), WithClock(func() time.Time {
return time.UnixMilli(1_000).UTC()
}))
_, _, err := svc.CreateManagerUserBlock(appcode.WithContext(context.Background(), testCase.appCode), ManagerUserBlockCommand{
CommandID: "manager-block", ManagerUserID: 100, TargetUserID: 200, BlockedUntilMS: 2_000,
})
if testCase.wantAllowed && err != nil {
t.Fatalf("cross-region block should be allowed: %v", err)
}
if !testCase.wantAllowed && !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("out-of-scope block should be denied: %v", err)
}
})
}
}
func TestAdminBanUserAcceptsPermanentBanAndRunsRealtimeSideEffectsOnce(t *testing.T) {
repository := &fakeModerationRepository{
user: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned},
@ -232,6 +269,7 @@ func TestBatchGetUserAdminProfilesDeduplicatesAndLimitsInput(t *testing.T) {
type fakeModerationRepository struct {
user userdomain.User
users map[int64]userdomain.User
inviteReferrer userdomain.User
sessionIDs []string
countrySessionIDs []string
@ -250,6 +288,7 @@ type fakeModerationRepository struct {
adminBanPersistence AdminUserBanPersistenceResult
adminBanRelease AdminUserBanRelease
adminBanExpirations []AdminUserBanRelease
managerCrossRegionEnabled bool
}
func (r *fakeModerationRepository) SetUserStatus(_ context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error) {
@ -258,6 +297,10 @@ func (r *fakeModerationRepository) SetUserStatus(_ context.Context, command User
return UserStatusPersistenceResult{User: r.user, RevokedSessionIDs: append([]string{}, r.sessionIDs...)}, nil
}
func (r *fakeModerationRepository) ManagerCrossRegionEnabled(context.Context, int64) (bool, error) {
return r.managerCrossRegionEnabled, nil
}
func (r *fakeModerationRepository) GetInviteAttribution(context.Context, int64) (invitedomain.Attribution, error) {
return invitedomain.Attribution{}, nil
}
@ -317,7 +360,10 @@ func (r *fakeModerationRepository) ExpireAdminUserBans(context.Context, int64, i
return r.adminBanExpirations, nil
}
func (r *fakeModerationRepository) GetUser(context.Context, int64) (userdomain.User, error) {
func (r *fakeModerationRepository) GetUser(_ context.Context, userID int64) (userdomain.User, error) {
if user, ok := r.users[userID]; ok {
return user, nil
}
return r.user, nil
}

View File

@ -168,6 +168,7 @@ type DeviceRepository interface {
// ModerationRepository 持有用户状态和认证 session 的同库事务。
type ModerationRepository interface {
SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error)
ManagerCrossRegionEnabled(ctx context.Context, managerUserID int64) (bool, error)
CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand, target userdomain.User) (ManagerUserBlock, error)
ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error)
ReleaseManagerUserBlock(ctx context.Context, command ReleaseManagerUserBlockCommand) (ManagerUserBlockRelease, error)

View File

@ -373,8 +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.CapabilityManagerCrossRegion, hostservice.CapabilityManagerAddBDLeaderInRegion:
columnSQL = "can_manage_cross_region = 1"
case hostservice.CapabilityManagerAddAdmin:
columnSQL = "can_add_admin = 1"
case hostservice.CapabilityManagerAddSuperadmin:

View File

@ -13,6 +13,21 @@ import (
userservice "hyapp/services/user-service/internal/service/user"
)
// ManagerCrossRegionEnabled 读取 active 经理的通用数据范围开关;功能权限仍由 gateway/user host service 分别校验。
func (r *Repository) ManagerCrossRegionEnabled(ctx context.Context, managerUserID int64) (bool, error) {
var enabled bool
err := r.db.QueryRowContext(ctx, `
SELECT can_manage_cross_region = 1
FROM manager_profiles
WHERE app_code = ? AND user_id = ? AND status = 'active'
LIMIT 1
`, appcode.FromContext(ctx), managerUserID).Scan(&enabled)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return enabled, err
}
// SetUserStatus 原子更新用户主状态,并在非 active 状态下吊销该用户所有 active refresh session。
func (r *Repository) SetUserStatus(ctx context.Context, command userservice.UserStatusCommand) (userservice.UserStatusPersistenceResult, error) {
if r == nil || r.db == nil {

View File

@ -829,7 +829,7 @@ 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_bd_leader_in_region, 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_manage_cross_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
@ -841,7 +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_manage_cross_region = VALUES(can_manage_cross_region),
can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user),
@ -849,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), profile.CanAddBDLeaderInRegion, defaultTrue(profile.CanAddAdmin),
defaultTrue(profile.CanUpdateUserLevel), defaultTrue(profile.CanAddBDLeader), profile.CanManageCrossRegion, defaultTrue(profile.CanAddAdmin),
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
@ -857,12 +857,12 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
}
}
func (r *Repository) SetManagerAddBDLeaderInRegion(userID int64, enabled bool) {
func (r *Repository) SetManagerCrossRegion(userID int64, enabled bool) {
r.t.Helper()
_, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE manager_profiles
SET can_add_bd_leader_in_region = ?
SET can_manage_cross_region = ?
WHERE user_id = ?
`, enabled, userID)
if err != nil {

View File

@ -550,6 +550,7 @@ func (s *Server) GetRoleScopePolicy(ctx context.Context, req *userv1.GetRoleScop
return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
Scene: policy.Scene,
BaseScope: string(policy.BaseScope),
ExpandedScope: string(policy.ExpandedScope),
RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
}}, nil
}