经理相关
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;
|
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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -43,18 +43,25 @@ type CoinSellerListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ManagerListItem struct {
|
type ManagerListItem struct {
|
||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
DisplayUserID string `json:"displayUserId"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
RegionID int64 `json:"regionId"`
|
RegionID int64 `json:"regionId"`
|
||||||
RegionName string `json:"regionName"`
|
RegionName string `json:"regionName"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Contact string `json:"contact"`
|
Contact string `json:"contact"`
|
||||||
BDLeaderCount int64 `json:"bdLeaderCount"`
|
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
|
||||||
LastInvitedAtMs int64 `json:"lastInvitedAtMs"`
|
CanGrantVehicle bool `json:"canGrantVehicle"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
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 {
|
type CoinSellerSalaryRateTier struct {
|
||||||
@ -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,
|
||||||
|
|||||||
@ -61,8 +61,70 @@ 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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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`,
|
||||||
} {
|
} {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
row := tx.QueryRowContext(ctx, `
|
||||||
for i := int32(1); i <= drawCount; i++ {
|
|
||||||
sub := wheelSubCommandID(commandID, i, drawCount)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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 {
|
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))
|
aggregate.DrawIDs = []string{aggregate.DrawID}
|
||||||
|
aggregate.Rewards = make([]domain.DrawReward, 0, len(results))
|
||||||
if len(results) == 1 {
|
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
|
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)
|
||||||
|
|||||||
@ -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 {
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 后看起来像奖品没有价值。
|
||||||
|
|||||||
@ -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{})
|
||||||
|
|||||||
@ -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) != ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -189,13 +189,20 @@ type BDProfile struct {
|
|||||||
// ManagerProfile 是经理身份事实。
|
// ManagerProfile 是经理身份事实。
|
||||||
// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。
|
// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。
|
||||||
type ManagerProfile struct {
|
type ManagerProfile struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
RegionID int64
|
RegionID int64
|
||||||
Status string
|
Status string
|
||||||
Contact string
|
Contact string
|
||||||
CreatedByAdminID int64
|
CanGrantAvatarFrame bool
|
||||||
CreatedAtMs int64
|
CanGrantVehicle bool
|
||||||
UpdatedAtMs int64
|
CanUpdateUserLevel bool
|
||||||
|
CanAddBDLeader bool
|
||||||
|
CanAddAdmin bool
|
||||||
|
CanAddSuperadmin bool
|
||||||
|
CanBlockUser bool
|
||||||
|
CreatedByAdminID int64
|
||||||
|
CreatedAtMs int64
|
||||||
|
UpdatedAtMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。
|
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。
|
||||||
|
|||||||
@ -8,8 +8,15 @@ 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user