From 9a19267c6a5d84587fdcb3eb33d9c0fc06a628cd Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 24 Jun 2026 14:46:07 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=8F=E7=90=86=E6=9D=83=E9=99=90=E5=92=8C?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=87=AA=E5=8A=A8=E8=B5=A0=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mysql/045_manager_profiles.sql | 28 ++++- .../admin/internal/modules/hostorg/reader.go | 24 ++-- .../admin/internal/modules/hostorg/request.go | 10 ++ .../internal/client/user_client.go | 5 + .../invite_activity_reward_handler_test.go | 4 + .../transport/http/httproutes/router.go | 26 +++-- .../http/manager_center_handler_test.go | 97 +++++++++++++++- .../transport/http/managerapi/handler.go | 25 +++-- .../http/managerapi/manager_center_handler.go | 105 +++++++++++++++--- .../internal/transport/http/response_test.go | 16 +++ .../deploy/mysql/initdb/001_user_service.sql | 2 + .../user-service/internal/domain/host/host.go | 2 + .../internal/service/host/business.go | 6 +- .../internal/storage/mysql/host/queries.go | 8 +- .../internal/testutil/mysqltest/mysqltest.go | 12 +- 15 files changed, 309 insertions(+), 61 deletions(-) diff --git a/scripts/mysql/045_manager_profiles.sql b/scripts/mysql/045_manager_profiles.sql index 7e5e682b..6e27a0de 100644 --- a/scripts/mysql/045_manager_profiles.sql +++ b/scripts/mysql/045_manager_profiles.sql @@ -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) diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index 9f85918c..cb991bcb 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -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, diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index 307172a1..bbf125a5 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -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) { diff --git a/services/gateway-service/internal/client/user_client.go b/services/gateway-service/internal/client/user_client.go index f49bf736..e09e6d66 100644 --- a/services/gateway-service/internal/client/user_client.go +++ b/services/gateway-service/internal/client/user_client.go @@ -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) } diff --git a/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go index 06e932ac..51f32bb2 100644 --- a/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go +++ b/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go @@ -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 } diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 058f41db..5be2a64a 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -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) } diff --git a/services/gateway-service/internal/transport/http/manager_center_handler_test.go b/services/gateway-service/internal/transport/http/manager_center_handler_test.go index 48140b0c..3e2a11a4 100644 --- a/services/gateway-service/internal/transport/http/manager_center_handler_test.go +++ b/services/gateway-service/internal/transport/http/manager_center_handler_test.go @@ -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 } diff --git a/services/gateway-service/internal/transport/http/managerapi/handler.go b/services/gateway-service/internal/transport/http/managerapi/handler.go index ebaf4531..aa7ceceb 100644 --- a/services/gateway-service/internal/transport/http/managerapi/handler.go +++ b/services/gateway-service/internal/transport/http/managerapi/handler.go @@ -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, } } diff --git a/services/gateway-service/internal/transport/http/managerapi/manager_center_handler.go b/services/gateway-service/internal/transport/http/managerapi/manager_center_handler.go index 75815571..0a6b421a 100644 --- a/services/gateway-service/internal/transport/http/managerapi/manager_center_handler.go +++ b/services/gateway-service/internal/transport/http/managerapi/manager_center_handler.go @@ -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 } diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index c71fe49e..5207b0d6 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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 { diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 59c557b8..2bf6ad25 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -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', diff --git a/services/user-service/internal/domain/host/host.go b/services/user-service/internal/domain/host/host.go index 4155b024..953386d9 100644 --- a/services/user-service/internal/domain/host/host.go +++ b/services/user-service/internal/domain/host/host.go @@ -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 diff --git a/services/user-service/internal/service/host/business.go b/services/user-service/internal/service/host/business.go index 7da2ed30..2346c7d1 100644 --- a/services/user-service/internal/service/host/business.go +++ b/services/user-service/internal/service/host/business.go @@ -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 { diff --git a/services/user-service/internal/storage/mysql/host/queries.go b/services/user-service/internal/storage/mysql/host/queries.go index db2ff7cf..c23e2e9c 100644 --- a/services/user-service/internal/storage/mysql/host/queries.go +++ b/services/user-service/internal/storage/mysql/host/queries.go @@ -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 } diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index ea97a2b7..2702915a 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -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)