经理权限和身份自动赠送

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

@ -136,6 +136,7 @@ type ManagerHandlers struct {
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

@ -47,5 +47,6 @@ func (h *Handler) ManagerHandlers() httproutes.ManagerHandlers {
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)
}
@ -745,20 +812,24 @@ func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bo
permissions := map[string]bool{
"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_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)