经理相关

This commit is contained in:
zhx 2026-06-18 18:04:33 +08:00
parent 9c056b9cbf
commit c8fc1ede01
23 changed files with 3221 additions and 2386 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1948,6 +1948,18 @@ message WheelDrawResult {
string wallet_transaction_id = 16; string wallet_transaction_id = 16;
int64 coin_balance_after = 17; int64 coin_balance_after = 17;
string metadata_json = 18; string metadata_json = 18;
repeated WheelDrawReward rewards = 19;
}
message WheelDrawReward {
string selected_tier_id = 1;
string reward_type = 2;
string reward_id = 3;
int64 reward_count = 4;
int64 reward_coins = 5;
int64 rtp_value_coins = 6;
string reward_status = 7;
string metadata_json = 8;
} }
message ExecuteWheelDrawRequest { message ExecuteWheelDrawRequest {

View File

@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID', user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息', 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_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 '是否允许在经理中心封禁用户',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID', created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -23,14 +30,86 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt; EXECUTE stmt;
DEALLOCATE PREPARE 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_avatar_frame') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送头像框'' AFTER contact_info',
'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_grant_vehicle') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送座驾'' AFTER can_grant_avatar_frame',
'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',
'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_add_bd_leader') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 BD Leader'' AFTER can_update_user_level',
'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_add_admin') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 Admin'' AFTER can_add_bd_leader',
'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_add_superadmin') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_superadmin TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 Superadmin'' AFTER can_add_admin',
'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_block_user') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_block_user TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心封禁用户'' AFTER can_add_superadmin',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO manager_profiles ( INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms 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,
created_by_admin_id, created_at_ms, updated_at_ms
) )
SELECT SELECT
bl.app_code, bl.app_code,
bl.created_by_user_id, bl.created_by_user_id,
'active', 'active',
'', '',
1,
1,
1,
1,
1,
1,
1,
0, 0,
MIN(bl.created_at_ms), MIN(bl.created_at_ms),
MAX(bl.updated_at_ms) MAX(bl.updated_at_ms)

View File

@ -92,6 +92,26 @@ func (h *Handler) CreateManager(c *gin.Context) {
response.Created(c, item) response.Created(c, item)
} }
func (h *Handler) UpdateManager(c *gin.Context) {
userID, ok := parseInt64ID(c, "user_id")
if !ok {
return
}
var req updateManagerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "经理参数不正确")
return
}
item, err := h.service.UpdateManager(c.Request.Context(), adminActorID(c), userID, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "update-manager", "manager_profiles", item.UserID,
fmt.Sprintf("user_id=%d permissions_updated=true", item.UserID))
response.OK(c, item)
}
func (h *Handler) ListHosts(c *gin.Context) { func (h *Handler) ListHosts(c *gin.Context) {
query, ok := parseListQuery(c) query, ok := parseListQuery(c)
if !ok { if !ok {

View File

@ -51,6 +51,13 @@ type ManagerListItem struct {
RegionName string `json:"regionName"` RegionName string `json:"regionName"`
Status string `json:"status"` Status string `json:"status"`
Contact string `json:"contact"` Contact string `json:"contact"`
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddAdmin bool `json:"canAddAdmin"`
CanAddSuperadmin bool `json:"canAddSuperadmin"`
CanBlockUser bool `json:"canBlockUser"`
BDLeaderCount int64 `json:"bdLeaderCount"` BDLeaderCount int64 `json:"bdLeaderCount"`
LastInvitedAtMs int64 `json:"lastInvitedAtMs"` LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
@ -575,30 +582,42 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.
// CreateManagerProfile 创建或恢复经理身份。 // CreateManagerProfile 创建或恢复经理身份。
// Manager 是 manager_profiles 表里的独立身份;这里只写经理事实和联系方式,不改 Host/Agency/BD 任一关系。 // Manager 是 manager_profiles 表里的独立身份;这里只写经理事实和联系方式,不改 Host/Agency/BD 任一关系。
func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, contact string) (*ManagerListItem, error) { func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, req createManagerRequest) (*ManagerListItem, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, errUserDBNotConfigured() return nil, errUserDBNotConfigured()
} }
if userID <= 0 { if userID <= 0 {
return nil, fmt.Errorf("target_user_id is required") return nil, fmt.Errorf("target_user_id is required")
} }
contact = strings.TrimSpace(contact) contact := strings.TrimSpace(req.Contact)
if len([]rune(contact)) > 128 { if len([]rune(contact)) > 128 {
return nil, fmt.Errorf("contact is too long") return nil, fmt.Errorf("contact is too long")
} }
permissions := req.permissionsWithDefault()
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
appCode := appctx.FromContext(ctx) appCode := appctx.FromContext(ctx)
// ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态, // ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态,
// 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。 // 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。
if _, err := r.db.ExecContext(ctx, ` if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles ( INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
) VALUES (?, ?, 'active', ?, ?, ?, ?) can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
status = 'active', status = 'active',
contact_info = VALUES(contact_info), contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
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),
updated_at_ms = VALUES(updated_at_ms) updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, contact, actorID, nowMs, nowMs); err != nil { `, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, actorID, nowMs, nowMs); err != nil {
return nil, err return nil, err
} }
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1}) items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
@ -611,6 +630,54 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
return items[0], nil return items[0], nil
} }
// UpdateManagerProfile 局部更新经理运营资料和经理中心权限。
// 未传入的布尔权限不参与 SET避免后台只编辑联系方式时把现有权限误恢复成默认值。
func (r *Reader) UpdateManagerProfile(ctx context.Context, userID int64, actorID int64, req updateManagerRequest) (*ManagerListItem, error) {
if r == nil || r.db == nil {
return nil, errUserDBNotConfigured()
}
if userID <= 0 {
return nil, fmt.Errorf("manager user_id is required")
}
assignments := []string{"updated_at_ms = ?"}
args := []any{time.Now().UnixMilli()}
if req.Contact != nil {
contact := strings.TrimSpace(*req.Contact)
if len([]rune(contact)) > 128 {
return nil, fmt.Errorf("contact is too long")
}
assignments = append(assignments, "contact_info = ?")
args = append(args, contact)
}
req.appendPermissionAssignments(&assignments, &args)
args = append(args, appctx.FromContext(ctx), userID)
// 权限更新只作用于已存在经理RowsAffected=0 时返回 not found避免 PUT 顺手创建新经理绕过创建审计。
result, err := r.db.ExecContext(ctx, `
UPDATE manager_profiles
SET `+strings.Join(assignments, ", ")+`
WHERE app_code = ? AND user_id = ?
`, args...)
if err != nil {
return nil, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, fmt.Errorf("manager not found")
}
_ = actorID
items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("manager not found")
}
return items[0], nil
}
// ListManagers 按 manager_profiles 的独立身份事实读取经理列表。 // ListManagers 按 manager_profiles 的独立身份事实读取经理列表。
// 经理是 BD Leader/Admin 的上级,不属于 Host/Agency/BD 链路;下级 BD Leader 只作为统计信息左连汇总。 // 经理是 BD Leader/Admin 的上级,不属于 Host/Agency/BD 链路;下级 BD Leader 只作为统计信息左连汇总。
func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) { func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) {
@ -648,11 +715,17 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''), SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`, COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
mp.status, COALESCE(mp.contact_info, ''), COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0), 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,
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.created_at_ms, mp.updated_at_ms mp.created_at_ms, mp.updated_at_ms
%s %s
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar, GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
manager_region.region_id, manager_region.name, mp.status, mp.contact_info, mp.created_at_ms, mp.updated_at_ms 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.created_at_ms, mp.updated_at_ms
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
@ -673,6 +746,13 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.RegionName, &item.RegionName,
&item.Status, &item.Status,
&item.Contact, &item.Contact,
&item.CanGrantAvatarFrame,
&item.CanGrantVehicle,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddAdmin,
&item.CanAddSuperadmin,
&item.CanBlockUser,
&item.BDLeaderCount, &item.BDLeaderCount,
&item.LastInvitedAtMs, &item.LastInvitedAtMs,
&item.CreatedAtMs, &item.CreatedAtMs,

View File

@ -63,6 +63,68 @@ type createBDLeaderRequest struct {
type createManagerRequest struct { type createManagerRequest struct {
Contact string `json:"contact"` Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"` TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
}
type updateManagerRequest struct {
Contact *string `json:"contact"`
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
CanAddSuperadmin *bool `json:"canAddSuperadmin"`
CanBlockUser *bool `json:"canBlockUser"`
}
type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
}
func (r createManagerRequest) permissionsWithDefault() managerPermissions {
return managerPermissions{
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
CanAddSuperadmin: boolDefaultTrue(r.CanAddSuperadmin),
CanBlockUser: boolDefaultTrue(r.CanBlockUser),
}
}
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_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)
}
func appendBoolAssignment(assignments *[]string, args *[]any, column string, value *bool) {
if value == nil {
return
}
*assignments = append(*assignments, column+" = ?")
*args = append(*args, *value)
}
func boolDefaultTrue(value *bool) bool {
return value == nil || *value
} }
type createBDRequest struct { type createBDRequest struct {

View File

@ -17,6 +17,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus) protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus)
protected.GET("/admin/managers", middleware.RequirePermission("bd:view"), h.ListManagers) protected.GET("/admin/managers", middleware.RequirePermission("bd:view"), h.ListManagers)
protected.POST("/admin/managers", middleware.RequirePermission("bd:create"), h.CreateManager) protected.POST("/admin/managers", middleware.RequirePermission("bd:create"), h.CreateManager)
protected.PUT("/admin/managers/:user_id", middleware.RequirePermission("bd:update"), h.UpdateManager)
protected.GET("/admin/agencies", middleware.RequirePermission("agency:view"), h.ListAgencies) protected.GET("/admin/agencies", middleware.RequirePermission("agency:view"), h.ListAgencies)
protected.POST("/admin/agencies", middleware.RequirePermission("agency:create"), h.CreateAgency) protected.POST("/admin/agencies", middleware.RequirePermission("agency:create"), h.CreateAgency)
protected.POST("/admin/agencies/:agency_id/close", middleware.RequirePermission("agency:status"), h.CloseAgency) protected.POST("/admin/agencies/:agency_id/close", middleware.RequirePermission("agency:status"), h.CloseAgency)

