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 scene = 1;
string base_scope = 2; string base_scope = 2;
bool region_expansion_configurable = 3; bool region_expansion_configurable = 3;
// expanded_scope region
string expanded_scope = 4;
} }
message GetRoleScopePolicyRequest { 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 复用。 当前 `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 ```sql
admin_operation_logs( 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. - BD/BD Leader base is downstream host salary only.
- Leader direct Agency salary source must be de-duplicated. - Leader direct Agency salary source must be de-duplicated.
- Every admin mutation must be audited and idempotent. - 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. - 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 { type RoleScopePolicy struct {
Scene string `json:"scene"` Scene string `json:"scene"`
BaseScope string `json:"baseScope"` BaseScope string `json:"baseScope"`
ExpandedScope string `json:"expandedScope"`
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"` RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
} }
@ -846,6 +847,7 @@ func (c *GRPCClient) GetRoleScopePolicy(ctx context.Context, req GetRoleScopePol
return &RoleScopePolicy{ return &RoleScopePolicy{
Scene: policy.GetScene(), Scene: policy.GetScene(),
BaseScope: policy.GetBaseScope(), BaseScope: policy.GetBaseScope(),
ExpandedScope: policy.GetExpandedScope(),
RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(), RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(),
}, nil }, nil
} }

View File

@ -75,7 +75,7 @@ func (h *Handler) ListManagers(c *gin.Context) {
} }
response.OK(c, ManagerPageResponse{ response.OK(c, ManagerPageResponse{
Items: items, Page: query.Page, PageSize: query.PageSize, Total: total, 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 { type ManagerListItem struct {
UserID int64 `json:"userId,string"` UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"` DisplayUserID string `json:"displayUserId"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"` RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"` RegionName string `json:"regionName"`
Status string `json:"status"` Status string `json:"status"`
Contact string `json:"contact"` Contact string `json:"contact"`
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"` CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"` CanGrantVehicle bool `json:"canGrantVehicle"`
CanGrantBadge bool `json:"canGrantBadge"` CanGrantBadge bool `json:"canGrantBadge"`
CanGrantVIP bool `json:"canGrantVip"` CanGrantVIP bool `json:"canGrantVip"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"` CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"` CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddBDLeaderInRegion bool `json:"canAddBdLeaderInRegion"` CanManageCrossRegion bool `json:"canManageCrossRegion"`
BDLeaderInviteBaseScope string `json:"bdLeaderInviteBaseScope"` LegacyBDLeaderInRegion bool `json:"canAddBdLeaderInRegion"`
BDLeaderInviteEffectiveScope string `json:"bdLeaderInviteEffectiveScope"` ManagerOperationBaseScope string `json:"managerOperationBaseScope"`
BDLeaderRegionExpansionConfigurable bool `json:"bdLeaderRegionExpansionConfigurable"` ManagerOperationEffectiveScope string `json:"managerOperationEffectiveScope"`
CanAddAdmin bool `json:"canAddAdmin"` ManagerCrossRegionConfigurable bool `json:"managerCrossRegionConfigurable"`
CanAddSuperadmin bool `json:"canAddSuperadmin"` CanAddAdmin bool `json:"canAddAdmin"`
CanBlockUser bool `json:"canBlockUser"` CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanTransferCountry bool `json:"canTransferUserCountry"` CanBlockUser bool `json:"canBlockUser"`
BDLeaderCount int64 `json:"bdLeaderCount"` CanTransferCountry bool `json:"canTransferUserCountry"`
LastInvitedAtMs int64 `json:"lastInvitedAtMs"` BDLeaderCount int64 `json:"bdLeaderCount"`
CreatedAtMs int64 `json:"createdAtMs"` LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
} }
type ManagerRoleScopePolicy struct { type ManagerRoleScopePolicy struct {
BaseScope string `json:"baseScope"` BaseScope string `json:"baseScope"`
ExpandedScope string `json:"expandedScope"`
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"` RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
} }
type ManagerPageResponse struct { type ManagerPageResponse struct {
Items []*ManagerListItem `json:"items"` Items []*ManagerListItem `json:"items"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"pageSize"` PageSize int `json:"pageSize"`
Total int64 `json:"total"` Total int64 `json:"total"`
BDLeaderInviteScopePolicy ManagerRoleScopePolicy `json:"bdLeaderInviteScopePolicy"` ManagerOperationScopePolicy ManagerRoleScopePolicy `json:"managerOperationScopePolicy"`
} }
type CoinSellerSalaryRateTier struct { type CoinSellerSalaryRateTier struct {
@ -662,7 +664,7 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
if _, err := r.db.ExecContext(ctx, ` if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles ( INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge, app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_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 created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE 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_grant_vip = VALUES(can_grant_vip),
can_update_user_level = VALUES(can_update_user_level), can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader), can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_bd_leader_in_region = VALUES(can_add_bd_leader_in_region), can_manage_cross_region = VALUES(can_manage_cross_region),
can_add_admin = VALUES(can_add_admin), can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin), can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user), can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country), can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms) updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle, `, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.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 { permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
return nil, err 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")+`, COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
mp.status, COALESCE(mp.contact_info, ''), mp.status, COALESCE(mp.contact_info, ''),
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level, mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_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), COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.created_at_ms, mp.updated_at_ms mp.created_at_ms, mp.updated_at_ms
%s %s
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar, GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
manager_region.region_id, manager_region.name, mp.status, mp.contact_info, manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level, mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_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 mp.created_at_ms, mp.updated_at_ms
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@ -824,7 +826,7 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.CanGrantVIP, &item.CanGrantVIP,
&item.CanUpdateUserLevel, &item.CanUpdateUserLevel,
&item.CanAddBDLeader, &item.CanAddBDLeader,
&item.CanAddBDLeaderInRegion, &item.CanManageCrossRegion,
&item.CanAddAdmin, &item.CanAddAdmin,
&item.CanAddSuperadmin, &item.CanAddSuperadmin,
&item.CanBlockUser, &item.CanBlockUser,

View File

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

View File

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

View File

@ -30,20 +30,28 @@ func TestFlexibleInt64AcceptsStringAndNumber(t *testing.T) {
} }
} }
func TestManagerRegionExpansionDefaultsClosedAndPreservesExplicitEnable(t *testing.T) { func TestManagerCrossRegionDefaultsClosedAndAcceptsLegacyField(t *testing.T) {
var omitted createManagerRequest var omitted createManagerRequest
if err := json.Unmarshal([]byte(`{"targetUserId":"1003"}`), &omitted); err != nil { if err := json.Unmarshal([]byte(`{"targetUserId":"1003"}`), &omitted); err != nil {
t.Fatalf("unmarshal omitted permission: %v", err) t.Fatalf("unmarshal omitted permission: %v", err)
} }
if omitted.permissionsWithDefault().CanAddBDLeaderInRegion { if omitted.permissionsWithDefault().CanManageCrossRegion {
t.Fatalf("region expansion must default closed") t.Fatalf("cross-region manager must default closed")
} }
var enabled createManagerRequest 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) t.Fatalf("unmarshal enabled permission: %v", err)
} }
if !enabled.permissionsWithDefault().CanAddBDLeaderInRegion { if !enabled.permissionsWithDefault().CanManageCrossRegion {
t.Fatalf("explicit region expansion must be preserved") 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" sortByTotalRechargeUSDT = "total_recharge_usdt"
bdLeaderPositionAliasMaxRunes = 64 bdLeaderPositionAliasMaxRunes = 64
hostOrgInternalUserIDMinDigits = 15 hostOrgInternalUserIDMinDigits = 15
managerAddBDLeaderScopeScene = "manager_add_bd_leader" managerOperationScopeScene = "manager_operations"
) )
type Service struct { type Service struct {
@ -70,6 +70,7 @@ func (s *Service) ListManagers(ctx context.Context, query listQuery) ([]*Manager
decorateManagerScopePolicy(items, policy) decorateManagerScopePolicy(items, policy)
return items, total, ManagerRoleScopePolicy{ return items, total, ManagerRoleScopePolicy{
BaseScope: policy.BaseScope, BaseScope: policy.BaseScope,
ExpandedScope: policy.ExpandedScope,
RegionExpansionConfigurable: policy.RegionExpansionConfigurable, RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
}, nil }, nil
} }
@ -79,7 +80,8 @@ func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID st
if err != nil { if err != nil {
return nil, err 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()) targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil { if err != nil {
return nil, err return nil, err
@ -98,7 +100,8 @@ func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64
if err != nil { if err != nil {
return nil, err 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) item, err := s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
if err != nil { if err != nil {
return nil, err 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) { func (s *Service) managerRoleScopePolicy(ctx context.Context) (*userclient.RoleScopePolicy, error) {
return s.userClient.GetRoleScopePolicy(ctx, userclient.GetRoleScopePolicyRequest{ return s.userClient.GetRoleScopePolicy(ctx, userclient.GetRoleScopePolicyRequest{
Caller: "hyapp-admin-server", Caller: "hyapp-admin-server",
Scene: managerAddBDLeaderScopeScene, Scene: managerOperationScopeScene,
}) })
} }
// normalizedManagerRegionPermission 让 App 基础策略决定开关是否可配置global App 即使收到篡改请求也只保存 false。 // normalizedManagerCrossRegionPermission 让 App 基础策略决定开关是否可配置global App 即使收到篡改请求也只保存 false。
func normalizedManagerRegionPermission(value *bool, configurable bool) *bool { func normalizedManagerCrossRegionPermission(value *bool, configurable bool) *bool {
if configurable { if configurable {
return value return value
} }
@ -131,11 +134,12 @@ func decorateManagerScopePolicy(items []*ManagerListItem, policy *userclient.Rol
if item == nil { if item == nil {
continue continue
} }
item.BDLeaderInviteBaseScope = policy.BaseScope item.LegacyBDLeaderInRegion = item.CanManageCrossRegion
item.BDLeaderRegionExpansionConfigurable = policy.RegionExpansionConfigurable item.ManagerOperationBaseScope = policy.BaseScope
item.BDLeaderInviteEffectiveScope = policy.BaseScope item.ManagerCrossRegionConfigurable = policy.RegionExpansionConfigurable
if policy.RegionExpansionConfigurable && item.CanAddBDLeaderInRegion { item.ManagerOperationEffectiveScope = policy.BaseScope
item.BDLeaderInviteEffectiveScope = "region" 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) { func TestDecorateManagerScopePolicyUsesPolicyMetadataWithoutAppBranches(t *testing.T) {
items := []*ManagerListItem{{CanAddBDLeaderInRegion: true}} items := []*ManagerListItem{{CanManageCrossRegion: true}}
decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{ decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{
BaseScope: "country", BaseScope: "country",
ExpandedScope: "region",
RegionExpansionConfigurable: true, 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]) t.Fatalf("configurable policy decoration mismatch: %+v", items[0])
} }
items[0].CanAddBDLeaderInRegion = true items[0].CanManageCrossRegion = true
decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{ decorateManagerScopePolicy(items, &userclient.RoleScopePolicy{
BaseScope: "global", BaseScope: "global",
ExpandedScope: "global",
RegionExpansionConfigurable: false, 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]) 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") 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{ profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{ businessLookupResp: &userv1.BusinessUserLookupResponse{
Users: []*userv1.BusinessUserLookupItem{ Users: []*userv1.BusinessUserLookupItem{
@ -298,15 +298,15 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
if err := json.NewDecoder(allRecorder.Body).Decode(&allEnvelope); err != nil { if err := json.NewDecoder(allRecorder.Body).Decode(&allEnvelope); err != nil {
t.Fatalf("decode all-country response failed: %v", err) t.Fatalf("decode all-country response failed: %v", err)
} }
if allEnvelope.Data.Total != 2 || allEnvelope.Data.Items[1].UserID != "900002" { if allEnvelope.Data.Total != 1 || allEnvelope.Data.Items[0].UserID != "900001" {
t.Fatalf("country transfer search must include cross-country users: %+v", allEnvelope.Data) t.Fatalf("scope=all must not bypass manager data scope: %+v", allEnvelope.Data)
} }
if !hostClient.sawCapability("manager_transfer_user_country") { if hostClient.sawCapability("manager_transfer_user_country") {
t.Fatalf("scope=all search must check transfer-country capability, saw %#v", hostClient.capabilities) 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{ profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{ businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE}, {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
@ -319,7 +319,7 @@ func TestSearchManagerUsersUsesResolvedGlobalPolicyForHuwaaBDLeaderAction(t *tes
}, },
} }
hostClient := &fakeUserHostClient{roleScopePolicyResp: &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{ 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) router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
@ -363,12 +363,12 @@ func TestSearchManagerUsersUsesResolvedGlobalPolicyForHuwaaBDLeaderAction(t *tes
if err := json.NewDecoder(adminRecorder.Body).Decode(&adminEnvelope); err != nil { if err := json.NewDecoder(adminRecorder.Body).Decode(&adminEnvelope); err != nil {
t.Fatalf("decode Huwaa admin search response failed: %v", err) t.Fatalf("decode Huwaa admin search response failed: %v", err)
} }
if adminEnvelope.Data.Total != 1 { if adminEnvelope.Data.Total != 2 {
t.Fatalf("Huwaa non-role manager actions must remain same-country: %+v", adminEnvelope.Data) 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{ profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{ businessLookupResp: &userv1.BusinessUserLookupResponse{Users: []*userv1.BusinessUserLookupItem{
{UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE}, {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE},
@ -384,38 +384,37 @@ func TestSearchManagerUsersAppliesManagerRegionExpansionWithoutCrossingRegion(t
} }
for _, testCase := range []struct { for _, testCase := range []struct {
name string name string
regionOpen bool crossRegion bool
wantVisible int wantVisible int
}{ }{
{name: "closed keeps country", regionOpen: false, wantVisible: 1}, {name: "closed keeps country", crossRegion: false, wantVisible: 1},
{name: "open expands within region", regionOpen: true, wantVisible: 2}, {name: "open expands inside region", crossRegion: true, wantVisible: 2},
} { } {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{ hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{
"manager_add_bd_leader_in_region": testCase.regionOpen, "manager_cross_region": testCase.crossRegion,
}} }}
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient) 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) 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.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=900&scope=all&action="+action, nil)
request.Header.Set("X-Request-ID", "req-manager-region-search") request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder() request.Header.Set("X-Request-ID", "req-manager-cross-region-search")
router.ServeHTTP(recorder, request) recorder := httptest.NewRecorder()
if recorder.Code != http.StatusOK { router.ServeHTTP(recorder, request)
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) 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 { var envelope struct {
Total int `json:"total"` Data struct {
} `json:"data"` Total int `json:"total"`
} } `json:"data"`
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { }
t.Fatalf("decode response: %v", err) 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("visible total = %d, want %d", envelope.Data.Total, testCase.wantVisible) if envelope.Data.Total != testCase.wantVisible {
} t.Fatalf("%s visible total = %d, want %d", action, envelope.Data.Total, testCase.wantVisible)
if hostClient.sawCapability("manager_transfer_user_country") { }
t.Fatalf("scope=all must not override add-bd-leader policy")
} }
}) })
} }
@ -423,8 +422,8 @@ func TestSearchManagerUsersAppliesManagerRegionExpansionWithoutCrossingRegion(t
func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) { func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
walletClient := &fakeWalletClient{} walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{} hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}
profileClient := &fakeUserProfileClient{} profileClient := crossRegionManagerProfileClient()
router := newManagerCenterTestRouter(walletClient, hostClient, profileClient) router := newManagerCenterTestRouter(walletClient, hostClient, profileClient)
body := []byte(`{"command_id":"mgr-grant-1","target_user_id":"900001","resource_id":"101"}`) 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)) 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 { if walletClient.lastGetResource == nil || walletClient.lastGetResource.GetResourceId() != 202 {
t.Fatalf("resource lookup mismatch: %+v", walletClient.lastGetResource) t.Fatalf("resource lookup mismatch: %+v", walletClient.lastGetResource)
} }
if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_center" { if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_cross_region" {
t.Fatalf("last manager capability should be final same-country actor check, got %+v", hostClient.lastCapability) 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") { if !hostClient.sawCapability("manager_grant_vehicle") {
t.Fatalf("vehicle grant must check manager_grant_vehicle, saw %#v", hostClient.capabilities) 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) { func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) {
walletClient := &fakeWalletClient{} 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}`) 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 := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -707,8 +709,8 @@ func TestGrantManagerVIPUsesTrialCardForConfiguredApp(t *testing.T) {
} }
func TestManagerBlockAndUnblockCallUserService(t *testing.T) { func TestManagerBlockAndUnblockCallUserService(t *testing.T) {
profileClient := &fakeUserProfileClient{} profileClient := crossRegionManagerProfileClient()
router := newManagerCenterTestRouter(&fakeWalletClient{}, &fakeUserHostClient{}, profileClient) 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}`) 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 := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/blocks", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -741,7 +743,7 @@ func TestManagerBlockAndUnblockCallUserService(t *testing.T) {
func TestSetManagerUserLevelCallsGrowthService(t *testing.T) { func TestSetManagerUserLevelCallsGrowthService(t *testing.T) {
growthClient := &fakeGrowthLevelClient{} 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}`) 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 := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/levels", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) 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}, 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}, 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) router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, hostClient, hostAdminClient, profileClient)
body := []byte(`{"command_id":"mgr-bdleader-cross","target_user_id":"900001"}`) body := []byte(`{"command_id":"mgr-bdleader-cross","target_user_id":"900001"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body)) request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/bd-leaders", bytes.NewReader(body))
@ -825,13 +827,13 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(t *testing.T) {
} }
} }
func TestCreateManagerBDLeaderAllowsCrossCountryTargetWhenRegionExpansionEnabled(t *testing.T) { func TestCreateManagerBDLeaderAllowsSameRegionTargetForCrossRegionManager(t *testing.T) {
hostAdminClient := &fakeUserHostAdminClient{} hostAdminClient := &fakeUserHostAdminClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001}, 42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "CA", RegionId: 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) 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 := 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("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) { func TestCreateManagerBDLeaderAllowsCrossCountryTargetForHuwaa(t *testing.T) {
hostAdminClient := &fakeUserHostAdminClient{} hostAdminClient := &fakeUserHostAdminClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 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}, 900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "163006", Country: "CA", RegionId: 2002},
}} }}
hostClient := &fakeUserHostClient{roleScopePolicyResp: &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{ 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) router := newManagerCenterTestRouterWithHostAdmin(&fakeWalletClient{}, hostClient, hostAdminClient, profileClient)
body := []byte(`{"command_id":"huwaa-mgr-bdleader-cross","target_user_id":"900001"}`) body := []byte(`{"command_id":"huwaa-mgr-bdleader-cross","target_user_id":"900001"}`)
@ -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{ 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) router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
body := []byte(`{"command_id":"mgr-country-1","target_user_id":"900001","country":"CA"}`) 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)) 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 验证经理概览聚合团队工资卡片: // TestGetManagerOverviewIncludesTeamSalaryDashboard 验证经理概览聚合团队工资卡片:
// user-service 组织树 Agency owner 去重后传给 wallet-service按本月/上月两个 UTC 周期取回预收入并计算环比。 // user-service 组织树 Agency owner 去重后传给 wallet-service按本月/上月两个 UTC 周期取回预收入并计算环比。
func TestGetManagerOverviewIncludesTeamSalaryDashboard(t *testing.T) { func TestGetManagerOverviewIncludesTeamSalaryDashboard(t *testing.T) {

View File

@ -24,7 +24,7 @@ const managerGrantBadgeCapability = "manager_grant_badge"
const managerGrantVIPCapability = "manager_grant_vip" const managerGrantVIPCapability = "manager_grant_vip"
const managerUpdateUserLevelCapability = "manager_update_user_level" const managerUpdateUserLevelCapability = "manager_update_user_level"
const managerAddBDLeaderCapability = "manager_add_bd_leader" const managerAddBDLeaderCapability = "manager_add_bd_leader"
const managerAddBDLeaderInRegionCapability = "manager_add_bd_leader_in_region" const managerCrossRegionCapability = "manager_cross_region"
const managerAddAdminCapability = "manager_add_admin" const managerAddAdminCapability = "manager_add_admin"
const managerAddSuperadminCapability = "manager_add_superadmin" const managerAddSuperadminCapability = "manager_add_superadmin"
const managerBlockUserCapability = "manager_block_user" const managerBlockUserCapability = "manager_block_user"
@ -38,6 +38,7 @@ const managerGrantMaxVIPLevel int32 = 5
const managerRoleScopeCountry = "country" const managerRoleScopeCountry = "country"
const managerRoleScopeRegion = "region" const managerRoleScopeRegion = "region"
const managerRoleScopeGlobal = "global" const managerRoleScopeGlobal = "global"
const managerOperationScopeScene = "manager_operations"
type managerGrantResourceData struct { type managerGrantResourceData struct {
ResourceID string `json:"resource_id"` 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") httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return return
} }
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和等级投影做最终过滤。 // 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和区域投影做最终过滤。
// BD Leader 的 App 差异来自 user-service 策略handler 不直接判断 app_code。 // 所有经理动作的 App 差异都来自 user-service 策略handler 不直接判断 app_code。
actor, ok := h.requireManagerCenterActor(writer, request) actor, ok := h.requireManagerCenterActor(writer, request)
if !ok { if !ok {
return return
} }
scope := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("scope"))) managerTargetScope, ok := h.managerEffectiveScope(writer, request)
bdLeaderSearch := managerSearchScene(request) == managerAddBDLeaderCapability if !ok {
requestedAllCountries := scope == "all" && !bdLeaderSearch
if requestedAllCountries && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
return return
} }
managerTargetScope := managerRoleScopeCountry
if bdLeaderSearch {
managerTargetScope, ok = h.managerBDLeaderEffectiveScope(writer, request)
if !ok {
return
}
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok { if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
@ -235,8 +227,7 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
for _, userID := range userIDs { for _, userID := range userIDs {
user := usersResp.GetUsers()[userID] user := usersResp.GetUsers()[userID]
// 搜索与最终创建共用 scope evaluator避免同一 App 的规则在两个 handler 分叉。 // 搜索与最终创建共用 scope evaluator避免同一 App 的规则在两个 handler 分叉。
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || !managerTargetMatchesScope(managerTargetScope, actor, user) {
(!requestedAllCountries && !managerTargetMatchesScope(managerTargetScope, actor, user)) {
continue continue
} }
item := managerUserFromProto(user, levels[userID]) item := managerUserFromProto(user, levels[userID])
@ -391,8 +382,8 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
} }
resources = append(resources, resource) resources = append(resources, resource)
} }
// 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。 // 所有经理目标用户动作共用同一有效范围wallet-service 还会在事务内复验资源白名单。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok { if _, ok := h.requireManagerTarget(writer, request, targetUserID); !ok {
return return
} }
reason := strings.TrimSpace(body.Reason) reason := strings.TrimSpace(body.Reason)
@ -477,7 +468,7 @@ func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Requ
} }
// 经理中心先读取 App 级 program再选择直接会员或体验卡专用命令。钱包会在写事务内再次校验 // 经理中心先读取 App 级 program再选择直接会员或体验卡专用命令。钱包会在写事务内再次校验
// 因此配置在两次 RPC 之间切换时只会明确失败,不会绕过当前发放语义。 // 因此配置在两次 RPC 之间切换时只会明确失败,不会绕过当前发放语义。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok { if _, ok := h.requireManagerTarget(writer, request, targetUserID); !ok {
return return
} }
reason := strings.TrimSpace(body.Reason) 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") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
// user-service 会再次读取双方用户并校验同国家,这里保留 gateway 早拒绝以减少无效封禁事务。 // user-service 会按同一 App 策略复验gateway 先拒绝越界目标以减少无效封禁事务。
if _, ok := h.requireSameCountryTargetWithActor(writer, request, actor, targetUserID); !ok { if _, ok := h.requireManagerTargetWithActor(writer, request, actor, targetUserID); !ok {
return return
} }
resp, err := h.userProfileClient.CreateManagerUserBlock(request.Context(), &userv1.CreateManagerUserBlockRequest{ resp, err := h.userProfileClient.CreateManagerUserBlock(request.Context(), &userv1.CreateManagerUserBlockRequest{
@ -732,7 +723,7 @@ func (h *Handler) setManagerUserLevel(writer http.ResponseWriter, request *http.
return return
} }
// 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。 // 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok { if _, ok := h.requireManagerTarget(writer, request, targetUserID); !ok {
return return
} }
resp, err := h.growthLevelClient.SetUserLevel(request.Context(), &activityv1.SetUserLevelRequest{ resp, err := h.growthLevelClient.SetUserLevel(request.Context(), &activityv1.SetUserLevelRequest{
@ -783,7 +774,7 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
return return
} }
// 写入口重新解析策略并读取 active 目标,不能信任搜索结果或客户端自报范围。 // 写入口重新解析策略并读取 active 目标,不能信任搜索结果或客户端自报范围。
target, ok := h.requireManagerBDLeaderTarget(writer, request, actor, targetUserID) target, ok := h.requireManagerTargetWithActor(writer, request, actor, targetUserID)
if !ok { if !ok {
return return
} }
@ -847,7 +838,10 @@ func (h *Handler) transferManagerUserCountry(writer http.ResponseWriter, request
if reason == "" { if reason == "" {
reason = "manager_center_country_transfer" 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{ resp, err := h.userProfileClient.AdminChangeUserCountry(request.Context(), &userv1.AdminChangeUserCountryRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
TargetUserId: targetUserID, TargetUserId: targetUserID,
@ -950,10 +944,10 @@ func (h *Handler) requireManagerCapability(writer http.ResponseWriter, request *
return true 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{ resp, err := h.userHostClient.GetRoleScopePolicy(request.Context(), &userv1.GetRoleScopePolicyRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
Scene: managerAddBDLeaderCapability, Scene: managerOperationScopeScene,
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
@ -968,43 +962,40 @@ func (h *Handler) managerBDLeaderEffectiveScope(writer http.ResponseWriter, requ
if !policy.GetRegionExpansionConfigurable() { if !policy.GetRegionExpansionConfigurable() {
return scope, true 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{ capability, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()), ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: managerAddBDLeaderInRegionCapability, Capability: managerCrossRegionCapability,
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return "", false return "", false
} }
if capability.GetAllowed() { if capability.GetAllowed() {
scope = managerRoleScopeRegion scope = expandedScope
} }
return scope, true 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) actor, ok := h.requireManagerCenterActor(writer, request)
if !ok { if !ok {
return nil, false 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) { func (h *Handler) requireManagerTargetWithActor(writer http.ResponseWriter, request *http.Request, actor *userv1.User, targetUserID int64) (*userv1.User, bool) {
target, ok := h.requireActiveTarget(writer, request, targetUserID) scope, ok := h.managerEffectiveScope(writer, request)
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)
if !ok { if !ok {
return nil, false return nil, false
} }
@ -1019,7 +1010,7 @@ func (h *Handler) requireManagerBDLeaderTarget(writer http.ResponseWriter, reque
return target, true return target, true
} }
// requireActiveTarget 是经理写入口共同的目标用户真实性门禁;区域策略只能决定后续是否比较国家,不能绕过 active 用户校验。 // requireActiveTarget 是经理写入口共同的目标用户真实性门禁;范围策略只能决定后续国家/区域匹配,不能绕过 active 用户校验。
func (h *Handler) requireActiveTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) { func (h *Handler) requireActiveTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""), 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 { func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bool {
permissions := map[string]bool{ permissions := map[string]bool{
"can_grant_avatar_frame": false, "can_grant_avatar_frame": false,
"can_grant_vehicle": false, "can_grant_vehicle": false,
"can_grant_badge": false, "can_grant_badge": false,
"can_grant_vip": false, "can_grant_vip": false,
"can_update_user_level": false, "can_update_user_level": false,
"can_add_bd_leader": false, "can_add_bd_leader": false,
"can_add_bd_leader_in_region": false, "can_manage_cross_region": false,
"can_add_admin": false, "can_add_admin": false,
"can_add_superadmin": false, "can_add_superadmin": false,
"can_block_user": false, "can_block_user": false,
"can_transfer_user_country": false, "can_transfer_user_country": false,
} }
for key, capability := range map[string]string{ for key, capability := range map[string]string{
"can_grant_avatar_frame": managerGrantAvatarFrameCapability, "can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability, "can_grant_vehicle": managerGrantVehicleCapability,
"can_grant_badge": managerGrantBadgeCapability, "can_grant_badge": managerGrantBadgeCapability,
"can_grant_vip": managerGrantVIPCapability, "can_grant_vip": managerGrantVIPCapability,
"can_update_user_level": managerUpdateUserLevelCapability, "can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability, "can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_bd_leader_in_region": managerAddBDLeaderInRegionCapability, "can_manage_cross_region": managerCrossRegionCapability,
"can_add_admin": managerAddAdminCapability, "can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability, "can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability, "can_block_user": managerBlockUserCapability,
"can_transfer_user_country": managerTransferUserCountryCapability, "can_transfer_user_country": managerTransferUserCountryCapability,
} { } {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{ resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),

View File

@ -1979,6 +1979,9 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
if allowed, configured := f.capabilityAllowed[req.GetCapability()]; configured { if allowed, configured := f.capabilityAllowed[req.GetCapability()]; configured {
return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed}, nil return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed}, nil
} }
if req.GetCapability() == "manager_cross_region" {
return &userv1.CheckBusinessCapabilityResponse{Allowed: false}, nil
}
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, 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 f.roleScopePolicyResp, nil
} }
return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{ return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
Scene: "manager_add_bd_leader", Scene: "manager_operations",
BaseScope: "country", BaseScope: "country",
ExpandedScope: "region",
RegionExpansionConfigurable: true, RegionExpansionConfigurable: true,
}}, nil }}, 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_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader', can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
can_add_bd_leader_in_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许把 BD Leader 邀请范围从本国家扩展到经理所属区域', can_add_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_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin', can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户', can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',
@ -1341,6 +1342,8 @@ CREATE TABLE IF NOT EXISTS login_audit (
provider VARCHAR(64) NULL COMMENT '提供方', provider VARCHAR(64) NULL COMMENT '提供方',
channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '渠道', channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '渠道',
platform VARCHAR(16) 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 '结果', result VARCHAR(32) NOT NULL COMMENT '结果',
failure_code VARCHAR(64) NULL COMMENT '失败编码', failure_code VARCHAR(64) NULL COMMENT '失败编码',
client_ip VARCHAR(64) NULL COMMENT '客户端IP', 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_request_id (app_code, request_id),
KEY idx_login_audit_created_at (app_code, created_at_ms), 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_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_blocked (app_code, blocked, created_at_ms),
KEY idx_login_audit_ip_country (app_code, ip_country_code, 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='登录审计表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录审计表';

View File

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

View File

@ -8,14 +8,16 @@ import (
) )
const ( const (
CapabilityManagerCenter = "manager_center" CapabilityManagerCenter = "manager_center"
CapabilityManagerResourceGrant = "manager_resource_grant" CapabilityManagerResourceGrant = "manager_resource_grant"
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame" CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
CapabilityManagerGrantVehicle = "manager_grant_vehicle" CapabilityManagerGrantVehicle = "manager_grant_vehicle"
CapabilityManagerGrantBadge = "manager_grant_badge" CapabilityManagerGrantBadge = "manager_grant_badge"
CapabilityManagerGrantVIP = "manager_grant_vip" CapabilityManagerGrantVIP = "manager_grant_vip"
CapabilityManagerUpdateUserLevel = "manager_update_user_level" CapabilityManagerUpdateUserLevel = "manager_update_user_level"
CapabilityManagerAddBDLeader = "manager_add_bd_leader" CapabilityManagerAddBDLeader = "manager_add_bd_leader"
CapabilityManagerCrossRegion = "manager_cross_region"
// CapabilityManagerAddBDLeaderInRegion 兼容上一版 gateway底层读取同一个通用跨区经理开关。
CapabilityManagerAddBDLeaderInRegion = "manager_add_bd_leader_in_region" CapabilityManagerAddBDLeaderInRegion = "manager_add_bd_leader_in_region"
CapabilityManagerAddAdmin = "manager_add_admin" CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin" CapabilityManagerAddSuperadmin = "manager_add_superadmin"
@ -50,6 +52,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
CapabilityManagerGrantVIP, CapabilityManagerGrantVIP,
CapabilityManagerUpdateUserLevel, CapabilityManagerUpdateUserLevel,
CapabilityManagerAddBDLeader, CapabilityManagerAddBDLeader,
CapabilityManagerCrossRegion,
CapabilityManagerAddBDLeaderInRegion, CapabilityManagerAddBDLeaderInRegion,
CapabilityManagerAddAdmin, CapabilityManagerAddAdmin,
CapabilityManagerAddSuperadmin, CapabilityManagerAddSuperadmin,

View File

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

View File

@ -15,7 +15,7 @@ func TestResolveRoleScopePolicyKeepsAppDifferencesInRegistry(t *testing.T) {
} }
for _, testCase := range tests { for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
policy, err := ResolveRoleScopePolicy(testCase.appCode, RoleScopeSceneManagerAddBDLeader) policy, err := ResolveRoleScopePolicy(testCase.appCode, RoleScopeSceneManagerOperations)
if err != nil { if err != nil {
t.Fatalf("resolve policy: %v", err) 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) 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" { if err != nil || allowed || reason != "manager_capability_required" {
t.Fatalf("region expansion must default closed: allowed=%v reason=%q err=%v", allowed, reason, err) t.Fatalf("region expansion must default closed: allowed=%v reason=%q err=%v", allowed, reason, err)
} }
repository.SetManagerAddBDLeaderInRegion(101, true) repository.SetManagerCrossRegion(101, true)
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerAddBDLeaderInRegion) allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
if err != nil || !allowed || reason != "" { if err != nil || !allowed || reason != "" {
t.Fatalf("enabled region expansion should be allowed: allowed=%v reason=%q err=%v", allowed, reason, err) 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/idgen"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user" userdomain "hyapp/services/user-service/internal/domain/user"
hostservice "hyapp/services/user-service/internal/service/host"
) )
const ( const (
@ -160,7 +161,7 @@ func (s *Service) statusResultAfterPersistence(ctx context.Context, command User
} }
// CreateManagerUserBlock 创建经理封禁记录并把目标用户状态置为 banned。 // CreateManagerUserBlock 创建经理封禁记录并把目标用户状态置为 banned。
// 国家和 active 状态在 service 层校验,保证任何 gateway/H5 入口都不能跨国家或重复封禁非 active 用户 // 目标范围由 App 策略与经理通用跨区开关共同决定user-service 必须复验,不能只依赖 gateway 早拒绝
func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand) (ManagerUserBlock, UserStatusResult, error) { func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand) (ManagerUserBlock, UserStatusResult, error) {
if s.userRepository == nil || s.moderationRepository == nil { if s.userRepository == nil || s.moderationRepository == nil {
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "user repository is not configured") 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 { if err != nil {
return ManagerUserBlock{}, UserStatusResult{}, err return ManagerUserBlock{}, UserStatusResult{}, err
} }
if manager.Country == "" || target.Country == "" || !strings.EqualFold(manager.Country, target.Country) { policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), hostservice.RoleScopeSceneManagerOperations)
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.PermissionDenied, "target user is outside manager country") 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 { if target.Status != userdomain.StatusActive {
return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "target user is not active") 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 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) { func (s *Service) ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error) {
if s.moderationRepository == nil { if s.moderationRepository == nil {
return nil, xerr.New(xerr.Unavailable, "moderation repository is not configured") 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) { func TestAdminBanUserAcceptsPermanentBanAndRunsRealtimeSideEffectsOnce(t *testing.T) {
repository := &fakeModerationRepository{ repository := &fakeModerationRepository{
user: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned}, user: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned},
@ -232,6 +269,7 @@ func TestBatchGetUserAdminProfilesDeduplicatesAndLimitsInput(t *testing.T) {
type fakeModerationRepository struct { type fakeModerationRepository struct {
user userdomain.User user userdomain.User
users map[int64]userdomain.User
inviteReferrer userdomain.User inviteReferrer userdomain.User
sessionIDs []string sessionIDs []string
countrySessionIDs []string countrySessionIDs []string
@ -250,6 +288,7 @@ type fakeModerationRepository struct {
adminBanPersistence AdminUserBanPersistenceResult adminBanPersistence AdminUserBanPersistenceResult
adminBanRelease AdminUserBanRelease adminBanRelease AdminUserBanRelease
adminBanExpirations []AdminUserBanRelease adminBanExpirations []AdminUserBanRelease
managerCrossRegionEnabled bool
} }
func (r *fakeModerationRepository) SetUserStatus(_ context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error) { 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 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) { func (r *fakeModerationRepository) GetInviteAttribution(context.Context, int64) (invitedomain.Attribution, error) {
return invitedomain.Attribution{}, nil return invitedomain.Attribution{}, nil
} }
@ -317,7 +360,10 @@ func (r *fakeModerationRepository) ExpireAdminUserBans(context.Context, int64, i
return r.adminBanExpirations, nil 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 return r.user, nil
} }

View File

@ -168,6 +168,7 @@ type DeviceRepository interface {
// ModerationRepository 持有用户状态和认证 session 的同库事务。 // ModerationRepository 持有用户状态和认证 session 的同库事务。
type ModerationRepository interface { type ModerationRepository interface {
SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error) 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) CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand, target userdomain.User) (ManagerUserBlock, error)
ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error) ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error)
ReleaseManagerUserBlock(ctx context.Context, command ReleaseManagerUserBlockCommand) (ManagerUserBlockRelease, 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" columnSQL = "can_update_user_level = 1"
case hostservice.CapabilityManagerAddBDLeader: case hostservice.CapabilityManagerAddBDLeader:
columnSQL = "can_add_bd_leader = 1" columnSQL = "can_add_bd_leader = 1"
case hostservice.CapabilityManagerAddBDLeaderInRegion: case hostservice.CapabilityManagerCrossRegion, hostservice.CapabilityManagerAddBDLeaderInRegion:
columnSQL = "can_add_bd_leader_in_region = 1" columnSQL = "can_manage_cross_region = 1"
case hostservice.CapabilityManagerAddAdmin: case hostservice.CapabilityManagerAddAdmin:
columnSQL = "can_add_admin = 1" columnSQL = "can_add_admin = 1"
case hostservice.CapabilityManagerAddSuperadmin: case hostservice.CapabilityManagerAddSuperadmin:

View File

@ -13,6 +13,21 @@ import (
userservice "hyapp/services/user-service/internal/service/user" 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。 // SetUserStatus 原子更新用户主状态,并在非 active 状态下吊销该用户所有 active refresh session。
func (r *Repository) SetUserStatus(ctx context.Context, command userservice.UserStatusCommand) (userservice.UserStatusPersistenceResult, error) { func (r *Repository) SetUserStatus(ctx context.Context, command userservice.UserStatusCommand) (userservice.UserStatusPersistenceResult, error) {
if r == nil || r.db == nil { 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(), ` _, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO manager_profiles ( INSERT INTO manager_profiles (
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_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 created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
@ -841,7 +841,7 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
can_grant_vip = VALUES(can_grant_vip), can_grant_vip = VALUES(can_grant_vip),
can_update_user_level = VALUES(can_update_user_level), can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader), can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_bd_leader_in_region = VALUES(can_add_bd_leader_in_region), can_manage_cross_region = VALUES(can_manage_cross_region),
can_add_admin = VALUES(can_add_admin), can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin), can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user), can_block_user = VALUES(can_block_user),
@ -849,7 +849,7 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
updated_at_ms = VALUES(updated_at_ms) updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame), `, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanGrantVIP), defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanGrantVIP),
defaultTrue(profile.CanUpdateUserLevel), defaultTrue(profile.CanAddBDLeader), 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), defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs) profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil { if err != nil {
@ -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() r.t.Helper()
_, err := r.schema.DB.ExecContext(context.Background(), ` _, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE manager_profiles UPDATE manager_profiles
SET can_add_bd_leader_in_region = ? SET can_manage_cross_region = ?
WHERE user_id = ? WHERE user_id = ?
`, enabled, userID) `, enabled, userID)
if err != nil { 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{ return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
Scene: policy.Scene, Scene: policy.Scene,
BaseScope: string(policy.BaseScope), BaseScope: string(policy.BaseScope),
ExpandedScope: string(policy.ExpandedScope),
RegionExpansionConfigurable: policy.RegionExpansionConfigurable, RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
}}, nil }}, nil
} }