经理权限和身份自动赠送

This commit is contained in:
zhx 2026-06-24 14:46:07 +08:00
parent b97e5f0011
commit 9a19267c6a
15 changed files with 309 additions and 61 deletions

View File

@ -9,11 +9,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息',
can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送座驾',
can_grant_badge 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_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',
can_transfer_user_country TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心转移用户国家',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -48,9 +50,18 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_grant_badge') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_badge TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送徽章'' AFTER can_grant_vehicle',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_update_user_level') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心升级用户等级'' AFTER can_grant_vehicle',
'ALTER TABLE manager_profiles ADD COLUMN can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心升级用户等级'' AFTER can_grant_badge',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
@ -93,9 +104,18 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_transfer_user_country') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_transfer_user_country TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心转移用户国家'' AFTER can_block_user',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
)
SELECT
@ -110,6 +130,8 @@ SELECT
1,
1,
1,
1,
1,
0,
MIN(bl.created_at_ms),
MAX(bl.updated_at_ms)

View File

@ -53,11 +53,13 @@ type ManagerListItem struct {
Contact string `json:"contact"`
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanGrantBadge bool `json:"canGrantBadge"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddAdmin bool `json:"canAddAdmin"`
CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanBlockUser bool `json:"canBlockUser"`
CanTransferCountry bool `json:"canTransferUserCountry"`
BDLeaderCount int64 `json:"bdLeaderCount"`
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
@ -600,24 +602,26 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
// 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。
if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = 'active',
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_grant_badge = VALUES(can_grant_badge),
can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, actorID, nowMs, nowMs); err != nil {
permissions.CanGrantBadge, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
return nil, err
}
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
@ -716,15 +720,15 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
mp.status, COALESCE(mp.contact_info, ''),
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.created_at_ms, mp.updated_at_ms
%s
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
mp.created_at_ms, mp.updated_at_ms
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
LIMIT ? OFFSET ?
@ -748,11 +752,13 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.Contact,
&item.CanGrantAvatarFrame,
&item.CanGrantVehicle,
&item.CanGrantBadge,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddAdmin,
&item.CanAddSuperadmin,
&item.CanBlockUser,
&item.CanTransferCountry,
&item.BDLeaderCount,
&item.LastInvitedAtMs,
&item.CreatedAtMs,

View File

@ -65,54 +65,64 @@ type createManagerRequest struct {
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
CanTransferCountry *bool `json:"canTransferUserCountry"`
}
type updateManagerRequest struct {
Contact *string `json:"contact"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
CanTransferCountry *bool `json:"canTransferUserCountry"`
}
type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
}
func (r createManagerRequest) permissionsWithDefault() managerPermissions {
return managerPermissions{
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
CanTransferCountry: boolDefaultTrue(r.CanTransferCountry),
}
}
func (r updateManagerRequest) appendPermissionAssignments(assignments *[]string, args *[]any) {
appendBoolAssignment(assignments, args, "can_grant_avatar_frame", r.CanGrantAvatarFrame)
appendBoolAssignment(assignments, args, "can_grant_vehicle", r.CanGrantVehicle)
appendBoolAssignment(assignments, args, "can_grant_badge", r.CanGrantBadge)
appendBoolAssignment(assignments, args, "can_update_user_level", r.CanUpdateUserLevel)
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
appendBoolAssignment(assignments, args, "can_add_superadmin", r.CanAddSuperadmin)
appendBoolAssignment(assignments, args, "can_block_user", r.CanBlockUser)
appendBoolAssignment(assignments, args, "can_transfer_user_country", r.CanTransferCountry)
}
func appendBoolAssignment(assignments *[]string, args *[]any, column string, value *bool) {

View File

@ -41,6 +41,7 @@ type UserProfileClient interface {
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error)
ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
CreateManagerUserBlock(ctx context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error)
ListManagerUserBlocks(ctx context.Context, req *userv1.ListManagerUserBlocksRequest) (*userv1.ListManagerUserBlocksResponse, error)
UnblockManagerUser(ctx context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error)
@ -338,6 +339,10 @@ func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *user
return c.client.ChangeUserCountry(ctx, req)
}
func (c *grpcUserProfileClient) AdminChangeUserCountry(ctx context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return c.client.AdminChangeUserCountry(ctx, req)
}
func (c *grpcUserProfileClient) CreateManagerUserBlock(ctx context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error) {
return c.client.CreateManagerUserBlock(ctx, req)
}

View File