View File

@ -60,8 +60,12 @@ func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID st
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 经理身份只落 manager_profiles 表;联系方式作为后台运营资料保存,不参与经理中心授权。 // 经理身份只落 manager_profiles 表;权限开关同时写入 manager_profiles供 H5 写入口逐项校验。
return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req.Contact) return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req)
}
func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64, req updateManagerRequest) (*ManagerListItem, error) {
return s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
} }
func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) { func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) {

View File

@ -26,6 +26,8 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
Username: "Kitty", Username: "Kitty",
Status: "active", Status: "active",
Contact: "+63", Contact: "+63",
CanGrantVehicle: true,
CanBlockUser: false,
BDLeaderCount: 3, BDLeaderCount: 3,
LastInvitedAtMs: 1720000000000, LastInvitedAtMs: 1720000000000,
} }
@ -38,6 +40,8 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
`"userId":"316033326332776448"`, `"userId":"316033326332776448"`,
`"displayUserId":"165549"`, `"displayUserId":"165549"`,
`"contact":"+63"`, `"contact":"+63"`,
`"canGrantVehicle":true`,
`"canBlockUser":false`,
`"bdLeaderCount":3`, `"bdLeaderCount":3`,
`"lastInvitedAtMs":1720000000000`, `"lastInvitedAtMs":1720000000000`,
} { } {

View File

@ -79,6 +79,19 @@ type DrawResult struct {
WalletTransactionID string WalletTransactionID string
CoinBalanceAfter int64 CoinBalanceAfter int64
MetadataJSON string MetadataJSON string
Rewards []DrawReward
}
// DrawReward 是批量抽奖响应里的单次命中奖品快照;它只服务展示和协议转换,发奖状态仍以每条 draw record 为准。
type DrawReward struct {
SelectedTierID string
RewardType string
RewardID string
RewardCount int64
RewardCoins int64
RTPValueCoins int64
RewardStatus string
MetadataJSON string
} }
type DrawSummary struct { type DrawSummary struct {

View File

@ -171,23 +171,35 @@ func (r *Repository) ExecuteWheelDraw(ctx context.Context, cmd domain.DrawComman
totalPoolIn += unitSpent * config.PoolRatePPM / wheelPPMScale totalPoolIn += unitSpent * config.PoolRatePPM / wheelPPMScale
pool.Balance += unitSpent * config.PoolRatePPM / wheelPPMScale pool.Balance += unitSpent * config.PoolRatePPM / wheelPPMScale
subCommand := wheelSubCommandID(cmd.CommandID, i, cmd.DrawCount) subCommand := wheelSubCommandID(cmd.CommandID, i, cmd.DrawCount)
result, poolOut, err := r.executeSingleWheelDraw(ctx, tx, appCode, cmd, subCommand, unitSpent, config, &window, &pool, nowMS) result, poolOut, err := r.executeSingleWheelDraw(cmd, subCommand, unitSpent, config, &window, &pool, nowMS)
if err != nil { if err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
} }
totalPoolOut += poolOut totalPoolOut += poolOut
results = append(results, result) results = append(results, result)
} }
if err := r.persistWheelRTPWindow(ctx, tx, appCode, config.WheelID, window, nowMS); err != nil {
return domain.DrawResult{}, err
}
if err := r.persistWheelPoolDelta(ctx, tx, appCode, config.WheelID, totalPoolIn, totalPoolOut, nowMS); err != nil { if err := r.persistWheelPoolDelta(ctx, tx, appCode, config.WheelID, totalPoolIn, totalPoolOut, nowMS); err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
} }
if err := r.updateWheelStats(ctx, tx, appCode, config.WheelID, cmd.UserID, cmd.CoinSpent, results, nowMS); err != nil { if err := r.updateWheelStats(ctx, tx, appCode, config.WheelID, cmd.UserID, cmd.CoinSpent, results, nowMS); err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
} }
aggregate := aggregateWheelDrawResults(cmd, results)
if err := r.insertAggregateWheelDraw(ctx, tx, appCode, cmd, aggregate, nowMS); err != nil {
return domain.DrawResult{}, err
}
if aggregate.RewardStatus == domain.StatusPending {
if err := r.insertWheelRewardOutbox(ctx, tx, appCode, aggregate, cmd, nowMS); err != nil {
return domain.DrawResult{}, err
}
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
} }
return aggregateWheelDrawResults(cmd, results), nil return aggregate, nil
} }
func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) { func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
@ -222,6 +234,7 @@ func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery)
return nil, 0, err return nil, 0, err
} }
item.DrawIDs = []string{item.DrawID} item.DrawIDs = []string{item.DrawID}
item.Rewards = wheelRewardsFromMetadata(item.MetadataJSON)
items = append(items, item) items = append(items, item)
} }
return items, total, rows.Err() return items, total, rows.Err()
@ -355,7 +368,7 @@ func (r *Repository) MarkWheelDrawsGranted(ctx context.Context, appCode string,
return tx.Commit() return tx.Commit()
} }
func (r *Repository) executeSingleWheelDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, commandID string, unitSpent int64, config domain.RuleConfig, window *wheelRTPWindow, pool *wheelPool, nowMS int64) (domain.DrawResult, int64, error) { func (r *Repository) executeSingleWheelDraw(cmd domain.DrawCommand, commandID string, unitSpent int64, config domain.RuleConfig, window *wheelRTPWindow, pool *wheelPool, nowMS int64) (domain.DrawResult, int64, error) {
candidates, limited := wheelPayableCandidates(config, pool) candidates, limited := wheelPayableCandidates(config, pool)
if len(candidates) == 0 { if len(candidates) == 0 {
return domain.DrawResult{}, 0, xerr.New(xerr.Conflict, "wheel has no payable tier") return domain.DrawResult{}, 0, xerr.New(xerr.Conflict, "wheel has no payable tier")
@ -383,42 +396,12 @@ func (r *Repository) executeSingleWheelDraw(ctx context.Context, tx *sql.Tx, app
window.WagerCoins += unitSpent window.WagerCoins += unitSpent
window.TargetPayout = window.WagerCoins * window.TargetRTPPPM / wheelPPMScale window.TargetPayout = window.WagerCoins * window.TargetRTPPPM / wheelPPMScale
window.ActualRTPValue += tier.RTPValueCoins window.ActualRTPValue += tier.RTPValueCoins
if _, err := tx.ExecContext(ctx, `
UPDATE wheel_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, actual_rtp_value_coins = ?,
carry_ppm = ?, updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayout, window.ActualRTPValue,
window.CarryPPM, nowMS, appCode, config.WheelID, window.WindowIndex,
); err != nil {
return domain.DrawResult{}, 0, err
}
drawID := idgen.New("wheel_draw") drawID := idgen.New("wheel_draw")
status := domain.StatusGranted status := domain.StatusGranted
if wheelDrawNeedsFulfillment(tier) { if wheelDrawNeedsFulfillment(tier) {
status = domain.StatusPending status = domain.StatusPending
} }
candidateJSON, _ := json.Marshal(map[string]any{"selected": tier.TierID, "limited": limited}) _ = limited
rtpJSON, _ := json.Marshal(map[string]any{"window_index": window.WindowIndex, "wager_coins": window.WagerCoins, "actual_rtp_value_coins": window.ActualRTPValue})
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_draw_records (
app_code, draw_id, command_id, user_id, device_id, visible_region_id, wheel_id, coin_spent,
rule_version, rtp_window_index, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, candidate_tiers_json, rtp_snapshot_json, prize_metadata_json,
reward_status, paid_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, drawID, commandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, config.WheelID, unitSpent,
config.RuleVersion, window.WindowIndex, tier.TierID, tier.RewardType, tier.RewardID, tier.RewardCount,
tier.RewardCoins, tier.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(tier.MetadataJSON),
status, cmd.PaidAtMS, nowMS, nowMS,
); err != nil {
return domain.DrawResult{}, 0, err
}
if status == domain.StatusPending {
if err := r.insertWheelRewardOutbox(ctx, tx, appCode, drawID, commandID, cmd, tier, nowMS); err != nil {
return domain.DrawResult{}, 0, err
}
}
return domain.DrawResult{ return domain.DrawResult{
DrawID: drawID, DrawID: drawID,
CommandID: commandID, CommandID: commandID,
@ -576,6 +559,18 @@ func (r *Repository) persistWheelPoolDelta(ctx context.Context, tx *sql.Tx, appC
return err return err
} }
func (r *Repository) persistWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, window wheelRTPWindow, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE wheel_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, actual_rtp_value_coins = ?,
carry_ppm = ?, updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayout, window.ActualRTPValue,
window.CarryPPM, nowMS, appCode, wheelID, window.WindowIndex,
)
return err
}
func wheelPayableCandidates(config domain.RuleConfig, pool *wheelPool) ([]wheelCandidate, map[string]bool) { func wheelPayableCandidates(config domain.RuleConfig, pool *wheelPool) ([]wheelCandidate, map[string]bool) {
limited := map[string]bool{} limited := map[string]bool{}
capacity := pool.Balance - pool.ReserveFloor capacity := pool.Balance - pool.ReserveFloor
@ -643,50 +638,73 @@ func (r *Repository) updateWheelStats(ctx context.Context, tx *sql.Tx, appCode,
return nil return nil
} }
func (r *Repository) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode, drawID, commandID string, cmd domain.DrawCommand, tier domain.Tier, nowMS int64) error { func (r *Repository) insertAggregateWheelDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, result domain.DrawResult, nowMS int64) error {
candidateJSON, _ := json.Marshal(map[string]any{
"draw_count": len(result.Rewards),
"batch": len(result.Rewards) > 1,
})
rtpJSON, _ := json.Marshal(map[string]any{
"rtp_window_index": result.RTPWindowIndex,
"total_rtp_value_coins": result.RTPValueCoins,
})
_, err := tx.ExecContext(ctx, `
INSERT INTO wheel_draw_records (
app_code, draw_id, command_id, user_id, device_id, visible_region_id, wheel_id, coin_spent,
rule_version, rtp_window_index, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, candidate_tiers_json, rtp_snapshot_json, prize_metadata_json,
reward_status, paid_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, result.DrawID, cmd.CommandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, result.WheelID, cmd.CoinSpent,
result.RuleVersion, result.RTPWindowIndex, result.SelectedTierID, result.RewardType, result.RewardID, result.RewardCount,
result.RewardCoins, result.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(result.MetadataJSON),
result.RewardStatus, cmd.PaidAtMS, nowMS, nowMS,
)
return err
}
func (r *Repository) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode string, result domain.DrawResult, cmd domain.DrawCommand, nowMS int64) error {
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"app_code": appCode, "app_code": appCode,
"draw_id": drawID, "draw_id": result.DrawID,
"command_id": commandID, "draw_ids": result.DrawIDs,
"command_id": cmd.CommandID,
"user_id": cmd.UserID, "user_id": cmd.UserID,
"wheel_id": cmd.WheelID, "wheel_id": cmd.WheelID,
"selected_tier_id": tier.TierID, "selected_tier_id": result.SelectedTierID,
"reward_type": tier.RewardType, "reward_type": result.RewardType,
"reward_id": tier.RewardID, "reward_id": result.RewardID,
"reward_count": tier.RewardCount, "reward_count": result.RewardCount,
"reward_coins": tier.RewardCoins, "reward_coins": result.RewardCoins,
"rewards": result.Rewards,
"visible_region_id": cmd.VisibleRegionID, "visible_region_id": cmd.VisibleRegionID,
"created_at_ms": nowMS, "created_at_ms": nowMS,
}) })
return r.insertLuckyDrawOutbox(ctx, tx, appCode, "wheel_reward_"+drawID, domain.EventTypeWheelRewardSettlement, payload, nowMS) return r.insertLuckyDrawOutbox(ctx, tx, appCode, "wheel_reward_"+result.DrawID, domain.EventTypeWheelRewardSettlement, payload, nowMS)
} }
func (r *Repository) collectWheelDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, drawCount int32) ([]domain.DrawResult, bool, error) { func (r *Repository) collectWheelDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, drawCount int32) ([]domain.DrawResult, bool, error) {
if drawCount <= 0 { if drawCount <= 0 {
drawCount = 1 drawCount = 1
} }
results := make([]domain.DrawResult, 0, drawCount)
for i := int32(1); i <= drawCount; i++ {
sub := wheelSubCommandID(commandID, i, drawCount)
row := tx.QueryRowContext(ctx, ` row := tx.QueryRowContext(ctx, `
SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id, reward_count, SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms, reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms,
COALESCE(CAST(prize_metadata_json AS CHAR), '{}') COALESCE(CAST(prize_metadata_json AS CHAR), '{}')
FROM wheel_draw_records FROM wheel_draw_records
WHERE app_code = ? AND command_id = ?`, WHERE app_code = ? AND command_id = ?`,
appCode, sub, appCode, strings.TrimSpace(commandID),
) )
var result domain.DrawResult var result domain.DrawResult
if err := row.Scan(&result.DrawID, &result.CommandID, &result.WheelID, &result.RuleVersion, &result.SelectedTierID, &result.RewardType, &result.RewardID, &result.RewardCount, if err := row.Scan(&result.DrawID, &result.CommandID, &result.WheelID, &result.RuleVersion, &result.SelectedTierID, &result.RewardType, &result.RewardID, &result.RewardCount,
&result.RewardCoins, &result.RTPValueCoins, &result.RewardStatus, &result.WalletTransactionID, &result.RTPWindowIndex, &result.CreatedAtMS, &result.MetadataJSON); err != nil { &result.RewardCoins, &result.RTPValueCoins, &result.RewardStatus, &result.WalletTransactionID, &result.RTPWindowIndex, &result.CreatedAtMS, &result.MetadataJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
continue return nil, false, nil
} }
return nil, false, err return nil, false, err
} }
results = append(results, result) result.DrawIDs = []string{result.DrawID}
} result.Rewards = wheelRewardsFromMetadata(result.MetadataJSON)
return results, int32(len(results)) == drawCount, nil return []domain.DrawResult{result}, true, nil
} }
func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult { func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult {
@ -695,9 +713,15 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
} }
aggregate := results[0] aggregate := results[0]
aggregate.CommandID = cmd.CommandID aggregate.CommandID = cmd.CommandID
aggregate.DrawIDs = make([]string, 0, len(results))
if len(results) == 1 {
aggregate.DrawIDs = []string{aggregate.DrawID} aggregate.DrawIDs = []string{aggregate.DrawID}
aggregate.Rewards = make([]domain.DrawReward, 0, len(results))
if len(results) == 1 {
if len(results[0].Rewards) > 0 {
aggregate.Rewards = append([]domain.DrawReward(nil), results[0].Rewards...)
return aggregate
}
aggregate.Rewards = []domain.DrawReward{wheelDrawRewardFromResult(aggregate)}
aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards)
return aggregate return aggregate
} }
aggregate.SelectedTierID = "batch" aggregate.SelectedTierID = "batch"
@ -707,8 +731,10 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
aggregate.RewardCoins = 0 aggregate.RewardCoins = 0
aggregate.RTPValueCoins = 0 aggregate.RTPValueCoins = 0
aggregate.RewardStatus = domain.StatusGranted aggregate.RewardStatus = domain.StatusGranted
aggregate.MetadataJSON = ""
for _, result := range results { for _, result := range results {
aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID) aggregate.Rewards = append(aggregate.Rewards, wheelDrawRewardFromResult(result))
aggregate.RewardCount += result.RewardCount
aggregate.RewardCoins += result.RewardCoins aggregate.RewardCoins += result.RewardCoins
aggregate.RTPValueCoins += result.RTPValueCoins aggregate.RTPValueCoins += result.RTPValueCoins
if result.RewardStatus == domain.StatusPending { if result.RewardStatus == domain.StatusPending {
@ -717,9 +743,73 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
aggregate.RewardStatus = domain.StatusFailed aggregate.RewardStatus = domain.StatusFailed
} }
} }
aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards)
return aggregate return aggregate
} }
func wheelDrawRewardFromResult(result domain.DrawResult) domain.DrawReward {
return domain.DrawReward{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}
}
func wheelAggregateMetadataJSON(rewards []domain.DrawReward) string {
items := make([]map[string]any, 0, len(rewards))
for _, reward := range rewards {
items = append(items, map[string]any{
"selected_tier_id": reward.SelectedTierID,
"reward_type": reward.RewardType,
"reward_id": reward.RewardID,
"reward_count": reward.RewardCount,
"reward_coins": reward.RewardCoins,
"rtp_value_coins": reward.RTPValueCoins,
"reward_status": reward.RewardStatus,
"metadata_json": wheelMetadataJSON(reward.MetadataJSON),
})
}
payload, _ := json.Marshal(map[string]any{"rewards": items})
return string(payload)
}
func wheelRewardsFromMetadata(metadataJSON string) []domain.DrawReward {
var payload struct {
Rewards []struct {
SelectedTierID string `json:"selected_tier_id"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
RTPValueCoins int64 `json:"rtp_value_coins"`
RewardStatus string `json:"reward_status"`
MetadataJSON string `json:"metadata_json"`
} `json:"rewards"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil || len(payload.Rewards) == 0 {
return nil
}
rewards := make([]domain.DrawReward, 0, len(payload.Rewards))
for _, item := range payload.Rewards {
rewards = append(rewards, domain.DrawReward{
SelectedTierID: item.SelectedTierID,
RewardType: item.RewardType,
RewardID: item.RewardID,
RewardCount: item.RewardCount,
RewardCoins: item.RewardCoins,
RTPValueCoins: item.RTPValueCoins,
RewardStatus: item.RewardStatus,
MetadataJSON: wheelMetadataJSON(item.MetadataJSON),
})
}
return rewards
}
func wheelSubCommandID(commandID string, drawIndex int32, drawCount int32) string { func wheelSubCommandID(commandID string, drawIndex int32, drawCount int32) string {
if drawCount <= 1 { if drawCount <= 1 {
return strings.TrimSpace(commandID) return strings.TrimSpace(commandID)

View File

@ -0,0 +1,30 @@
package mysql
import (
"testing"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
func TestAggregateWheelDrawResultsKeepsBatchRewardDetails(t *testing.T) {
results := []domain.DrawResult{
{DrawID: "draw-1", CommandID: "cmd#000001", WheelID: "luxury", SelectedTierID: "gift-a", RewardType: domain.RewardTypeGift, RewardID: "gift-a", RewardCount: 1, RTPValueCoins: 100, RewardStatus: domain.StatusPending, MetadataJSON: `{"resource_id":331}`},
{DrawID: "draw-2", CommandID: "cmd#000002", WheelID: "luxury", SelectedTierID: "gift-b", RewardType: domain.RewardTypeGift, RewardID: "gift-b", RewardCount: 1, RTPValueCoins: 200, RewardStatus: domain.StatusGranted, MetadataJSON: `{"resource_id":332}`},
{DrawID: "draw-3", CommandID: "cmd#000003", WheelID: "luxury", SelectedTierID: "gift-a", RewardType: domain.RewardTypeGift, RewardID: "gift-a", RewardCount: 1, RTPValueCoins: 100, RewardStatus: domain.StatusPending, MetadataJSON: `{"resource_id":331}`},
}
aggregate := aggregateWheelDrawResults(domain.DrawCommand{CommandID: "cmd", DrawCount: 3}, results)
if aggregate.CommandID != "cmd" || aggregate.SelectedTierID != "batch" || aggregate.RewardType != "batch" {
t.Fatalf("batch aggregate identity mismatch: %+v", aggregate)
}
if len(aggregate.DrawIDs) != 1 || aggregate.DrawIDs[0] != aggregate.DrawID || len(aggregate.Rewards) != 3 {
t.Fatalf("batch aggregate must keep one persisted draw id and all reward details: %+v", aggregate)
}
if aggregate.RewardStatus != domain.StatusPending || aggregate.RTPValueCoins != 400 {
t.Fatalf("batch aggregate status/value mismatch: %+v", aggregate)
}
if aggregate.Rewards[0].RewardID != "gift-a" || aggregate.Rewards[1].RewardID != "gift-b" || aggregate.Rewards[2].MetadataJSON != `{"resource_id":331}` {
t.Fatalf("batch reward details changed order/content: %+v", aggregate.Rewards)
}
}

View File

@ -175,6 +175,22 @@ func wheelRuleConfigToProto(config domain.RuleConfig) *activityv1.WheelRuleConfi
} }
func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResult { func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResult {
if len(result.Rewards) == 0 {
result.Rewards = []domain.DrawReward{{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}}
}
rewards := make([]*activityv1.WheelDrawReward, 0, len(result.Rewards))
for _, reward := range result.Rewards {
rewards = append(rewards, wheelDrawRewardToProto(reward))
}
return &activityv1.WheelDrawResult{ return &activityv1.WheelDrawResult{
DrawId: result.DrawID, DrawId: result.DrawID,
DrawIds: result.DrawIDs, DrawIds: result.DrawIDs,
@ -194,6 +210,20 @@ func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResul
WalletTransactionId: result.WalletTransactionID, WalletTransactionId: result.WalletTransactionID,
CoinBalanceAfter: result.CoinBalanceAfter, CoinBalanceAfter: result.CoinBalanceAfter,
MetadataJson: result.MetadataJSON, MetadataJson: result.MetadataJSON,
Rewards: rewards,
}
}
func wheelDrawRewardToProto(reward domain.DrawReward) *activityv1.WheelDrawReward {
return &activityv1.WheelDrawReward{
SelectedTierId: reward.SelectedTierID,
RewardType: reward.RewardType,
RewardId: reward.RewardID,
RewardCount: reward.RewardCount,
RewardCoins: reward.RewardCoins,
RtpValueCoins: reward.RTPValueCoins,
RewardStatus: reward.RewardStatus,
MetadataJson: reward.MetadataJSON,
} }
} }

View File

@ -273,6 +273,14 @@ func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
return wheelDrawData{Rewards: []wheelPrizeData{}} return wheelDrawData{Rewards: []wheelPrizeData{}}
} }
reward := wheelPrizeFromDraw(item) reward := wheelPrizeFromDraw(item)
rewards := make([]wheelPrizeData, 0, len(item.GetRewards()))
for _, detail := range item.GetRewards() {
rewards = append(rewards, wheelPrizeFromDrawReward(detail))
}
if len(rewards) == 0 {
rewards = []wheelPrizeData{reward}
}
rewards = mergeWheelPrizeData(rewards)
return wheelDrawData{ return wheelDrawData{
DrawID: item.GetDrawId(), DrawID: item.GetDrawId(),
DrawIDs: item.GetDrawIds(), DrawIDs: item.GetDrawIds(),
@ -287,8 +295,8 @@ func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
WalletTransactionID: item.GetWalletTransactionId(), WalletTransactionID: item.GetWalletTransactionId(),
CoinBalanceAfter: item.GetCoinBalanceAfter(), CoinBalanceAfter: item.GetCoinBalanceAfter(),
MetadataJSON: item.GetMetadataJson(), MetadataJSON: item.GetMetadataJson(),
RewardValue: reward.Value, RewardValue: wheelPrizeTotalValue(rewards),
Rewards: []wheelPrizeData{reward}, Rewards: rewards,
} }
} }
@ -311,6 +319,63 @@ func wheelPrizeFromDraw(item *activityv1.WheelDrawResult) wheelPrizeData {
} }
} }
func wheelPrizeFromDrawReward(item *activityv1.WheelDrawReward) wheelPrizeData {
if item == nil {
return wheelPrizeData{}
}
previewURL, animationURL := wheelAssetURLs(item.GetRewardType(), item.GetMetadataJson())
return wheelPrizeData{
TierID: item.GetSelectedTierId(),
DisplayName: item.GetSelectedTierId(),
RewardType: item.GetRewardType(),
RewardID: item.GetRewardId(),
RewardCount: item.GetRewardCount(),
RewardCoins: item.GetRewardCoins(),
MetadataJSON: item.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
}
}
func mergeWheelPrizeData(items []wheelPrizeData) []wheelPrizeData {
merged := make([]wheelPrizeData, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := wheelPrizeMergeKey(item)
if index, ok := byKey[key]; ok {
merged[index].RewardCount += item.RewardCount
continue
}
byKey[key] = len(merged)
merged = append(merged, item)
}
return merged
}
func wheelPrizeTotalValue(items []wheelPrizeData) int64 {
var total int64
for _, item := range items {
count := item.RewardCount
if count <= 0 {
count = 1
}
total += item.Value * count
}
return total
}
func wheelPrizeMergeKey(item wheelPrizeData) string {
return strings.Join([]string{
item.RewardType,
item.RewardID,
item.MetadataJSON,
item.PreviewURL,
item.AnimationURL,
fmt.Sprintf("%d", item.Value),
}, "|")
}
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64) int64 { func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64) int64 {
// H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。 // H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。
// 道具和装扮不计入 RTP 时内部估值会是 0这时回退奖励数量避免页面拿到 value=0 后看起来像奖品没有价值。 // 道具和装扮不计入 RTP 时内部估值会是 0这时回退奖励数量避免页面拿到 value=0 后看起来像奖品没有价值。

View File

@ -206,6 +206,36 @@ func TestGrantManagerResourceAllowsConfiguredDuration(t *testing.T) {
} }
} }
func TestGrantManagerResourceChecksCapabilityByWalletResourceType(t *testing.T) {
walletClient := &fakeWalletClient{
resourcesByID: map[int64]*walletv1.Resource{
202: {ResourceId: 202, ResourceType: "vehicle"},
},
}
hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-grant-car","target_user_id":"900001","resource_id":"202"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-grant-car")
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())
}
if walletClient.lastGetResource == nil || walletClient.lastGetResource.GetResourceId() != 202 {
t.Fatalf("resource lookup mismatch: %+v", walletClient.lastGetResource)
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_center" {
t.Fatalf("last manager capability should be final same-country actor check, got %+v", hostClient.lastCapability)
}
if !hostClient.sawCapability("manager_grant_vehicle") {
t.Fatalf("vehicle grant must check manager_grant_vehicle, saw %#v", hostClient.capabilities)
}
}
func TestGrantManagerResourceRejectsClientComputedQuantityOrInvalidDuration(t *testing.T) { func TestGrantManagerResourceRejectsClientComputedQuantityOrInvalidDuration(t *testing.T) {
walletClient := &fakeWalletClient{} walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{}) router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})

