经理相关
This commit is contained in:
parent
9c056b9cbf
commit
c8fc1ede01
File diff suppressed because it is too large
Load Diff
@ -1948,6 +1948,18 @@ message WheelDrawResult {
|
||||
string wallet_transaction_id = 16;
|
||||
int64 coin_balance_after = 17;
|
||||
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 {
|
||||
|
||||
@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID',
|
||||
status VARCHAR(32) NOT NULL 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_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;
|
||||
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 (
|
||||
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
|
||||
bl.app_code,
|
||||
bl.created_by_user_id,
|
||||
'active',
|
||||
'',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
MIN(bl.created_at_ms),
|
||||
MAX(bl.updated_at_ms)
|
||||
|
||||
@ -92,6 +92,26 @@ func (h *Handler) CreateManager(c *gin.Context) {
|
||||
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) {
|
||||
query, ok := parseListQuery(c)
|
||||
if !ok {
|
||||
|
||||
@ -43,18 +43,25 @@ type CoinSellerListItem struct {
|
||||
}
|
||||
|
||||
type ManagerListItem struct {
|
||||
UserID int64 `json:"userId,string"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Status string `json:"status"`
|
||||
Contact string `json:"contact"`
|
||||
BDLeaderCount int64 `json:"bdLeaderCount"`
|
||||
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Status string `json:"status"`
|
||||
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"`
|
||||
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type CoinSellerSalaryRateTier struct {
|
||||
@ -575,30 +582,42 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.
|
||||
|
||||
// CreateManagerProfile 创建或恢复经理身份。
|
||||
// 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 {
|
||||
return nil, errUserDBNotConfigured()
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, fmt.Errorf("target_user_id is required")
|
||||
}
|
||||
contact = strings.TrimSpace(contact)
|
||||
contact := strings.TrimSpace(req.Contact)
|
||||
if len([]rune(contact)) > 128 {
|
||||
return nil, fmt.Errorf("contact is too long")
|
||||
}
|
||||
permissions := req.permissionsWithDefault()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
appCode := appctx.FromContext(ctx)
|
||||
// ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态,
|
||||
// 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO manager_profiles (
|
||||
app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 'active', ?, ?, ?, ?)
|
||||
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
|
||||
) 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_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)
|
||||
`, 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 的独立身份事实读取经理列表。
|
||||
// 经理是 BD Leader/Admin 的上级,不属于 Host/Agency/BD 链路;下级 BD Leader 只作为统计信息左连汇总。
|
||||
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(`
|
||||
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, ''), 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
|
||||
%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.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
|
||||
LIMIT ? OFFSET ?
|
||||
`, 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.Status,
|
||||
&item.Contact,
|
||||
&item.CanGrantAvatarFrame,
|
||||
&item.CanGrantVehicle,
|
||||
&item.CanUpdateUserLevel,
|
||||
&item.CanAddBDLeader,
|
||||
&item.CanAddAdmin,
|
||||
&item.CanAddSuperadmin,
|
||||
&item.CanBlockUser,
|
||||
&item.BDLeaderCount,
|
||||
&item.LastInvitedAtMs,
|
||||
&item.CreatedAtMs,
|
||||
|
||||
@ -61,8 +61,70 @@ type createBDLeaderRequest struct {
|
||||
}
|
||||
|
||||
type createManagerRequest struct {
|
||||
Contact string `json:"contact"`
|
||||
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
||||
Contact string `json:"contact"`
|
||||
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 {
|
||||
|
||||
@ -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.GET("/admin/managers", middleware.RequirePermission("bd:view"), h.ListManagers)
|
||||
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.POST("/admin/agencies", middleware.RequirePermission("agency:create"), h.CreateAgency)
|
||||
protected.POST("/admin/agencies/:agency_id/close", middleware.RequirePermission("agency:status"), h.CloseAgency)
|
||||
|
||||
@ -60,8 +60,12 @@ func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 经理身份只落 manager_profiles 表;联系方式作为后台运营资料保存,不参与经理中心授权。
|
||||
return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req.Contact)
|
||||
// 经理身份只落 manager_profiles 表;权限开关同时写入 manager_profiles,供 H5 写入口逐项校验。
|
||||
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) {
|
||||
|
||||
@ -26,6 +26,8 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
|
||||
Username: "Kitty",
|
||||
Status: "active",
|
||||
Contact: "+63",
|
||||
CanGrantVehicle: true,
|
||||
CanBlockUser: false,
|
||||
BDLeaderCount: 3,
|
||||
LastInvitedAtMs: 1720000000000,
|
||||
}
|
||||
@ -38,6 +40,8 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
|
||||
`"userId":"316033326332776448"`,
|
||||
`"displayUserId":"165549"`,
|
||||
`"contact":"+63"`,
|
||||
`"canGrantVehicle":true`,
|
||||
`"canBlockUser":false`,
|
||||
`"bdLeaderCount":3`,
|
||||
`"lastInvitedAtMs":1720000000000`,
|
||||
} {
|
||||
|
||||
@ -79,6 +79,19 @@ type DrawResult struct {
|
||||
WalletTransactionID string
|
||||
CoinBalanceAfter int64
|
||||
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 {
|
||||
|
||||
@ -171,23 +171,35 @@ func (r *Repository) ExecuteWheelDraw(ctx context.Context, cmd domain.DrawComman
|
||||
totalPoolIn += unitSpent * config.PoolRatePPM / wheelPPMScale
|
||||
pool.Balance += unitSpent * config.PoolRatePPM / wheelPPMScale
|
||||
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 {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
totalPoolOut += poolOut
|
||||
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 {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
if err := r.updateWheelStats(ctx, tx, appCode, config.WheelID, cmd.UserID, cmd.CoinSpent, results, nowMS); err != nil {
|
||||
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 {
|
||||
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) {
|
||||
@ -222,6 +234,7 @@ func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery)
|
||||
return nil, 0, err
|
||||
}
|
||||
item.DrawIDs = []string{item.DrawID}
|
||||
item.Rewards = wheelRewardsFromMetadata(item.MetadataJSON)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
@ -355,7 +368,7 @@ func (r *Repository) MarkWheelDrawsGranted(ctx context.Context, appCode string,
|
||||
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)
|
||||
if len(candidates) == 0 {
|
||||
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.TargetPayout = window.WagerCoins * window.TargetRTPPPM / wheelPPMScale
|
||||
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")
|
||||
status := domain.StatusGranted
|
||||
if wheelDrawNeedsFulfillment(tier) {
|
||||
status = domain.StatusPending
|
||||
}
|
||||
candidateJSON, _ := json.Marshal(map[string]any{"selected": tier.TierID, "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
|
||||
}
|
||||
}
|
||||
_ = limited
|
||||
return domain.DrawResult{
|
||||
DrawID: drawID,
|
||||
CommandID: commandID,
|
||||
@ -576,6 +559,18 @@ func (r *Repository) persistWheelPoolDelta(ctx context.Context, tx *sql.Tx, appC
|
||||
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) {
|
||||
limited := map[string]bool{}
|
||||
capacity := pool.Balance - pool.ReserveFloor
|
||||
@ -643,50 +638,73 @@ func (r *Repository) updateWheelStats(ctx context.Context, tx *sql.Tx, appCode,
|
||||
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{
|
||||
"app_code": appCode,
|
||||
"draw_id": drawID,
|
||||
"command_id": commandID,
|
||||
"draw_id": result.DrawID,
|
||||
"draw_ids": result.DrawIDs,
|
||||
"command_id": cmd.CommandID,
|
||||
"user_id": cmd.UserID,
|
||||
"wheel_id": cmd.WheelID,
|
||||
"selected_tier_id": tier.TierID,
|
||||
"reward_type": tier.RewardType,
|
||||
"reward_id": tier.RewardID,
|
||||
"reward_count": tier.RewardCount,
|
||||
"reward_coins": tier.RewardCoins,
|
||||
"selected_tier_id": result.SelectedTierID,
|
||||
"reward_type": result.RewardType,
|
||||
"reward_id": result.RewardID,
|
||||
"reward_count": result.RewardCount,
|
||||
"reward_coins": result.RewardCoins,
|
||||
"rewards": result.Rewards,
|
||||
"visible_region_id": cmd.VisibleRegionID,
|
||||
"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) {
|
||||
if drawCount <= 0 {
|
||||
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,
|
||||
reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms,
|
||||
COALESCE(CAST(prize_metadata_json AS CHAR), '{}')
|
||||
FROM wheel_draw_records
|
||||
WHERE app_code = ? AND command_id = ?`,
|
||||
appCode, sub,
|
||||
)
|
||||
var result domain.DrawResult
|
||||
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 {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
return nil, false, err
|
||||
appCode, strings.TrimSpace(commandID),
|
||||
)
|
||||
var result domain.DrawResult
|
||||
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 {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
results = append(results, result)
|
||||
return nil, false, err
|
||||
}
|
||||
return results, int32(len(results)) == drawCount, nil
|
||||
result.DrawIDs = []string{result.DrawID}
|
||||
result.Rewards = wheelRewardsFromMetadata(result.MetadataJSON)
|
||||
return []domain.DrawResult{result}, true, nil
|
||||
}
|
||||
|
||||
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.CommandID = cmd.CommandID
|
||||
aggregate.DrawIDs = make([]string, 0, len(results))
|
||||
aggregate.DrawIDs = []string{aggregate.DrawID}
|
||||
aggregate.Rewards = make([]domain.DrawReward, 0, len(results))
|
||||
if len(results) == 1 {
|
||||
aggregate.DrawIDs = []string{aggregate.DrawID}
|
||||
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
|
||||
}
|
||||
aggregate.SelectedTierID = "batch"
|
||||
@ -707,8 +731,10 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
|
||||
aggregate.RewardCoins = 0
|
||||
aggregate.RTPValueCoins = 0
|
||||
aggregate.RewardStatus = domain.StatusGranted
|
||||
aggregate.MetadataJSON = ""
|
||||
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.RTPValueCoins += result.RTPValueCoins
|
||||
if result.RewardStatus == domain.StatusPending {
|
||||
@ -717,9 +743,73 @@ func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
|
||||
aggregate.RewardStatus = domain.StatusFailed
|
||||
}
|
||||
}
|
||||
aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards)
|
||||
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 {
|
||||
if drawCount <= 1 {
|
||||
return strings.TrimSpace(commandID)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -175,6 +175,22 @@ func wheelRuleConfigToProto(config domain.RuleConfig) *activityv1.WheelRuleConfi
|
||||
}
|
||||
|
||||
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{
|
||||
DrawId: result.DrawID,
|
||||
DrawIds: result.DrawIDs,
|
||||
@ -194,6 +210,20 @@ func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResul
|
||||
WalletTransactionId: result.WalletTransactionID,
|
||||
CoinBalanceAfter: result.CoinBalanceAfter,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -273,6 +273,14 @@ func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
|
||||
return wheelDrawData{Rewards: []wheelPrizeData{}}
|
||||
}
|
||||
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{
|
||||
DrawID: item.GetDrawId(),
|
||||
DrawIDs: item.GetDrawIds(),
|
||||
@ -287,8 +295,8 @@ func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
|
||||
WalletTransactionID: item.GetWalletTransactionId(),
|
||||
CoinBalanceAfter: item.GetCoinBalanceAfter(),
|
||||
MetadataJSON: item.GetMetadataJson(),
|
||||
RewardValue: reward.Value,
|
||||
Rewards: []wheelPrizeData{reward},
|
||||
RewardValue: wheelPrizeTotalValue(rewards),
|
||||
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 {
|
||||
// H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。
|
||||
// 道具和装扮不计入 RTP 时内部估值会是 0,这时回退奖励数量,避免页面拿到 value=0 后看起来像奖品没有价值。
|
||||
|
||||
@ -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) {
|
||||
walletClient := &fakeWalletClient{}
|
||||
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
|
||||
|
||||
@ -18,6 +18,13 @@ import (
|
||||
|
||||
const managerCenterCapability = "manager_center"
|
||||
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 managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
|
||||
|
||||
@ -129,6 +136,7 @@ func (h *Handler) getManagerOverview(writer http.ResponseWriter, request *http.R
|
||||
"region_id": actor.GetRegionId(),
|
||||
"region_code": actor.GetRegionCode(),
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
if !h.requireManagerResourceGrantCapability(writer, request) {
|
||||
resourceType := strings.TrimSpace(request.URL.Query().Get("resource_type"))
|
||||
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resourceType)) {
|
||||
return
|
||||
}
|
||||
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{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
|
||||
ResourceType: resourceType,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
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")
|
||||
return
|
||||
}
|
||||
if !h.requireManagerResourceGrantCapability(writer, request) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
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")
|
||||
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 还会在事务内复验资源白名单。
|
||||
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
|
||||
return
|
||||
@ -419,6 +443,9 @@ func (h *Handler) listManagerBlocks(writer http.ResponseWriter, request *http.Re
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.requireManagerCapability(writer, request, managerBlockUserCapability) {
|
||||
return
|
||||
}
|
||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50)
|
||||
if !ok {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
if !h.requireManagerCapability(writer, request, managerBlockUserCapability) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
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")
|
||||
return
|
||||
}
|
||||
if !h.requireManagerCapability(writer, request, managerUpdateUserLevelCapability) {
|
||||
return
|
||||
}
|
||||
// 等级设置会把目标轨道 total_value 直接改到对应规则阈值,并由 activity-service 补齐 1..目标等级奖励。
|
||||
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
|
||||
return
|
||||
@ -599,6 +632,9 @@ func (h *Handler) createManagerBDLeader(writer http.ResponseWriter, request *htt
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.requireManagerCapability(writer, request, managerAddBDLeaderCapability) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
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 {
|
||||
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
|
||||
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
|
||||
return h.requireManagerCapability(writer, request, managerResourceGrantCapability)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) && strings.TrimSpace(left) != ""
|
||||
}
|
||||
|
||||
@ -465,6 +465,7 @@ type fakeUserHostClient struct {
|
||||
kickResp *userv1.KickAgencyHostResponse
|
||||
kickErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilities []string
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
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) {
|
||||
f.lastCapability = req
|
||||
f.capabilities = append(f.capabilities, req.GetCapability())
|
||||
if f.capabilityErr != nil {
|
||||
return nil, f.capabilityErr
|
||||
}
|
||||
@ -1468,6 +1470,15 @@ func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *use
|
||||
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) {
|
||||
f.lastMembers = req
|
||||
if f.membersErr != nil {
|
||||
@ -1846,7 +1857,10 @@ func (f *fakeWalletClient) GetResource(_ context.Context, req *walletv1.GetResou
|
||||
if f.resourcesByID != 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) {
|
||||
|
||||
@ -766,6 +766,13 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID',
|
||||
status VARCHAR(32) NOT NULL 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_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
|
||||
@ -189,13 +189,20 @@ type BDProfile struct {
|
||||
// ManagerProfile 是经理身份事实。
|
||||
// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。
|
||||
type ManagerProfile struct {
|
||||
UserID int64
|
||||
RegionID int64
|
||||
Status string
|
||||
Contact string
|
||||
CreatedByAdminID int64
|
||||
CreatedAtMs int64
|
||||
UpdatedAtMs int64
|
||||
UserID int64
|
||||
RegionID int64
|
||||
Status string
|
||||
Contact string
|
||||
CanGrantAvatarFrame bool
|
||||
CanGrantVehicle bool
|
||||
CanUpdateUserLevel bool
|
||||
CanAddBDLeader bool
|
||||
CanAddAdmin bool
|
||||
CanAddSuperadmin bool
|
||||
CanBlockUser bool
|
||||
CreatedByAdminID int64
|
||||
CreatedAtMs int64
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
|
||||
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。
|
||||
|
||||
@ -8,8 +8,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityManagerCenter = "manager_center"
|
||||
CapabilityManagerResourceGrant = "manager_resource_grant"
|
||||
CapabilityManagerCenter = "manager_center"
|
||||
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 业务入口的服务端身份边界。
|
||||
@ -23,7 +30,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
|
||||
}
|
||||
capability = strings.ToLower(strings.TrimSpace(capability))
|
||||
switch capability {
|
||||
case CapabilityManagerCenter, CapabilityManagerResourceGrant:
|
||||
case CapabilityManagerCenter:
|
||||
ok, err := s.repository.HasActiveManagerProfile(ctx, actorUserID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
@ -32,6 +39,23 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
|
||||
return false, "active_manager_required", 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:
|
||||
return false, "", xerr.New(xerr.InvalidArgument, "business capability is invalid")
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ type Repository interface {
|
||||
GetAgency(ctx context.Context, agencyID int64) (hostdomain.Agency, error)
|
||||
HasActiveAgencyOwner(ctx context.Context, userID int64) (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)
|
||||
ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error)
|
||||
}
|
||||
|
||||
@ -316,6 +316,46 @@ func (r *Repository) HasActiveManagerProfile(ctx context.Context, userID int64)
|
||||
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 行当前仍有效,避免停用后继续读取团队数据。
|
||||
func (r *Repository) ListBDLeaderBDs(ctx context.Context, command hostservice.ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
|
||||
leader, err := queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID)
|
||||
|
||||
@ -756,13 +756,26 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
|
||||
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO manager_profiles (
|
||||
user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
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
|
||||
) 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_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)
|
||||
`, 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 {
|
||||
r.t.Fatalf("seed manager profile %d failed: %v", profile.UserID, err)
|
||||
}
|
||||
@ -819,3 +832,10 @@ func nullableInt64(value int64) any {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultTrue(value bool) bool {
|
||||
if !value {
|
||||
return true
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user