@ -141,6 +141,10 @@ func (f *fakeInviteActivityUserProfileClient) ChangeUserCountry(context.Context,
return &userv1.ChangeUserCountryResponse{}, nil
}
func (f *fakeInviteActivityUserProfileClient) AdminChangeUserCountry(context.Context, *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return &userv1.ChangeUserCountryResponse{}, nil
}
func (f *fakeInviteActivityUserProfileClient) CreateManagerUserBlock(context.Context, *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error) {
return &userv1.CreateManagerUserBlockResponse{}, nil
}

View File

@ -124,18 +124,19 @@ type UserHandlers struct {
}
type ManagerHandlers struct {
ListManagerGrantResources http.HandlerFunc
GrantManagerResource http.HandlerFunc
LookupBusinessUsers http.HandlerFunc
GetManagerOverview http.HandlerFunc
SearchManagerUsers http.HandlerFunc
GrantManagerVIP http.HandlerFunc
ManagerBlocks http.HandlerFunc
ListManagerBlocks http.HandlerFunc
CreateManagerBlock http.HandlerFunc
UnblockManagerBlock http.HandlerFunc
SetManagerUserLevel http.HandlerFunc
CreateManagerBDLeader http.HandlerFunc
ListManagerGrantResources http.HandlerFunc
GrantManagerResource http.HandlerFunc
LookupBusinessUsers http.HandlerFunc
GetManagerOverview http.HandlerFunc
SearchManagerUsers http.HandlerFunc
GrantManagerVIP http.HandlerFunc
ManagerBlocks http.HandlerFunc
ListManagerBlocks http.HandlerFunc
CreateManagerBlock http.HandlerFunc
UnblockManagerBlock http.HandlerFunc
SetManagerUserLevel http.HandlerFunc
CreateManagerBDLeader http.HandlerFunc
TransferManagerUserCountry http.HandlerFunc
}
type RoomHandlers struct {
@ -462,6 +463,7 @@ func (r routes) registerManagerRoutes() {
r.profile("/manager-center/blocks/{block_id}/unblock", http.MethodPost, h.UnblockManagerBlock)
r.profile("/manager-center/levels", http.MethodPost, h.SetManagerUserLevel)
r.profile("/manager-center/bd-leaders", http.MethodPost, h.CreateManagerBDLeader)
r.profile("/manager-center/users/country", http.MethodPost, h.TransferManagerUserCountry)
r.profile("/business/users/lookup", http.MethodGet, h.LookupBusinessUsers)
}

View File

@ -68,7 +68,7 @@ func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *tes
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 || hostClient.lastCapability.GetCapability() != "manager_resource_grant" {
if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 || hostClient.lastCapability.GetCapability() != "manager_grant_badge" {
t.Fatalf("manager capability request mismatch: %+v", hostClient.lastCapability)
}
if walletClient.lastListResources == nil {
@ -160,6 +160,75 @@ func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) {
}
}
func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *testing.T) {
profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{
Users: []*userv1.BusinessUserLookupItem{
{UserId: 900001, DisplayUserId: "163006", Status: userv1.UserStatus_USER_STATUS_ACTIVE},
{UserId: 900002, DisplayUserId: "163007", Status: userv1.UserStatus_USER_STATUS_ACTIVE},
},
},
usersByID: map[int64]*userv1.User{
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US"},
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "163006", Country: "US"},
900002: {UserId: 900002, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "163007", Country: "CA"},
},
}
hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
sameCountryReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20", nil)
sameCountryReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
sameCountryReq.Header.Set("X-Request-ID", "req-manager-search-same")
sameCountryRecorder := httptest.NewRecorder()
router.ServeHTTP(sameCountryRecorder, sameCountryReq)
if sameCountryRecorder.Code != http.StatusOK {
t.Fatalf("same-country search status mismatch: got %d body=%s", sameCountryRecorder.Code, sameCountryRecorder.Body.String())
}
var sameEnvelope struct {
Data struct {
Items []struct {
UserID string `json:"user_id"`
} `json:"items"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.NewDecoder(sameCountryRecorder.Body).Decode(&sameEnvelope); err != nil {
t.Fatalf("decode same-country response failed: %v", err)
}
if sameEnvelope.Data.Total != 1 || sameEnvelope.Data.Items[0].UserID != "900001" {
t.Fatalf("same-country search must filter cross-country users: %+v", sameEnvelope.Data)
}
allReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&scope=all", nil)
allReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
allReq.Header.Set("X-Request-ID", "req-manager-search-all")
allRecorder := httptest.NewRecorder()
router.ServeHTTP(allRecorder, allReq)
if allRecorder.Code != http.StatusOK {
t.Fatalf("all-country search status mismatch: got %d body=%s", allRecorder.Code, allRecorder.Body.String())
}
var allEnvelope struct {
Data struct {
Items []struct {
UserID string `json:"user_id"`
} `json:"items"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.NewDecoder(allRecorder.Body).Decode(&allEnvelope); err != nil {
t.Fatalf("decode all-country response failed: %v", err)
}
if allEnvelope.Data.Total != 2 || allEnvelope.Data.Items[1].UserID != "900002" {
t.Fatalf("country transfer search must include cross-country users: %+v", allEnvelope.Data)
}
if !hostClient.sawCapability("manager_transfer_user_country") {
t.Fatalf("scope=all search must check transfer-country capability, saw %#v", hostClient.capabilities)
}
}
func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{}
@ -441,6 +510,32 @@ func TestCreateManagerBDLeaderRejectsCrossCountryTarget(t *testing.T) {
}
}
func TestTransferManagerUserCountryUsesAdminCountryChangeWithoutSameCountryFilter(t *testing.T) {
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "US", RegionId: 1001},
}}
hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
body := []byte(`{"command_id":"mgr-country-1","target_user_id":"900001","country":"CA"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/users/country", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-country")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
generatedRequestID := assertGeneratedRequestID(t, recorder, "req-manager-country")
if req := profileClient.lastAdminCountry; req == nil || req.GetMeta().GetRequestId() != generatedRequestID || req.GetAdminUserId() != 42 || req.GetTargetUserId() != 900001 || req.GetCountry() != "CA" || req.GetReason() != "manager_center_country_transfer" {
t.Fatalf("AdminChangeUserCountry request mismatch: %+v", req)
}
if !hostClient.sawCapability("manager_transfer_user_country") {
t.Fatalf("country transfer must check manager_transfer_user_country, saw %#v", hostClient.capabilities)
}
}
type fakeGrowthLevelClient struct {
lastSet *activityv1.SetUserLevelRequest
}

View File

@ -35,17 +35,18 @@ func New(config Config) *Handler {
func (h *Handler) ManagerHandlers() httproutes.ManagerHandlers {
return httproutes.ManagerHandlers{
ListManagerGrantResources: h.listManagerGrantResources,
GrantManagerResource: h.grantManagerResource,
LookupBusinessUsers: h.lookupBusinessUsers,
GetManagerOverview: h.getManagerOverview,
SearchManagerUsers: h.searchManagerUsers,
GrantManagerVIP: h.grantManagerVIP,
ManagerBlocks: h.managerBlocks,
ListManagerBlocks: h.listManagerBlocks,
CreateManagerBlock: h.createManagerBlock,
UnblockManagerBlock: h.unblockManagerBlock,
SetManagerUserLevel: h.setManagerUserLevel,
CreateManagerBDLeader: h.createManagerBDLeader,
ListManagerGrantResources: h.listManagerGrantResources,
GrantManagerResource: h.grantManagerResource,
LookupBusinessUsers: h.lookupBusinessUsers,
GetManagerOverview: h.getManagerOverview,
SearchManagerUsers: h.searchManagerUsers,
GrantManagerVIP: h.grantManagerVIP,
ManagerBlocks: h.managerBlocks,
ListManagerBlocks: h.listManagerBlocks,
CreateManagerBlock: h.createManagerBlock,
UnblockManagerBlock: h.unblockManagerBlock,
SetManagerUserLevel: h.setManagerUserLevel,
CreateManagerBDLeader: h.createManagerBDLeader,
TransferManagerUserCountry: h.transferManagerUserCountry,
}
}

View File

@ -20,11 +20,13 @@ const managerCenterCapability = "manager_center"
const managerResourceGrantCapability = "manager_resource_grant"
const managerGrantAvatarFrameCapability = "manager_grant_avatar_frame"
const managerGrantVehicleCapability = "manager_grant_vehicle"
const managerGrantBadgeCapability = "manager_grant_badge"
const managerUpdateUserLevelCapability = "manager_update_user_level"
const managerAddBDLeaderCapability = "manager_add_bd_leader"
const managerAddAdminCapability = "manager_add_admin"
const managerAddSuperadminCapability = "manager_add_superadmin"
const managerBlockUserCapability = "manager_block_user"
const managerTransferUserCountryCapability = "manager_transfer_user_country"
const managerGrantDayMS int64 = 24 * 60 * 60 * 1000
const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
@ -146,10 +148,16 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
return
}
// 搜索先走已有 business lookup 保持短号/昵称解析一致,再用 BatchGetUsers 拿国家和等级投影做最终过滤。
// 只有国家转移入口显式带 scope=all 且经理拥有对应开关时,才允许跨国家搜索;其他动作继续默认同国家。
actor, ok := h.requireManagerCenterActor(writer, request)
if !ok {
return
}
scope := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("scope")))
allCountries := scope == "all"
if allCountries && !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
@ -193,8 +201,8 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
items := make([]managerUserData, 0, len(userIDs))
for _, userID := range userIDs {
user := usersResp.GetUsers()[userID]
// 同国家限制必须放在 gateway 写入口和读入口都做,避免 H5 通过短号枚举跨国家用户
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || !sameCountry(actor.GetCountry(), user.GetCountry()) {
// 默认搜索必须和写入口一样收敛到同国家;国家转移已单独校验权限,允许在这里跨国家挑选目标
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || (!allCountries && !sameCountry(actor.GetCountry(), user.GetCountry())) {
continue
}
items = append(items, managerUserFromProto(user, levels[userID]))
@ -671,6 +679,65 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
})
}
func (h *Handler) transferManagerUserCountry(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil || h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
TargetUserID json.RawMessage `json:"target_user_id"`
TargetUserIDAlt json.RawMessage `json:"targetUserId"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
CountryCodeAlt string `json:"countryCode"`
Reason string `json:"reason"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
actor, ok := h.requireManagerCenterActor(writer, request)
if !ok {
return
}
if !h.requireManagerCapability(writer, request, managerTransferUserCountryCapability) {
return
}
targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt)
country := strings.ToUpper(strings.TrimSpace(body.Country))
if country == "" {
country = strings.ToUpper(strings.TrimSpace(body.CountryCode))
}
if country == "" {
country = strings.ToUpper(strings.TrimSpace(body.CountryCodeAlt))
}
if !targetOK || targetUserID <= 0 || country == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "manager_center_country_transfer"
}
// 国家转移是治理动作,不复用同国家目标校验;权限由 manager_profiles 独立开关控制,实际国家/区域事务由 user-service 统一落库和发 outbox。
resp, err := h.userProfileClient.AdminChangeUserCountry(request.Context(), &userv1.AdminChangeUserCountryRequest{
Meta: httpkit.UserMeta(request, ""),
TargetUserId: targetUserID,
Country: country,
AdminUserId: actor.GetUserId(),
Reason: reason,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"user": managerUserFromProto(resp.GetUser(), nil),
"old_region_id": resp.GetOldRegionId(),
"new_region_id": resp.GetNewRegionId(),
"region_changed": resp.GetRegionChanged(),
})
}
func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool {
return h.requireManagerCapability(writer, request, managerResourceGrantCapability)
}
@ -743,22 +810,26 @@ func (h *Handler) requireSameCountryTargetWithActor(writer http.ResponseWriter,
func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bool {
permissions := map[string]bool{
"can_grant_avatar_frame": false,
"can_grant_vehicle": false,
"can_update_user_level": false,
"can_add_bd_leader": false,
"can_add_admin": false,
"can_add_superadmin": false,
"can_block_user": false,
"can_grant_avatar_frame": false,
"can_grant_vehicle": false,
"can_grant_badge": false,
"can_update_user_level": false,
"can_add_bd_leader": false,
"can_add_admin": false,
"can_add_superadmin": false,
"can_block_user": false,
"can_transfer_user_country": false,
}
for key, capability := range map[string]string{
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability,
"can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability,
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability,
"can_grant_badge": managerGrantBadgeCapability,
"can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_admin": managerAddAdminCapability,
"can_add_superadmin": managerAddSuperadminCapability,
"can_block_user": managerBlockUserCapability,
"can_transfer_user_country": managerTransferUserCountryCapability,
} {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""),
@ -781,6 +852,8 @@ func managerCapabilityForResourceType(resourceType string) string {
return managerGrantAvatarFrameCapability
case "vehicle":
return managerGrantVehicleCapability
case "badge":
return managerGrantBadgeCapability
default:
return managerResourceGrantCapability
}

View File

@ -314,6 +314,7 @@ type fakeUserProfileClient struct {
lastUpdate *userv1.UpdateUserProfileRequest
lastBackground *userv1.UpdateUserProfileBackgroundRequest
lastCountry *userv1.ChangeUserCountryRequest
lastAdminCountry *userv1.AdminChangeUserCountryRequest
lastCreateBlock *userv1.CreateManagerUserBlockRequest
lastListBlocks *userv1.ListManagerUserBlocksRequest
lastUnblock *userv1.UnblockManagerUserRequest
@ -865,6 +866,21 @@ func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1
}, NextChangeAllowedAtMs: 3600000, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil
}
func (f *fakeUserProfileClient) AdminChangeUserCountry(_ context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
f.lastAdminCountry = req
if f.countryErr != nil {
return nil, f.countryErr
}
return &userv1.ChangeUserCountryResponse{User: &userv1.User{
UserId: req.GetTargetUserId(),
DisplayUserId: "100001",
Country: req.GetCountry(),
RegionId: 2002,
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
UpdatedAtMs: 3000,
}, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil
}
func (f *fakeUserProfileClient) CreateManagerUserBlock(_ context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error) {
f.lastCreateBlock = req
if f.blockErr != nil {

View File

@ -772,11 +772,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息',
can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送座驾',
can_grant_badge 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_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Superadmin',
can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心封禁用户',
can_transfer_user_country TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心转移用户国家',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -195,11 +195,13 @@ type ManagerProfile struct {
Contact string
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CanTransferCountry bool
CreatedByAdminID int64
CreatedAtMs int64
UpdatedAtMs int64

View File

@ -12,11 +12,13 @@ const (
CapabilityManagerResourceGrant = "manager_resource_grant"
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
CapabilityManagerGrantBadge = "manager_grant_badge"
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
CapabilityManagerBlockUser = "manager_block_user"
CapabilityManagerTransferCountry = "manager_transfer_user_country"
)
// CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。
@ -42,11 +44,13 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
case CapabilityManagerResourceGrant,
CapabilityManagerGrantAvatarFrame,
CapabilityManagerGrantVehicle,
CapabilityManagerGrantBadge,
CapabilityManagerUpdateUserLevel,
CapabilityManagerAddBDLeader,
CapabilityManagerAddAdmin,
CapabilityManagerAddSuperadmin,
CapabilityManagerBlockUser:
CapabilityManagerBlockUser,
CapabilityManagerTransferCountry:
// 细分能力必须同时满足 active 经理身份和对应权限开关;这样 H5 被篡改后直调写接口也会被 user-service 拒绝。
ok, err := s.repository.HasActiveManagerCapability(ctx, actorUserID, capability)
if err != nil {

View File

@ -317,16 +317,18 @@ func (r *Repository) HasActiveManagerProfile(ctx context.Context, userID int64)
}
// HasActiveManagerCapability 精确校验经理中心单项能力。
// 这里把 legacy 的 manager_resource_grant 收敛成“头像框或座驾任一资源赠送权限,写入口仍会按真实资源类型再校验一次。
// 这里把 legacy 的 manager_resource_grant 收敛成任一资源赠送权限,写入口仍会按真实资源类型再校验一次。
func (r *Repository) HasActiveManagerCapability(ctx context.Context, userID int64, capability string) (bool, error) {
columnSQL := ""
switch capability {
case hostservice.CapabilityManagerResourceGrant:
columnSQL = "(can_grant_avatar_frame = 1 OR can_grant_vehicle = 1)"
columnSQL = "(can_grant_avatar_frame = 1 OR can_grant_vehicle = 1 OR can_grant_badge = 1)"
case hostservice.CapabilityManagerGrantAvatarFrame:
columnSQL = "can_grant_avatar_frame = 1"
case hostservice.CapabilityManagerGrantVehicle:
columnSQL = "can_grant_vehicle = 1"
case hostservice.CapabilityManagerGrantBadge:
columnSQL = "can_grant_badge = 1"
case hostservice.CapabilityManagerUpdateUserLevel:
columnSQL = "can_update_user_level = 1"
case hostservice.CapabilityManagerAddBDLeader:
@ -337,6 +339,8 @@ func (r *Repository) HasActiveManagerCapability(ctx context.Context, userID int6
columnSQL = "can_add_superadmin = 1"
case hostservice.CapabilityManagerBlockUser:
columnSQL = "can_block_user = 1"
case hostservice.CapabilityManagerTransferCountry:
columnSQL = "can_transfer_user_country = 1"
default:
return false, nil
}

View File

@ -766,25 +766,27 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO manager_profiles (
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_grant_badge = VALUES(can_grant_badge),
can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanUpdateUserLevel),
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanUpdateUserLevel),
defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser),
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed manager profile %d failed: %v", profile.UserID, err)