View File

@ -18,6 +18,13 @@ import (
const managerCenterCapability = "manager_center" const managerCenterCapability = "manager_center"
const managerResourceGrantCapability = "manager_resource_grant" const managerResourceGrantCapability = "manager_resource_grant"
const managerGrantAvatarFrameCapability = "manager_grant_avatar_frame"
const managerGrantVehicleCapability = "manager_grant_vehicle"
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 managerGrantDayMS int64 = 24 * 60 * 60 * 1000 const managerGrantDayMS int64 = 24 * 60 * 60 * 1000
const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
@ -129,6 +136,7 @@ func (h *Handler) getManagerOverview(writer http.ResponseWriter, request *http.R
"region_id": actor.GetRegionId(), "region_id": actor.GetRegionId(),
"region_code": actor.GetRegionCode(), "region_code": actor.GetRegionCode(),
"region_name": actor.GetRegionName(), "region_name": actor.GetRegionName(),
"permissions": h.managerPermissionOverview(request),
}) })
} }
@ -202,7 +210,8 @@ func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request
if _, ok := h.requireManagerCenterActor(writer, request); !ok { if _, ok := h.requireManagerCenterActor(writer, request); !ok {
return return
} }
if !h.requireManagerResourceGrantCapability(writer, request) { resourceType := strings.TrimSpace(request.URL.Query().Get("resource_type"))
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resourceType)) {
return return
} }
page, pageSize, ok := managerPage(request) page, pageSize, ok := managerPage(request)
@ -213,7 +222,7 @@ func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request
resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{ resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")), ResourceType: resourceType,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
ActiveOnly: true, ActiveOnly: true,
@ -290,9 +299,6 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
if !h.requireManagerResourceGrantCapability(writer, request) {
return
}
commandID := strings.TrimSpace(body.CommandID) commandID := strings.TrimSpace(body.CommandID)
if commandID == "" { if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt) commandID = strings.TrimSpace(body.CommandIDAlt)
@ -303,6 +309,24 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
resourceResp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceId: resourceID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resource := resourceResp.GetResource()
if resource == nil || resource.GetResourceId() <= 0 {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
// 写入口不能信任 H5 自报的资源类型;必须先从 wallet-service 读取 resource_id 的真实类型,再按头像框或座驾权限拒绝。
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resource.GetResourceType())) {
return
}
// 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。 // 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok { if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
return return
@ -419,6 +443,9 @@ func (h *Handler) listManagerBlocks(writer http.ResponseWriter, request *http.Re
if !ok { if !ok {
return return
} }
if !h.requireManagerCapability(writer, request, managerBlockUserCapability) {
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50) pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50)
if !ok { if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
@ -466,6 +493,9 @@ func (h *Handler) createManagerBlock(writer http.ResponseWriter, request *http.R
if !ok { if !ok {
return return
} }
if !h.requireManagerCapability(writer, request, managerBlockUserCapability) {
return
}
commandID := strings.TrimSpace(body.CommandID) commandID := strings.TrimSpace(body.CommandID)
if commandID == "" { if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt) commandID = strings.TrimSpace(body.CommandIDAlt)
@ -560,6 +590,9 @@ func (h *Handler) setManagerUserLevel(writer http.ResponseWriter, request *http.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
if !h.requireManagerCapability(writer, request, managerUpdateUserLevelCapability) {
return
}
// 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。 // 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok { if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
return return
@ -599,6 +632,9 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
if !ok { if !ok {
return return
} }
if !h.requireManagerCapability(writer, request, managerAddBDLeaderCapability) {
return
}
commandID := strings.TrimSpace(body.CommandID) commandID := strings.TrimSpace(body.CommandID)
if commandID == "" { if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt) commandID = strings.TrimSpace(body.CommandIDAlt)
@ -636,20 +672,7 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
} }
func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool { func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{ return h.requireManagerCapability(writer, request, managerResourceGrantCapability)
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: managerResourceGrantCapability,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return false
}
if !resp.GetAllowed() {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return false
}
return true
} }
func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) { func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) {
@ -718,6 +741,51 @@ func (h *Handler) requireSameCountryTargetWithActor(writer http.ResponseWriter,
return target, true return target, true
} }
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,
}
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,
} {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: capability,
})
if err != nil {
// overview 已经通过 manager_center 身份校验;单项权限读取失败时按关闭返回,避免 H5 展示不可确认的高危入口。
permissions[key] = false
continue
}
permissions[key] = resp.GetAllowed()
}
return permissions
}
func managerCapabilityForResourceType(resourceType string) string {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "avatar_frame":
return managerGrantAvatarFrameCapability
case "vehicle":
return managerGrantVehicleCapability
default:
return managerResourceGrantCapability
}
}
func sameCountry(left string, right string) bool { func sameCountry(left string, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) && strings.TrimSpace(left) != "" return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) && strings.TrimSpace(left) != ""
} }

View File

@ -465,6 +465,7 @@ type fakeUserHostClient struct {
kickResp *userv1.KickAgencyHostResponse kickResp *userv1.KickAgencyHostResponse
kickErr error kickErr error
lastCapability *userv1.CheckBusinessCapabilityRequest lastCapability *userv1.CheckBusinessCapabilityRequest
capabilities []string
capabilityResp *userv1.CheckBusinessCapabilityResponse capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error capabilityErr error
} }
@ -1459,6 +1460,7 @@ func (f *fakeUserHostClient) GetAgency(_ context.Context, req *userv1.GetAgencyR
func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) { func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) {
f.lastCapability = req f.lastCapability = req
f.capabilities = append(f.capabilities, req.GetCapability())
if f.capabilityErr != nil { if f.capabilityErr != nil {
return nil, f.capabilityErr return nil, f.capabilityErr
} }
@ -1468,6 +1470,15 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
} }
func (f *fakeUserHostClient) sawCapability(capability string) bool {
for _, item := range f.capabilities {
if item == capability {
return true
}
}
return false
}
func (f *fakeUserHostClient) GetAgencyMembers(_ context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) { func (f *fakeUserHostClient) GetAgencyMembers(_ context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
f.lastMembers = req f.lastMembers = req
if f.membersErr != nil { if f.membersErr != nil {
@ -1846,7 +1857,10 @@ func (f *fakeWalletClient) GetResource(_ context.Context, req *walletv1.GetResou
if f.resourcesByID != nil { if f.resourcesByID != nil {
return &walletv1.GetResourceResponse{Resource: f.resourcesByID[req.GetResourceId()]}, nil return &walletv1.GetResourceResponse{Resource: f.resourcesByID[req.GetResourceId()]}, nil
} }
return &walletv1.GetResourceResponse{}, nil return &walletv1.GetResourceResponse{Resource: &walletv1.Resource{
ResourceId: req.GetResourceId(),
ResourceType: "avatar_frame",
}}, nil
} }
func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) { func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {

View File

@ -766,6 +766,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID', user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息', 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_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 '是否允许在经理中心封禁用户',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID', created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -193,6 +193,13 @@ type ManagerProfile struct {
RegionID int64 RegionID int64
Status string Status string
Contact string Contact string
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
CanAddSuperadmin bool
CanBlockUser bool
CreatedByAdminID int64 CreatedByAdminID int64
CreatedAtMs int64 CreatedAtMs int64
UpdatedAtMs int64 UpdatedAtMs int64

View File

@ -10,6 +10,13 @@ import (
const ( const (
CapabilityManagerCenter = "manager_center" CapabilityManagerCenter = "manager_center"
CapabilityManagerResourceGrant = "manager_resource_grant" CapabilityManagerResourceGrant = "manager_resource_grant"
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
CapabilityManagerAddAdmin = "manager_add_admin"
CapabilityManagerAddSuperadmin = "manager_add_superadmin"
CapabilityManagerBlockUser = "manager_block_user"
) )
// CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。 // CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。
@ -23,7 +30,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
} }
capability = strings.ToLower(strings.TrimSpace(capability)) capability = strings.ToLower(strings.TrimSpace(capability))
switch capability { switch capability {
case CapabilityManagerCenter, CapabilityManagerResourceGrant: case CapabilityManagerCenter:
ok, err := s.repository.HasActiveManagerProfile(ctx, actorUserID) ok, err := s.repository.HasActiveManagerProfile(ctx, actorUserID)
if err != nil { if err != nil {
return false, "", err return false, "", err
@ -32,6 +39,23 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
return false, "active_manager_required", nil return false, "active_manager_required", nil
} }
return true, "", nil return true, "", nil
case CapabilityManagerResourceGrant,
CapabilityManagerGrantAvatarFrame,
CapabilityManagerGrantVehicle,
CapabilityManagerUpdateUserLevel,
CapabilityManagerAddBDLeader,
CapabilityManagerAddAdmin,
CapabilityManagerAddSuperadmin,
CapabilityManagerBlockUser:
// 细分能力必须同时满足 active 经理身份和对应权限开关;这样 H5 被篡改后直调写接口也会被 user-service 拒绝。
ok, err := s.repository.HasActiveManagerCapability(ctx, actorUserID, capability)
if err != nil {
return false, "", err
}
if !ok {
return false, "manager_capability_required", nil
}
return true, "", nil
default: default:
return false, "", xerr.New(xerr.InvalidArgument, "business capability is invalid") return false, "", xerr.New(xerr.InvalidArgument, "business capability is invalid")
} }

View File

@ -43,6 +43,7 @@ type Repository interface {
GetAgency(ctx context.Context, agencyID int64) (hostdomain.Agency, error) GetAgency(ctx context.Context, agencyID int64) (hostdomain.Agency, error)
HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error)
HasActiveManagerProfile(ctx context.Context, userID int64) (bool, error) HasActiveManagerProfile(ctx context.Context, userID int64) (bool, error)
HasActiveManagerCapability(ctx context.Context, userID int64, capability string) (bool, error)
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error)
ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error) ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error)
} }

View File

@ -316,6 +316,46 @@ func (r *Repository) HasActiveManagerProfile(ctx context.Context, userID int64)
return id > 0, nil return id > 0, nil
} }
// HasActiveManagerCapability 精确校验经理中心单项能力。
// 这里把 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)"
case hostservice.CapabilityManagerGrantAvatarFrame:
columnSQL = "can_grant_avatar_frame = 1"
case hostservice.CapabilityManagerGrantVehicle:
columnSQL = "can_grant_vehicle = 1"
case hostservice.CapabilityManagerUpdateUserLevel:
columnSQL = "can_update_user_level = 1"
case hostservice.CapabilityManagerAddBDLeader:
columnSQL = "can_add_bd_leader = 1"
case hostservice.CapabilityManagerAddAdmin:
columnSQL = "can_add_admin = 1"
case hostservice.CapabilityManagerAddSuperadmin:
columnSQL = "can_add_superadmin = 1"
case hostservice.CapabilityManagerBlockUser:
columnSQL = "can_block_user = 1"
default:
return false, nil
}
var id int64
err := r.db.QueryRowContext(ctx, `
SELECT user_id
FROM manager_profiles
WHERE app_code = ? AND user_id = ? AND status = ? AND `+columnSQL+`
LIMIT 1
`, appcode.FromContext(ctx), userID, hostdomain.ManagerStatusActive).Scan(&id)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return id > 0, nil
}
// ListBDLeaderBDs 读取 BD Leader 直属普通 BD调用前先校验独立 Leader 行当前仍有效,避免停用后继续读取团队数据。 // ListBDLeaderBDs 读取 BD Leader 直属普通 BD调用前先校验独立 Leader 行当前仍有效,避免停用后继续读取团队数据。
func (r *Repository) ListBDLeaderBDs(ctx context.Context, command hostservice.ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) { func (r *Repository) ListBDLeaderBDs(ctx context.Context, command hostservice.ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
leader, err := queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID) leader, err := queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID)

View File

@ -756,13 +756,26 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
_, err := r.schema.DB.ExecContext(context.Background(), ` _, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO manager_profiles ( INSERT INTO manager_profiles (
user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle,
) VALUES (?, ?, ?, ?, ?, ?) can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
status = VALUES(status), status = VALUES(status),
contact_info = VALUES(contact_info), contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
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),
updated_at_ms = VALUES(updated_at_ms) updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Status, profile.Contact, profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs) `, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanUpdateUserLevel),
defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser),
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil { if err != nil {
r.t.Fatalf("seed manager profile %d failed: %v", profile.UserID, err) r.t.Fatalf("seed manager profile %d failed: %v", profile.UserID, err)
} }
@ -819,3 +832,10 @@ func nullableInt64(value int64) any {
} }
return value return value
} }
func defaultTrue(value bool) bool {
if !value {
return true
}
return value
}