相关bug修复

This commit is contained in:
zhx 2026-07-22 15:55:17 +08:00
parent 838a76f0cc
commit f354aa7d88
23 changed files with 691 additions and 142 deletions

View File

@ -10212,6 +10212,9 @@ type BatchGetUserEquippedResourcesRequest struct {
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
UserIds []int64 `protobuf:"varint,3,rep,packed,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` UserIds []int64 `protobuf:"varint,3,rep,packed,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"`
ResourceTypes []string `protobuf:"bytes,4,rep,name=resource_types,json=resourceTypes,proto3" json:"resource_types,omitempty"` ResourceTypes []string `protobuf:"bytes,4,rep,name=resource_types,json=resourceTypes,proto3" json:"resource_types,omitempty"`
// vip_benefit_codes 只在调用方需要批量装配展示权限时传入wallet 会同时校验
// 当前 effective VIP、精确等级权益、体验卡可用性和用户开关避免调用方逐用户 N+1 校验。
VipBenefitCodes []string `protobuf:"bytes,5,rep,name=vip_benefit_codes,json=vipBenefitCodes,proto3" json:"vip_benefit_codes,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10274,10 +10277,19 @@ func (x *BatchGetUserEquippedResourcesRequest) GetResourceTypes() []string {
return nil return nil
} }
func (x *BatchGetUserEquippedResourcesRequest) GetVipBenefitCodes() []string {
if x != nil {
return x.VipBenefitCodes
}
return nil
}
type UserEquippedResources struct { type UserEquippedResources struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Resources []*UserResourceEntitlement `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty"` Resources []*UserResourceEntitlement `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty"`
// allowed_vip_benefit_codes 只返回请求中最终可执行的权益;设置值本身不能越权进入此集合。
AllowedVipBenefitCodes []string `protobuf:"bytes,3,rep,name=allowed_vip_benefit_codes,json=allowedVipBenefitCodes,proto3" json:"allowed_vip_benefit_codes,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10326,6 +10338,13 @@ func (x *UserEquippedResources) GetResources() []*UserResourceEntitlement {
return nil return nil
} }
func (x *UserEquippedResources) GetAllowedVipBenefitCodes() []string {
if x != nil {
return x.AllowedVipBenefitCodes
}
return nil
}
type BatchGetUserEquippedResourcesResponse struct { type BatchGetUserEquippedResourcesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Users []*UserEquippedResources `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` Users []*UserEquippedResources `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"`
@ -28199,16 +28218,18 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\n" + "\n" +
"unequipped\x18\x02 \x01(\bR\n" + "unequipped\x18\x02 \x01(\bR\n" +
"unequipped\x12\"\n" + "unequipped\x12\"\n" +
"\rupdated_at_ms\x18\x03 \x01(\x03R\vupdatedAtMs\"\xa2\x01\n" + "\rupdated_at_ms\x18\x03 \x01(\x03R\vupdatedAtMs\"\xce\x01\n" +
"$BatchGetUserEquippedResourcesRequest\x12\x1d\n" + "$BatchGetUserEquippedResourcesRequest\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x19\n" +
"\buser_ids\x18\x03 \x03(\x03R\auserIds\x12%\n" + "\buser_ids\x18\x03 \x03(\x03R\auserIds\x12%\n" +
"\x0eresource_types\x18\x04 \x03(\tR\rresourceTypes\"x\n" + "\x0eresource_types\x18\x04 \x03(\tR\rresourceTypes\x12*\n" +
"\x11vip_benefit_codes\x18\x05 \x03(\tR\x0fvipBenefitCodes\"\xb3\x01\n" +
"\x15UserEquippedResources\x12\x17\n" + "\x15UserEquippedResources\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12F\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12F\n" +
"\tresources\x18\x02 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\"e\n" + "\tresources\x18\x02 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\x129\n" +
"\x19allowed_vip_benefit_codes\x18\x03 \x03(\tR\x16allowedVipBenefitCodes\"e\n" +
"%BatchGetUserEquippedResourcesResponse\x12<\n" + "%BatchGetUserEquippedResourcesResponse\x12<\n" +
"\x05users\x18\x01 \x03(\v2&.hyapp.wallet.v1.UserEquippedResourcesR\x05users\"\xc4\x01\n" + "\x05users\x18\x01 \x03(\v2&.hyapp.wallet.v1.UserEquippedResourcesR\x05users\"\xc4\x01\n" +
"\x19ListResourceGrantsRequest\x12\x1d\n" + "\x19ListResourceGrantsRequest\x12\x1d\n" +

View File

@ -1207,11 +1207,16 @@ message BatchGetUserEquippedResourcesRequest {
string app_code = 2; string app_code = 2;
repeated int64 user_ids = 3; repeated int64 user_ids = 3;
repeated string resource_types = 4; repeated string resource_types = 4;
// vip_benefit_codes wallet
// effective VIP N+1
repeated string vip_benefit_codes = 5;
} }
message UserEquippedResources { message UserEquippedResources {
int64 user_id = 1; int64 user_id = 1;
repeated UserResourceEntitlement resources = 2; repeated UserResourceEntitlement resources = 2;
// allowed_vip_benefit_codes
repeated string allowed_vip_benefit_codes = 3;
} }
message BatchGetUserEquippedResourcesResponse { message BatchGetUserEquippedResourcesResponse {

View File

@ -47,6 +47,7 @@ INCREMENTAL_MIGRATION_FILES=(
"services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql" "services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql"
"services/wallet-service/deploy/mysql/migrations/012_wallet_outbox_archive_receipts.sql" "services/wallet-service/deploy/mysql/migrations/012_wallet_outbox_archive_receipts.sql"
"services/wallet-service/deploy/mysql/migrations/013_wallet_outbox_archive_purge_receipts.sql" "services/wallet-service/deploy/mysql/migrations/013_wallet_outbox_archive_purge_receipts.sql"
"services/wallet-service/deploy/mysql/migrations/018_host_salary_policy_revisions.sql"
"services/lucky-gift-service/deploy/mysql/migrations/013_jackpot_token_version_rate_limit.sql" "services/lucky-gift-service/deploy/mysql/migrations/013_jackpot_token_version_rate_limit.sql"
) )

View File

@ -436,7 +436,7 @@ type HostAgencySalaryPolicy struct {
RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"regionId"` RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"regionId"`
// RegionIDs 是这份政策实际覆盖的区域集合RegionID 只保留首个值用于旧客户端和旧索引兼容。 // RegionIDs 是这份政策实际覆盖的区域集合RegionID 只保留首个值用于旧客户端和旧索引兼容。
RegionIDs []int64 `gorm:"-" json:"regionIds"` RegionIDs []int64 `gorm:"-" json:"regionIds"`
// CycleKey 是工资归属月PolicyVersion 使用政策主键形成稳定版本号,发布后两者均不可修改 // CycleKey 是工资归属月PolicyVersion 首次发布等于主键,当前/未来月份修订时递增并对应一份不可变 wallet 快照
CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"` CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"`
PolicyVersion uint64 `gorm:"column:policy_version;uniqueIndex:uk_admin_host_agency_salary_policy_version;not null;default:0" json:"policyVersion"` PolicyVersion uint64 `gorm:"column:policy_version;uniqueIndex:uk_admin_host_agency_salary_policy_version;not null;default:0" json:"policyVersion"`
Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"` Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"`

View File

@ -201,14 +201,12 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
originalPolicyVersion := item.PolicyVersion
runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item) runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item)
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished { publishedRevision := item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished
// 运行侧已经按周期绑定该版本;编辑会让历史结算口径漂移,只能为新周期新建政策。
return policyDTO{}, errors.New("已发布的周期政策版本不可修改,请为新周期创建政策")
}
if strings.TrimSpace(req.PolicyType) == "" { if strings.TrimSpace(req.PolicyType) == "" {
// Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。 // Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。
req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType) req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType)
@ -218,6 +216,13 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
if publishedRevision {
// 已结束月份仍完全锁定;当前月和未来月只允许修改业务参数,发布身份不变,随后生成新的不可变运行版本。
if err := validatePublishedPolicyRevision(item, updated, time.Now().UTC()); err != nil {
return policyDTO{}, err
}
item.PolicyVersion = nextHostSalaryPolicyVersion(item.PolicyVersion, time.Now().UTC())
}
item.Name = updated.Name item.Name = updated.Name
item.PolicyType = updated.PolicyType item.PolicyType = updated.PolicyType
item.RegionID = updated.RegionID item.RegionID = updated.RegionID
@ -240,7 +245,7 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
item.PlatformWithdrawalAllowedDays = updated.PlatformWithdrawalAllowedDays item.PlatformWithdrawalAllowedDays = updated.PlatformWithdrawalAllowedDays
item.Description = updated.Description item.Description = updated.Description
item.UpdatedByAdminID = actorID item.UpdatedByAdminID = actorID
// 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft // 保存修订只更新 admin 草稿;运营重新发布后 wallet 才原子写入新快照并切换当前周期绑定
item.PublishStatus = publishStatusDraft item.PublishStatus = publishStatusDraft
item.PublishError = "" item.PublishError = ""
// 等级明细按整包替换处理:前端提交的是一套完整政策梯度,不做局部 patch减少排序和递增口径漂移。 // 等级明细按整包替换处理:前端提交的是一套完整政策梯度,不做局部 patch减少排序和递增口径漂移。
@ -248,12 +253,49 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
if err := s.ensureNoActiveOverlap(item, id); err != nil { if err := s.ensureNoActiveOverlap(item, id); err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
if err := s.store.UpdateHostAgencySalaryPolicy(&item); err != nil { if err := s.store.UpdateHostAgencySalaryPolicy(&item, originalPolicyVersion); err != nil {
return policyDTO{}, normalizePolicyPersistenceError(err) return policyDTO{}, normalizePolicyPersistenceError(err)
} }
return policyFromModel(item), nil return policyFromModel(item), nil
} }
func validatePublishedPolicyRevision(existing model.HostAgencySalaryPolicy, updated model.HostAgencySalaryPolicy, now time.Time) error {
currentCycle := now.UTC().Format("2006-01")
if existing.CycleKey < currentCycle {
return errors.New("已结束工资月的政策不可修改")
}
// 类型、月份和区域共同决定钱包记账分流及周期绑定;修订这些身份字段会留下无法解释的旧绑定,必须新建政策。
if normalizePolicyType(existing.PolicyType) != normalizePolicyType(updated.PolicyType) ||
existing.CycleKey != updated.CycleKey || !samePolicyRegionIDs(existing.RegionIDs, updated.RegionIDs) {
return errors.New("已发布政策的类型、周期和适用区域不可修改,请新建政策")
}
if updated.Status != policyStatusActive {
return errors.New("已发布政策修订时必须保持启用")
}
return nil
}
func samePolicyRegionIDs(left []int64, right []int64) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func nextHostSalaryPolicyVersion(current uint64, now time.Time) uint64 {
// epoch ms 对 JSON 安全且按时间可读;同一行在仓储事务中用旧版本 CAS毫秒内并发也只允许一个提交成功。
candidate := uint64(now.UTC().UnixMilli())
if candidate <= current {
return current + 1
}
return candidate
}
func policyTypeForUpdate(requested string, existing string) string { func policyTypeForUpdate(requested string, existing string) string {
if strings.TrimSpace(requested) == "" { if strings.TrimSpace(requested) == "" {
return existing return existing
@ -284,7 +326,7 @@ func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 { if item.PublishStatus == publishStatusPublished {
// 重复点击发布按幂等成功返回;不再覆盖运行表,也不生成新的周期绑定。 // 重复点击发布按幂等成功返回;不再覆盖运行表,也不生成新的周期绑定。
return policyFromModel(item), nil return policyFromModel(item), nil
} }
@ -314,6 +356,9 @@ func normalizePolicyPersistenceError(err error) error {
if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) { if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
return errors.New("同一区域同一工资周期只能存在一个启用政策") return errors.New("同一区域同一工资周期只能存在一个启用政策")
} }
if errors.Is(err, repository.ErrHostAgencySalaryPolicyConcurrentUpdate) {
return errors.New("政策已被其他人修改,请刷新后重试")
}
return err return err
} }
@ -382,13 +427,13 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
// 仅兼容迁移前创建但尚未发布的草稿;新记录在 admin 仓储创建事务中固定为 policy ID。 // 仅兼容迁移前创建但尚未发布的草稿;新记录在 admin 仓储创建事务中固定为 policy ID。
policyVersion = uint64(item.ID) policyVersion = uint64(item.ID)
} }
// 跨库状态回写失败后重试时wallet 可能已经拥有完整快照;内容一致视为幂等成功, // 跨库状态回写失败后重试时wallet 可能已经拥有同版本快照;内容一致视为幂等成功,
// 但任何业务字段或等级不一致都拒绝覆盖,继续保持已发布版本不可变 // 新修订使用新的 policy_version因此旧版本始终保留且任何同版本内容都不能被覆盖
if err := ensureRuntimeHostSalaryPolicySnapshot(ctx, tx, item, policyVersion, publishedAtMS); err != nil { if err := ensureRuntimeHostSalaryPolicySnapshot(ctx, tx, item, policyVersion, publishedAtMS); err != nil {
return err return err
} }
// 每个区域各自拥有不可覆盖的周期绑定,但同一政策快照和等级只写入一次,避免多区域版本漂移 // 每个区域各自拥有周期绑定;继承版本可由本月显式版本接管,同一政策的更高修订版本也可切换当前绑定
// 系统继承绑定的 source_cycle_key 指向上月,允许随后自动生成或人工创建的本月独立版本接管;显式绑定永不覆盖 // 旧运行快照不删除,工资记录中的 policy_version 仍能准确指回当时使用的口径
for _, regionID := range item.RegionIDs { for _, regionID := range item.RegionIDs {
if err := bindExplicitHostSalaryPolicy(ctx, tx, item, regionID, policyVersion, publishedAtMS); err != nil { if err := bindExplicitHostSalaryPolicy(ctx, tx, item, regionID, policyVersion, publishedAtMS); err != nil {
return err return err
@ -431,7 +476,7 @@ type runtimeHostSalaryLevelSnapshot struct {
} }
func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error { func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error {
// 先以完整快照尝试原子占位;主键已存在时只做 policy_id 自赋值,绝不改写任何业务字段。 // 先以完整快照尝试原子占位;复合主键包含 policy_version已存在时只做自赋值绝不改写业务字段。
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。 // 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policies ( INSERT INTO host_agency_salary_policies (
@ -463,8 +508,8 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count, coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
FROM host_agency_salary_policies FROM host_agency_salary_policies
WHERE app_code = ? AND policy_id = ? WHERE app_code = ? AND policy_id = ? AND policy_version = ?
FOR UPDATE`, item.AppCode, item.ID).Scan( FOR UPDATE`, item.AppCode, item.ID, policyVersion).Scan(
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status, &existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status,
&existing.SettlementMode, &existing.SettlementTriggerMode, &existing.SettlementMode, &existing.SettlementTriggerMode,
&existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD, &existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD,
@ -484,7 +529,7 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
if err != nil { if err != nil {
return err return err
} }
existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID) existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID, policyVersion)
if err != nil { if err != nil {
return err return err
} }
@ -495,17 +540,17 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
// 运行表保存美分整数,避免 wallet-service 结算时再次解析 decimal 字符串造成口径漂移。 // 运行表保存美分整数,避免 wallet-service 结算时再次解析 decimal 字符串造成口径漂移。
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policy_levels ( INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor, app_code, policy_id, policy_version, level_no, required_diamonds, host_salary_usd_minor,
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.AppCode, item.ID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor, item.AppCode, item.ID, policyVersion, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder,
publishedAtMS, publishedAtMS, publishedAtMS, publishedAtMS,
); err != nil { ); err != nil {
return err return err
} }
} }
existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID) existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID, policyVersion)
if err != nil { if err != nil {
return err return err
} }
@ -554,13 +599,13 @@ func runtimeHostSalaryLevelsFromModel(levels []model.HostAgencySalaryLevel) ([]r
return out, nil return out, nil
} }
func loadRuntimeHostSalaryLevels(ctx context.Context, tx *sql.Tx, appCode string, policyID uint) ([]runtimeHostSalaryLevelSnapshot, error) { func loadRuntimeHostSalaryLevels(ctx context.Context, tx *sql.Tx, appCode string, policyID uint, policyVersion uint64) ([]runtimeHostSalaryLevelSnapshot, error) {
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
agency_salary_usd_minor, status, sort_order agency_salary_usd_minor, status, sort_order
FROM host_agency_salary_policy_levels FROM host_agency_salary_policy_levels
WHERE app_code = ? AND policy_id = ? WHERE app_code = ? AND policy_id = ? AND policy_version = ?
ORDER BY level_no ASC`, appCode, policyID) ORDER BY level_no ASC`, appCode, policyID, policyVersion)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -613,6 +658,26 @@ func bindExplicitHostSalaryPolicy(ctx context.Context, tx *sql.Tx, item model.Ho
if existingPolicyID == uint64(item.ID) && existingPolicyVersion == policyVersion { if existingPolicyID == uint64(item.ID) && existingPolicyVersion == policyVersion {
return nil return nil
} }
if existingPolicyID == uint64(item.ID) && existingPolicyVersion < policyVersion && strings.TrimSpace(sourceCycleKey) == item.CycleKey {
// 当前/未来月份的同一逻辑政策允许发布更高版本WHERE 带旧版本,防止并发发布把更新顺序倒置。
result, updateErr := tx.ExecContext(ctx, `
UPDATE host_salary_policy_cycle_bindings
SET policy_version = ?, created_at_ms = ?
WHERE app_code = ? AND region_id = ? AND cycle_key = ? AND policy_id = ? AND policy_version = ? AND source_cycle_key = cycle_key`,
policyVersion, publishedAtMS, item.AppCode, regionID, item.CycleKey, item.ID, existingPolicyVersion,
)
if updateErr != nil {
return updateErr
}
affected, updateErr := result.RowsAffected()
if updateErr != nil {
return updateErr
}
if affected != 1 {
return errors.New("工资月政策版本已被并发修改,请重试")
}
return nil
}
if strings.TrimSpace(sourceCycleKey) == "" || strings.TrimSpace(sourceCycleKey) == item.CycleKey { if strings.TrimSpace(sourceCycleKey) == "" || strings.TrimSpace(sourceCycleKey) == item.CycleKey {
return errors.New("该区域工资月已经绑定其他显式政策版本") return errors.New("该区域工资月已经绑定其他显式政策版本")
} }

View File

@ -3,6 +3,7 @@ package hostagencypolicy
import ( import (
"context" "context"
"testing" "testing"
"time"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
@ -242,12 +243,12 @@ func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t
Levels: []model.HostAgencySalaryLevel{{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSD: "1.50", HostCoinReward: 20, AgencySalaryUSD: "0.50", Status: policyStatusActive, SortOrder: 1}}, Levels: []model.HostAgencySalaryLevel{{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSD: "1.50", HostCoinReward: 20, AgencySalaryUSD: "0.50", Status: policyStatusActive, SortOrder: 1}},
} }
mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{ mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81), uint64(81)).WillReturnRows(sqlmock.NewRows([]string{
"cycle_key", "policy_version", "name", "policy_type", "region_id", "status", "settlement_mode", "settlement_trigger_mode", "cycle_key", "policy_version", "name", "policy_type", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
"gift_ratio", "points_per_usd", "coins_per_usd", "minimum_usd_minor", "fee_bps", "agency_bps", "residual_rate", "gift_ratio", "points_per_usd", "coins_per_usd", "minimum_usd_minor", "fee_bps", "agency_bps", "residual_rate",
"coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days", "coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days",
}).AddRow("2026-08", 81, "August policy", "SALARY_DIAMOND", 25, "active", "daily", "automatic", "1.000000", 0, 0, 0, 0, 0, "0.000000000000", "week", 2, "day", 1, "15,30")) }).AddRow("2026-08", 81, "August policy", "SALARY_DIAMOND", 25, "active", "daily", "automatic", "1.000000", 0, 0, 0, 0, 0, "0.000000000000", "week", 2, "day", 1, "15,30"))
mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{ mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81), uint64(81)).WillReturnRows(sqlmock.NewRows([]string{
"level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order", "level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order",
}).AddRow(1, 100, 150, 20, 50, "active", 1)) }).AddRow(1, 100, 150, 20, 50, "active", 1))
if err := ensureRuntimeHostSalaryPolicySnapshot(context.Background(), tx, item, 81, 200); err != nil { if err := ensureRuntimeHostSalaryPolicySnapshot(context.Background(), tx, item, 81, 200); err != nil {
@ -281,6 +282,34 @@ func TestRuntimeHostSalaryPolicySnapshotRejectsDifferentContent(t *testing.T) {
} }
} }
func TestBindExplicitHostSalaryPolicyMovesSamePolicyToHigherVersion(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin transaction: %v", err)
}
item := model.HostAgencySalaryPolicy{ID: 81, AppCode: "fami", CycleKey: "2026-07"}
mock.ExpectQuery("SELECT policy_id, policy_version, source_cycle_key").
WithArgs("fami", int64(25), "2026-07").
WillReturnRows(sqlmock.NewRows([]string{"policy_id", "policy_version", "source_cycle_key"}).AddRow(81, 81, "2026-07"))
mock.ExpectExec("UPDATE host_salary_policy_cycle_bindings").
WithArgs(uint64(1784650000000), int64(1784650001000), "fami", int64(25), "2026-07", uint(81), uint64(81)).
WillReturnResult(sqlmock.NewResult(0, 1))
if err := bindExplicitHostSalaryPolicy(context.Background(), tx, item, 25, 1784650000000, 1784650001000); err != nil {
t.Fatalf("same current policy should move to its higher immutable version: %v", err)
}
mock.ExpectRollback()
_ = tx.Rollback()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) { func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@ -292,7 +321,7 @@ func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
t.Fatalf("open gorm: %v", err) t.Fatalf("open gorm: %v", err)
} }
store := repository.New(gormDB) store := repository.New(gormDB)
mock.ExpectExec("UPDATE .*admin_host_agency_salary_policies.*publish_status <> \\? AND published_at_ms = 0"). mock.ExpectExec("UPDATE .*admin_host_agency_salary_policies.*publish_status <> \\?").
WillReturnResult(sqlmock.NewResult(0, 0)) WillReturnResult(sqlmock.NewResult(0, 0))
if err := store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished("fami", 91, "duplicate publish", 7); err != nil { if err := store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished("fami", 91, "duplicate publish", 7); err != nil {
t.Fatalf("conditional failure update: %v", err) t.Fatalf("conditional failure update: %v", err)
@ -310,3 +339,33 @@ func TestPolicyTypeForUpdatePreservesPointDraftWhenFieldIsOmitted(t *testing.T)
t.Fatalf("explicit draft type change must remain visible to validation, got %q", got) t.Fatalf("explicit draft type change must remain visible to validation, got %q", got)
} }
} }
func TestPublishedPolicyRevisionAllowsCurrentBusinessParametersButLocksIdentity(t *testing.T) {
existing := model.HostAgencySalaryPolicy{
PolicyType: policyTypePointDiamond, CycleKey: "2026-07", RegionID: 25, RegionIDs: []int64{25, 26}, Status: policyStatusActive,
AgencyPointShareBPS: 20,
}
updated := existing
updated.AgencyPointShareBPS = 2000
if err := validatePublishedPolicyRevision(existing, updated, time.Date(2026, time.July, 22, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("current policy business parameters should be revisable: %v", err)
}
updated.RegionIDs = []int64{25}
if err := validatePublishedPolicyRevision(existing, updated, time.Date(2026, time.July, 22, 0, 0, 0, 0, time.UTC)); err == nil {
t.Fatal("published policy region identity must stay locked")
}
updated = existing
if err := validatePublishedPolicyRevision(existing, updated, time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)); err == nil {
t.Fatal("closed-cycle policy must remain immutable")
}
}
func TestNextHostSalaryPolicyVersionIsMonotonicAndJSONSafe(t *testing.T) {
now := time.Date(2026, time.July, 22, 0, 0, 0, 0, time.UTC)
if got := nextHostSalaryPolicyVersion(81, now); got != uint64(now.UnixMilli()) {
t.Fatalf("revision should use readable epoch-ms version, got %d", got)
}
if got := nextHostSalaryPolicyVersion(uint64(now.UnixMilli()), now); got != uint64(now.UnixMilli())+1 {
t.Fatalf("same-millisecond revision must still increase, got %d", got)
}
}

View File

@ -556,7 +556,7 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
if strings.TrimSpace(policy.PolicyType) != policyTypeSalaryDiamond { if strings.TrimSpace(policy.PolicyType) != policyTypeSalaryDiamond {
return hostPolicy{}, false, fmt.Errorf("policy type %s is not eligible for salary settlement", policy.PolicyType) return hostPolicy{}, false, fmt.Errorf("policy type %s is not eligible for salary settlement", policy.PolicyType)
} }
levels, err := s.policyLevels(ctx, appCode, policy.PolicyID) levels, err := s.policyLevels(ctx, appCode, policy.PolicyID, policy.PolicyVersion)
if err != nil { if err != nil {
return hostPolicy{}, false, err return hostPolicy{}, false, err
} }
@ -564,12 +564,12 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
return policy, true, nil return policy, true, nil
} }
func (s *Service) policyLevels(ctx context.Context, appCode string, policyID uint64) ([]hostPolicyLevel, error) { func (s *Service) policyLevels(ctx context.Context, appCode string, policyID uint64, policyVersion uint64) ([]hostPolicyLevel, error) {
rows, err := s.walletDB.QueryContext(ctx, ` rows, err := s.walletDB.QueryContext(ctx, `
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, agency_salary_usd_minor SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, agency_salary_usd_minor
FROM host_agency_salary_policy_levels FROM host_agency_salary_policy_levels
WHERE app_code = ? AND policy_id = ? AND status = 'active' WHERE app_code = ? AND policy_id = ? AND policy_version = ? AND status = 'active'
ORDER BY required_diamonds ASC, level_no ASC`, appCode, policyID) ORDER BY required_diamonds ASC, level_no ASC`, appCode, policyID, policyVersion)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -9,9 +9,13 @@ import (
mysqlDriver "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var ErrHostAgencySalaryPolicyActiveScopeConflict = errors.New("host agency salary policy active scope conflict") var (
ErrHostAgencySalaryPolicyActiveScopeConflict = errors.New("host agency salary policy active scope conflict")
ErrHostAgencySalaryPolicyConcurrentUpdate = errors.New("host agency salary policy was updated concurrently")
)
type HostAgencySalaryPolicyListOptions struct { type HostAgencySalaryPolicyListOptions struct {
AppCode string AppCode string
@ -167,8 +171,17 @@ func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy)
}) })
} }
func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy) error { func (s *Store) UpdateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy, expectedPolicyVersion uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
// 同一已发布政策可在当前月生成新版本;先锁主行并校验调用方读到的版本,避免两个运营提交互相覆盖配置和等级。
var locked model.HostAgencySalaryPolicy
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "policy_version").
Where("app_code = ? AND id = ?", item.AppCode, item.ID).First(&locked).Error; err != nil {
return err
}
if locked.PolicyVersion != expectedPolicyVersion {
return ErrHostAgencySalaryPolicyConcurrentUpdate
}
// 等级是整包配置,不做逐条 diff主表保存成功后删除旧明细再插入新梯度。 // 等级是整包配置,不做逐条 diff主表保存成功后删除旧明细再插入新梯度。
levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...) levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...)
item.Levels = nil item.Levels = nil
@ -214,9 +227,9 @@ func (s *Store) UpdateHostAgencySalaryPolicyPublishState(appCode string, id uint
func (s *Store) UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished(appCode string, id uint, publishError string, actorID uint) error { func (s *Store) UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished(appCode string, id uint, publishError string, actorID uint) error {
// wallet 提交与 admin 状态回写跨库,失败回写必须带状态条件:并发成功发布已经写入 published 后, // wallet 提交与 admin 状态回写跨库,失败回写必须带状态条件:并发成功发布已经写入 published 后,
// 较晚返回的重复发布错误不能把真实成功状态覆盖成 failed,也不能清空成功时间 // 较晚返回的重复发布错误不能把真实成功状态覆盖成 failedpublished_at_ms 保留上次成功时间,表明该政策身份已经发布过
return s.db.Model(&model.HostAgencySalaryPolicy{}). return s.db.Model(&model.HostAgencySalaryPolicy{}).
Where("app_code = ? AND id = ? AND publish_status <> ? AND published_at_ms = 0", strings.TrimSpace(appCode), id, "published"). Where("app_code = ? AND id = ? AND publish_status <> ?", strings.TrimSpace(appCode), id, "published").
Updates(map[string]any{ Updates(map[string]any{
"publish_status": "failed", "publish_status": "failed",
"publish_error": strings.TrimSpace(publishError), "publish_error": strings.TrimSpace(publishError),

View File

@ -4,6 +4,7 @@ import (
"context" "context"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/userleaderboard" "hyapp/pkg/userleaderboard"
"hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/client"
@ -18,7 +19,7 @@ type Handler struct {
achievementClient client.AchievementClient achievementClient client.AchievementClient
userProfileClient UserProfileReader userProfileClient UserProfileReader
roomQueryClient client.RoomQueryClient roomQueryClient client.RoomQueryClient
walletClient client.WalletClient walletClient ActivityWalletClient
userLeaderboard UserLeaderboardStore userLeaderboard UserLeaderboardStore
runtimeCapabilities appconfig.RuntimeCapabilityReader runtimeCapabilities appconfig.RuntimeCapabilityReader
registrationReward client.RegistrationRewardClient registrationReward client.RegistrationRewardClient
@ -49,6 +50,15 @@ type UserProfileReader interface {
BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error)
} }
// ActivityWalletClient 只开放活动模块现有的礼物目录、转盘扣款和榜单展示能力,避免 HTTP handler 获得完整账务接口。
type ActivityWalletClient interface {
BatchGetUserEquippedResources(ctx context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error)
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error)
}
type Config struct { type Config struct {
TaskClient client.TaskClient TaskClient client.TaskClient
GrowthLevelClient client.GrowthLevelClient GrowthLevelClient client.GrowthLevelClient

View File

@ -2,6 +2,7 @@ package activityapi
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -48,6 +49,7 @@ type userLeaderboardResponse struct {
type userLeaderboardItem struct { type userLeaderboardItem struct {
Rank int64 `json:"rank"` Rank int64 `json:"rank"`
UserID string `json:"user_id,omitempty"` UserID string `json:"user_id,omitempty"`
IdentityHidden bool `json:"identity_hidden,omitempty"`
RoomID string `json:"room_id,omitempty"` RoomID string `json:"room_id,omitempty"`
GiftValue int64 `json:"gift_value"` GiftValue int64 `json:"gift_value"`
GiftCount int64 `json:"gift_count"` GiftCount int64 `json:"gift_count"`
@ -284,17 +286,19 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st
if err != nil { if err != nil {
return err return err
} }
shortBadgeCovers, err := h.userLeaderboardShortBadgeCovers(request, userIDs) presentation, err := h.userLeaderboardPresentation(request, userIDs)
if err != nil { if err != nil {
return err return err
} }
for i := range items { for i := range items {
applyUserLeaderboardProfile(&items[i], profiles) applyUserLeaderboardProfile(&items[i], profiles)
applyUserLeaderboardShortBadgeCovers(&items[i], shortBadgeCovers) applyUserLeaderboardShortBadgeCovers(&items[i], presentation.ShortBadgeCovers)
applyUserLeaderboardPrivacy(&items[i], presentation.LeaderboardInvisible)
} }
if myRank != nil { if myRank != nil {
applyUserLeaderboardProfile(myRank, profiles) applyUserLeaderboardProfile(myRank, profiles)
applyUserLeaderboardShortBadgeCovers(myRank, shortBadgeCovers) applyUserLeaderboardShortBadgeCovers(myRank, presentation.ShortBadgeCovers)
applyUserLeaderboardPrivacy(myRank, presentation.LeaderboardInvisible)
} }
return nil return nil
} }
@ -392,28 +396,47 @@ func applyUserLeaderboardProfile(item *userLeaderboardItem, profiles map[int64]*
} }
} }
func (h *Handler) userLeaderboardShortBadgeCovers(request *http.Request, userIDs []int64) (map[int64][]string, error) { type userLeaderboardPresentation struct {
result := make(map[int64][]string) ShortBadgeCovers map[int64][]string
LeaderboardInvisible map[int64]struct{}
}
func (h *Handler) userLeaderboardPresentation(request *http.Request, userIDs []int64) (userLeaderboardPresentation, error) {
result := userLeaderboardPresentation{
ShortBadgeCovers: make(map[int64][]string),
LeaderboardInvisible: make(map[int64]struct{}),
}
userIDs = uniqueUserLeaderboardInt64s(userIDs) userIDs = uniqueUserLeaderboardInt64s(userIDs)
if h.walletClient == nil || len(userIDs) == 0 { if len(userIDs) == 0 {
// 榜单基础资料不依赖装扮服务;未接 wallet 的环境保持兼容,只是不返回短徽章。
return result, nil return result, nil
} }
// 一页榜单只发起一次批量查询,避免按用户逐个读取权益和资源造成 N+1 放大。 if h.walletClient == nil {
// 隐私权益不能在 wallet 未接入时 fail-open否则 VIP 用户会在依赖故障期间暴露身份。
return userLeaderboardPresentation{}, errors.New("wallet client is required for leaderboard privacy")
}
// 一页榜单只发起一次批量查询:静态短徽章与最终 VIP 隐私权限使用同一批用户,避免 N+1 放大。
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
UserIds: userIDs, UserIds: userIDs,
ResourceTypes: []string{"badge"}, ResourceTypes: []string{"badge"},
VipBenefitCodes: []string{"leaderboard_invisible"},
}) })
if err != nil { if err != nil {
return nil, err return userLeaderboardPresentation{}, err
} }
return userLeaderboardShortBadgeCoversFromResponse(resp), nil return userLeaderboardPresentationFromResponse(resp), nil
} }
func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse) map[int64][]string { func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse) map[int64][]string {
result := make(map[int64][]string) return userLeaderboardPresentationFromResponse(resp).ShortBadgeCovers
}
func userLeaderboardPresentationFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse) userLeaderboardPresentation {
result := userLeaderboardPresentation{
ShortBadgeCovers: make(map[int64][]string),
LeaderboardInvisible: make(map[int64]struct{}),
}
if resp == nil { if resp == nil {
return result return result
} }
@ -421,6 +444,12 @@ func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEqui
if user.GetUserId() <= 0 { if user.GetUserId() <= 0 {
continue continue
} }
for _, code := range user.GetAllowedVipBenefitCodes() {
if strings.EqualFold(strings.TrimSpace(code), "leaderboard_invisible") {
result.LeaderboardInvisible[user.GetUserId()] = struct{}{}
break
}
}
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, entitlement := range user.GetResources() { for _, entitlement := range user.GetResources() {
resource := entitlement.GetResource() resource := entitlement.GetResource()
@ -436,12 +465,29 @@ func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEqui
continue continue
} }
seen[coverURL] = struct{}{} seen[coverURL] = struct{}{}
result[user.GetUserId()] = append(result[user.GetUserId()], coverURL) result.ShortBadgeCovers[user.GetUserId()] = append(result.ShortBadgeCovers[user.GetUserId()], coverURL)
} }
} }
return result return result
} }
func applyUserLeaderboardPrivacy(item *userLeaderboardItem, invisible map[int64]struct{}) {
if item == nil {
return
}
userID, err := strconv.ParseInt(item.UserID, 10, 64)
if err != nil || userID <= 0 {
return
}
if _, hidden := invisible[userID]; !hidden {
return
}
// 名次和分值仍是榜单事实;身份字段必须整体清空,避免 H5 禁止跳转后响应里仍泄露内部 ID 或资料。
item.UserID = ""
item.User = nil
item.IdentityHidden = true
}
func isUserLeaderboardShortBadge(rawMetadata string) bool { func isUserLeaderboardShortBadge(rawMetadata string) bool {
metadata := map[string]any{} metadata := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(rawMetadata)), &metadata); err != nil { if err := json.Unmarshal([]byte(strings.TrimSpace(rawMetadata)), &metadata); err != nil {

View File

@ -18,6 +18,35 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
) )
type fakeUserLeaderboardPresentationReader struct {
last *walletv1.BatchGetUserEquippedResourcesRequest
response *walletv1.BatchGetUserEquippedResourcesResponse
}
func (*fakeUserLeaderboardPresentationReader) GetResource(context.Context, *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
return &walletv1.GetResourceResponse{}, nil
}
func (*fakeUserLeaderboardPresentationReader) GetResourceGroup(context.Context, *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {
return &walletv1.GetResourceGroupResponse{}, nil
}
func (*fakeUserLeaderboardPresentationReader) ListGiftConfigs(context.Context, *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
return &walletv1.ListGiftConfigsResponse{}, nil
}
func (*fakeUserLeaderboardPresentationReader) DebitWheelDraw(context.Context, *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) {
return &walletv1.DebitWheelDrawResponse{}, nil
}
func (f *fakeUserLeaderboardPresentationReader) BatchGetUserEquippedResources(_ context.Context, request *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) {
f.last = request
if f.response != nil {
return f.response, nil
}
return &walletv1.BatchGetUserEquippedResourcesResponse{}, nil
}
func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) { func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) {
now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60)) now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60))
@ -58,6 +87,7 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) {
func TestUserLeaderboardShortBadgeCoversFromResponse(t *testing.T) { func TestUserLeaderboardShortBadgeCoversFromResponse(t *testing.T) {
response := &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{{ response := &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{{
UserId: 20002, UserId: 20002,
AllowedVipBenefitCodes: []string{"leaderboard_invisible"},
Resources: []*walletv1.UserResourceEntitlement{ Resources: []*walletv1.UserResourceEntitlement{
{Resource: &walletv1.Resource{ResourceId: 1, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"tile","badge_kind":"normal"}`}}, {Resource: &walletv1.Resource{ResourceId: 1, ResourceType: "badge", PreviewUrl: "https://cdn.example/short.png", MetadataJson: `{"badge_form":"tile","badge_kind":"normal"}`}},
{Resource: &walletv1.Resource{ResourceId: 2, ResourceType: "badge", PreviewUrl: "https://cdn.example/long.png", MetadataJson: `{"badge_form":"strip","badge_kind":"normal"}`}}, {Resource: &walletv1.Resource{ResourceId: 2, ResourceType: "badge", PreviewUrl: "https://cdn.example/long.png", MetadataJson: `{"badge_form":"strip","badge_kind":"normal"}`}},
@ -74,6 +104,15 @@ func TestUserLeaderboardShortBadgeCoversFromResponse(t *testing.T) {
if len(covers[99999]) != 0 { if len(covers[99999]) != 0 {
t.Fatalf("users without a short badge must stay absent: %+v", covers) t.Fatalf("users without a short badge must stay absent: %+v", covers)
} }
presentation := userLeaderboardPresentationFromResponse(response)
if _, invisible := presentation.LeaderboardInvisible[20002]; !invisible {
t.Fatalf("wallet's final leaderboard privacy grant must be preserved: %+v", presentation)
}
item := userLeaderboardItem{UserID: "20002", User: &userLeaderboardUser{UserID: "20002", DisplayUserID: "251145", Username: "VIP"}}
applyUserLeaderboardPrivacy(&item, presentation.LeaderboardInvisible)
if !item.IdentityHidden || item.UserID != "" || item.User != nil {
t.Fatalf("invisible leaderboard identity must be removed without changing rank facts: %+v", item)
}
} }
func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) { func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) {
@ -147,9 +186,14 @@ func TestListUserLeaderboardsReadsRedisStore(t *testing.T) {
ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeViewerRegion, ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeViewerRegion,
ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional, ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional,
}} }}
walletReader := &fakeUserLeaderboardPresentationReader{response: &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{
{UserId: 20002, AllowedVipBenefitCodes: []string{"leaderboard_invisible"}},
{UserId: 10001},
}}}
handler := &Handler{ handler := &Handler{
userLeaderboard: store, userLeaderboard: store,
runtimeCapabilities: capabilityReader, runtimeCapabilities: capabilityReader,
walletClient: walletReader,
userProfileClient: &fakeInviteActivityUserProfileClient{user: &userv1.User{ userProfileClient: &fakeInviteActivityUserProfileClient{user: &userv1.User{
AppCode: "hyapp_room", RegionId: 1001, AppCode: "hyapp_room", RegionId: 1001,
}}, }},
@ -181,9 +225,12 @@ func TestListUserLeaderboardsReadsRedisStore(t *testing.T) {
if envelope.Code != "OK" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 { if envelope.Code != "OK" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
t.Fatalf("response mismatch: %+v", envelope) t.Fatalf("response mismatch: %+v", envelope)
} }
if envelope.Data.Items[0].UserID != "20002" || envelope.Data.Items[0].GiftValue != 900 || envelope.Data.MyRank == nil || envelope.Data.MyRank.Rank != 9 { if !envelope.Data.Items[0].IdentityHidden || envelope.Data.Items[0].UserID != "" || envelope.Data.Items[0].User != nil || envelope.Data.Items[0].GiftValue != 900 || envelope.Data.MyRank == nil || envelope.Data.MyRank.Rank != 9 {
t.Fatalf("leaderboard data mismatch: %+v my=%+v", envelope.Data.Items, envelope.Data.MyRank) t.Fatalf("leaderboard data mismatch: %+v my=%+v", envelope.Data.Items, envelope.Data.MyRank)
} }
if len(walletReader.last.GetVipBenefitCodes()) != 1 || walletReader.last.GetVipBenefitCodes()[0] != "leaderboard_invisible" {
t.Fatalf("leaderboard privacy must use the wallet batch gate: %+v", walletReader.last)
}
} }
func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) { func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) {
@ -217,6 +264,7 @@ func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) {
}} }}
handler := &Handler{ handler := &Handler{
userLeaderboard: store, userLeaderboard: store,
walletClient: &fakeUserLeaderboardPresentationReader{},
runtimeCapabilities: &fakeRuntimeCapabilityReader{capabilities: appconfig.RuntimeCapabilities{ runtimeCapabilities: &fakeRuntimeCapabilityReader{capabilities: appconfig.RuntimeCapabilities{
ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal, ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal,
ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional, ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional,

View File

@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID来自后台配置', policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID来自后台配置',
cycle_key VARCHAR(7) NOT NULL COMMENT '政策归属工资周期UTC yyyy-MM', cycle_key VARCHAR(7) NOT NULL COMMENT '政策归属工资周期UTC yyyy-MM',
policy_version BIGINT UNSIGNED NOT NULL COMMENT '不可变政策版本,等于后台政策 ID', policy_version BIGINT UNSIGNED NOT NULL COMMENT '不可变政策版本;首次等于后台政策 ID修订后使用递增版本',
name VARCHAR(120) NOT NULL DEFAULT '' COMMENT '政策名称', name VARCHAR(120) NOT NULL DEFAULT '' COMMENT '政策名称',
policy_type VARCHAR(32) NOT NULL DEFAULT 'SALARY_DIAMOND' COMMENT '政策类型SALARY_DIAMOND/POINT_DIAMOND', policy_type VARCHAR(32) NOT NULL DEFAULT 'SALARY_DIAMOND' COMMENT '政策类型SALARY_DIAMOND/POINT_DIAMOND',
region_id BIGINT NOT NULL COMMENT '适用区域 ID', region_id BIGINT NOT NULL COMMENT '适用区域 ID',
@ -421,7 +421,7 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间0 表示长期有效', effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间0 表示长期有效',
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',
PRIMARY KEY (app_code, policy_id), PRIMARY KEY (app_code, policy_id, policy_version),
KEY idx_host_agency_salary_policy_cycle (app_code, region_id, cycle_key, status, settlement_mode, settlement_trigger_mode) KEY idx_host_agency_salary_policy_cycle (app_code, region_id, cycle_key, status, settlement_mode, settlement_trigger_mode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策运行快照表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策运行快照表';
@ -459,7 +459,7 @@ CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations (
UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id) UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表';
-- 周期绑定一旦插入不可更新;复合主键保证新月份政策无法覆盖历史月份结算口径 -- 周期绑定按月唯一;历史月不更新,当前/未来月同一逻辑政策发布更高版本时只切换版本号
CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings ( CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
region_id BIGINT NOT NULL COMMENT '适用区域 ID', region_id BIGINT NOT NULL COMMENT '适用区域 ID',
@ -474,6 +474,7 @@ CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels ( CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID', policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
policy_version BIGINT UNSIGNED NOT NULL COMMENT '所属不可变政策版本',
level_no INT NOT NULL COMMENT '等级', level_no INT NOT NULL COMMENT '等级',
required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石', required_diamonds BIGINT NOT NULL COMMENT '达到该等级所需主播当月钻石',
host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计美元工资,单位美分', host_salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播累计美元工资,单位美分',
@ -483,8 +484,8 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policy_levels (
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
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',
PRIMARY KEY (app_code, policy_id, level_no), PRIMARY KEY (app_code, policy_id, policy_version, level_no),
KEY idx_host_agency_salary_policy_level_required (app_code, policy_id, status, required_diamonds) KEY idx_host_agency_salary_policy_level_required (app_code, policy_id, policy_version, status, required_diamonds)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级运行快照表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播代理工资政策等级运行快照表';
-- 结算进度按主播+月份独立记录累计已发值;日结/半月结只更新累计,月底清算只打 cleared 标记。 -- 结算进度按主播+月份独立记录累计已发值;日结/半月结只更新累计,月底清算只打 cleared 标记。

View File

@ -0,0 +1,64 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET time_zone = '+00:00';
USE hyapp_wallet;
-- 两张政策配置表数据量远小于钱包流水;迁移只重建配置表主键并按既有政策点查回填等级版本,
-- 不扫描余额、交易、工资进度或结算记录。上线前仍应确认表行数,并在低峰执行主键 DDL。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policy_levels' AND COLUMN_NAME = 'policy_version') = 0,
'ALTER TABLE host_agency_salary_policy_levels ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''所属不可变政策版本'' AFTER policy_id, ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 旧结构每个 policy_id 只有一个运行快照,按复合主键点关联即可回填,不读取任何业务流水。
UPDATE host_agency_salary_policy_levels level
JOIN host_agency_salary_policies policy
ON policy.app_code = level.app_code
AND policy.policy_id = level.policy_id
SET level.policy_version = policy.policy_version
WHERE level.policy_version = 0;
SET @ddl := IF(
COALESCE((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies' AND INDEX_NAME = 'PRIMARY'), '') <> 'app_code,policy_id,policy_version',
'ALTER TABLE host_agency_salary_policies DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, policy_id, policy_version), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
COALESCE((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policy_levels' AND INDEX_NAME = 'PRIMARY'), '') <> 'app_code,policy_id,policy_version,level_no',
'ALTER TABLE host_agency_salary_policy_levels DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, policy_id, policy_version, level_no), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @level_required_index_columns := COALESCE((
SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'host_agency_salary_policy_levels'
AND INDEX_NAME = 'idx_host_agency_salary_policy_level_required'
), '');
SET @ddl := IF(
@level_required_index_columns = 'app_code,policy_id,policy_version,status,required_diamonds',
'SELECT 1',
IF(
@level_required_index_columns = '',
'ALTER TABLE host_agency_salary_policy_levels ADD INDEX idx_host_agency_salary_policy_level_required (app_code, policy_id, policy_version, status, required_diamonds), ALGORITHM=INPLACE, LOCK=NONE',
'ALTER TABLE host_agency_salary_policy_levels DROP INDEX idx_host_agency_salary_policy_level_required, ADD INDEX idx_host_agency_salary_policy_level_required (app_code, policy_id, policy_version, status, required_diamonds), ALGORITHM=INPLACE, LOCK=NONE'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -76,9 +76,11 @@ type BatchGetUserEquippedResourcesQuery struct {
AppCode string AppCode string
UserIDs []int64 UserIDs []int64
ResourceTypes []string ResourceTypes []string
VIPBenefitCodes []string
} }
type UserEquippedResources struct { type UserEquippedResources struct {
UserID int64 UserID int64
Resources []UserResourceEntitlement Resources []UserResourceEntitlement
AllowedVIPBenefitCodes []string
} }

View File

@ -7,6 +7,7 @@ import (
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
walletservice "hyapp/services/wallet-service/internal/service/wallet" walletservice "hyapp/services/wallet-service/internal/service/wallet"
"hyapp/services/wallet-service/internal/testutil/mysqltest" "hyapp/services/wallet-service/internal/testutil/mysqltest"
) )
@ -128,6 +129,22 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
if err != nil || !roomCheck.Allowed { if err != nil || !roomCheck.Allowed {
t.Fatalf("enabled paid room entry benefit must be allowed: result=%+v err=%v", roomCheck, err) t.Fatalf("enabled paid room entry benefit must be allowed: result=%+v err=%v", roomCheck, err)
} }
leaderboardInvisibleOn := true
if _, err := service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
AppCode: "fami", UserID: paidUserID, LeaderboardInvisibleEnabled: &leaderboardInvisibleOn,
}); err != nil {
t.Fatalf("enable paid leaderboard privacy failed: %v", err)
}
presentation, err := service.BatchGetUserEquippedResources(famiCtx, resourcedomain.BatchGetUserEquippedResourcesQuery{
AppCode: "fami", UserIDs: []int64{paidUserID, concurrentUserID},
ResourceTypes: []string{resourcedomain.TypeBadge}, VIPBenefitCodes: []string{ledger.VipBenefitCodeLeaderboardInvisible},
})
if err != nil {
t.Fatalf("batch evaluate leaderboard privacy failed: %v", err)
}
if len(presentation) != 2 || len(presentation[0].AllowedVIPBenefitCodes) != 1 || presentation[0].AllowedVIPBenefitCodes[0] != ledger.VipBenefitCodeLeaderboardInvisible || len(presentation[1].AllowedVIPBenefitCodes) != 0 {
t.Fatalf("batch presentation must grant only the active VIP with its enabled setting: %+v", presentation)
}
// 体验卡与付费 VIP 共用同一设置门禁;佩戴后 effective_benefits 仍保留资格,执行结果为 disabled。 // 体验卡与付费 VIP 共用同一设置门禁;佩戴后 effective_benefits 仍保留资格,执行结果为 disabled。
const trialUserID int64 = 940002 const trialUserID int64 = 940002

View File

@ -530,7 +530,7 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar
if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond && policy.PolicyType != ledger.HostPolicyTypePointDiamond { if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond && policy.PolicyType != ledger.HostPolicyTypePointDiamond {
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Internal, "host policy type is invalid") return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Internal, "host policy type is invalid")
} }
levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID) levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID, policy.PolicyVersion)
if err != nil { if err != nil {
return ledger.HostSalaryPolicy{}, false, err return ledger.HostSalaryPolicy{}, false, err
} }
@ -589,14 +589,14 @@ func previousHostSalaryCycleKey(cycleKey string) (string, error) {
return parsed.AddDate(0, -1, 0).Format("2006-01"), nil return parsed.AddDate(0, -1, 0).Format("2006-01"), nil
} }
func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, q hostSalaryPolicyQuerier, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) { func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, q hostSalaryPolicyQuerier, policyID uint64, policyVersion uint64) ([]ledger.HostSalaryPolicyLevel, error) {
// 等级必须按 required_diamonds 升序返回highestHostSalaryLevel 依赖这个顺序找到最高已达档位。 // 等级必须按 required_diamonds 升序返回highestHostSalaryLevel 依赖这个顺序找到最高已达档位。
rows, err := q.QueryContext(ctx, ` rows, err := q.QueryContext(ctx, `
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
agency_salary_usd_minor, status, sort_order agency_salary_usd_minor, status, sort_order
FROM host_agency_salary_policy_levels FROM host_agency_salary_policy_levels
WHERE app_code = ? AND policy_id = ? AND status = 'active' WHERE app_code = ? AND policy_id = ? AND policy_version = ? AND status = 'active'
ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID) ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID, policyVersion)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -523,6 +523,11 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
if len(userIDs) == 0 { if len(userIDs) == 0 {
return []resourcedomain.UserEquippedResources{}, nil return []resourcedomain.UserEquippedResources{}, nil
} }
benefitCodes := normalizeVipBenefitCodes(query.VIPBenefitCodes)
// 展示批次只服务一页用户;显式限制矩阵规模,避免内部误用把动态 IN 查询扩大成无界扫描。
if len(benefitCodes) > 16 || (len(benefitCodes) > 0 && len(userIDs) > 100) {
return nil, xerr.New(xerr.InvalidArgument, "vip benefit presentation batch is too large")
}
resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes) resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes)
if err != nil { if err != nil {
@ -590,12 +595,17 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
if err := r.filterCurrentVipEquipmentBatch(ctx, userIDs, grouped, nowMs); err != nil { if err := r.filterCurrentVipEquipmentBatch(ctx, userIDs, grouped, nowMs); err != nil {
return nil, err return nil, err
} }
allowedBenefits, err := r.batchAllowedVipBenefits(ctx, userIDs, benefitCodes, nowMs)
if err != nil {
return nil, err
}
result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs)) result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs))
for _, userID := range userIDs { for _, userID := range userIDs {
result = append(result, resourcedomain.UserEquippedResources{ result = append(result, resourcedomain.UserEquippedResources{
UserID: userID, UserID: userID,
Resources: grouped[userID], Resources: grouped[userID],
AllowedVIPBenefitCodes: allowedBenefits[userID],
}) })
} }
return result, nil return result, nil

View File

@ -63,7 +63,12 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
} }
if _, err := db.ExecContext(ctx, ` if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policies ALTER TABLE host_agency_salary_policies
ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '不可变政策版本等于后台政策 ID' AFTER cycle_key`); err != nil && !isDuplicateColumnError(err) { ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '不可变政策版本首次等于后台政策 ID修订后递增' AFTER cycle_key`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE host_agency_salary_policy_levels
ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属不可变政策版本' AFTER policy_id`); err != nil && !isDuplicateColumnError(err) {
return err return err
} }
if _, err := db.ExecContext(ctx, ` if _, err := db.ExecContext(ctx, `
@ -266,6 +271,43 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
WHERE cycle_key = '' OR policy_version = 0`); err != nil { WHERE cycle_key = '' OR policy_version = 0`); err != nil {
return err return err
} }
// 旧结构每个 policy_id 只有一份快照;小型等级表按复合主键点关联回填,不触碰钱包流水和工资记录。
if _, err := db.ExecContext(ctx, `
UPDATE host_agency_salary_policy_levels level
JOIN host_agency_salary_policies policy
ON policy.app_code = level.app_code
AND policy.policy_id = level.policy_id
SET level.policy_version = policy.policy_version
WHERE level.policy_version = 0`); err != nil {
return err
}
if columns, err := tablePrimaryKeyColumns(ctx, db, "host_agency_salary_policies"); err != nil {
return err
} else if strings.Join(columns, ",") != "app_code,policy_id,policy_version" {
if _, err := db.ExecContext(ctx, `ALTER TABLE host_agency_salary_policies DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, policy_id, policy_version), ALGORITHM=INPLACE, LOCK=NONE`); err != nil {
return err
}
}
if columns, err := tablePrimaryKeyColumns(ctx, db, "host_agency_salary_policy_levels"); err != nil {
return err
} else if strings.Join(columns, ",") != "app_code,policy_id,policy_version,level_no" {
if _, err := db.ExecContext(ctx, `ALTER TABLE host_agency_salary_policy_levels DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, policy_id, policy_version, level_no), ALGORITHM=INPLACE, LOCK=NONE`); err != nil {
return err
}
}
levelIndexColumns, err := tableIndexColumns(ctx, db, "host_agency_salary_policy_levels", "idx_host_agency_salary_policy_level_required")
if err != nil {
return err
}
if strings.Join(levelIndexColumns, ",") != "app_code,policy_id,policy_version,status,required_diamonds" {
if len(levelIndexColumns) > 0 {
if _, err := db.ExecContext(ctx, `ALTER TABLE host_agency_salary_policy_levels DROP INDEX idx_host_agency_salary_policy_level_required, ADD INDEX idx_host_agency_salary_policy_level_required (app_code, policy_id, policy_version, status, required_diamonds), ALGORITHM=INPLACE, LOCK=NONE`); err != nil {
return err
}
} else if _, err := db.ExecContext(ctx, `ALTER TABLE host_agency_salary_policy_levels ADD INDEX idx_host_agency_salary_policy_level_required (app_code, policy_id, policy_version, status, required_diamonds), ALGORITHM=INPLACE, LOCK=NONE`); err != nil {
return err
}
}
if _, err := db.ExecContext(ctx, ` if _, err := db.ExecContext(ctx, `
INSERT IGNORE INTO host_salary_policy_cycle_bindings ( INSERT IGNORE INTO host_salary_policy_cycle_bindings (
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms

View File

@ -685,57 +685,10 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
return tx.Commit() return tx.Commit()
} }
accessArgs := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) levels, err := r.batchVipEffectiveLevelsFromSnapshot(ctx, tx, userIDs, nowMS)
accessArgs = append(accessArgs, nowMS, appcode.FromContext(ctx))
accessArgs = append(accessArgs, int64AnyArgs(userIDs)...)
accessArgs = append(accessArgs, resourcedomain.TypeVIPTrialCard, ledger.VipTrialCardStatusActive,
nowMS, nowMS, nowMS, nowMS)
accessRows, err := tx.QueryContext(ctx, `
SELECT membership.user_id, membership.level, 'paid' AS effective_source
FROM user_vip_memberships AS membership FORCE INDEX (PRIMARY)
WHERE membership.app_code = ? AND membership.user_id IN (`+placeholders(len(userIDs))+`)
AND membership.level > 0 AND membership.expires_at_ms > ?
UNION ALL
SELECT STRAIGHT_JOIN card.user_id, card.level, 'trial' AS effective_source
FROM user_resource_equipment AS equipment FORCE INDEX (PRIMARY)
JOIN user_vip_trial_cards AS card FORCE INDEX (uk_vip_trial_card_entitlement) ON card.app_code = equipment.app_code
AND card.user_id = equipment.user_id AND card.entitlement_id = equipment.entitlement_id
JOIN user_resource_entitlements AS entitlement ON entitlement.app_code = equipment.app_code
AND entitlement.user_id = equipment.user_id AND entitlement.entitlement_id = equipment.entitlement_id
JOIN resources AS resource ON resource.app_code = equipment.app_code AND resource.resource_id = equipment.resource_id
WHERE equipment.app_code = ? AND equipment.user_id IN (`+placeholders(len(userIDs))+`)
AND equipment.resource_type = ? AND card.status = ?
AND card.effective_at_ms <= ? AND card.expires_at_ms > ?
AND entitlement.status = 'active' AND entitlement.effective_at_ms <= ?
AND (entitlement.expires_at_ms = 0 OR entitlement.expires_at_ms > ?)
AND entitlement.remaining_quantity > 0 AND resource.status = 'active'`, accessArgs...)
if err != nil { if err != nil {
return err return err
} }
levels := make(map[int64]vipBatchEffectiveLevels, len(userIDs))
for accessRows.Next() {
var userID int64
var level int32
var source string
if err := accessRows.Scan(&userID, &level, &source); err != nil {
_ = accessRows.Close()
return err
}
access := levels[userID]
if source == ledger.VipEffectiveSourceTrial {
access.Trial = level
} else {
access.Paid = level
}
levels[userID] = access
}
if err := accessRows.Err(); err != nil {
_ = accessRows.Close()
return err
}
if err := accessRows.Close(); err != nil {
return err
}
filterVipEquipmentByAccess(grouped, historical, bindings, levels, program.TrialCardEnabled) filterVipEquipmentByAccess(grouped, historical, bindings, levels, program.TrialCardEnabled)
return tx.Commit() return tx.Commit()
} }

View File

@ -4,12 +4,14 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
"strings"
"time"
) )
type vipProgramScanner interface { type vipProgramScanner interface {
@ -79,6 +81,194 @@ func (r *Repository) GetVipState(ctx context.Context, userID int64) (ledger.VipS
return r.getVipStateFromSnapshot(ctx, tx, userID, nowMS, config) return r.getVipStateFromSnapshot(ctx, tx, userID, nowMS, config)
} }
// batchAllowedVipBenefits 在同一个只读快照内批量计算展示链路需要的最终权益。
// 查询数量固定为 program、权益矩阵、用户 effective level、用户设置四次且用户事实均以
// (app_code,user_id) 索引收敛,不能退化成榜单每个用户各调一次 CheckVipBenefit。
func (r *Repository) batchAllowedVipBenefits(ctx context.Context, userIDs []int64, rawBenefitCodes []string, nowMS int64) (map[int64][]string, error) {
result := make(map[int64][]string, len(userIDs))
userIDs = compactPositiveInt64s(userIDs)
benefitCodes := normalizeVipBenefitCodes(rawBenefitCodes)
if len(userIDs) == 0 || len(benefitCodes) == 0 {
return result, nil
}
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
program, err := r.requireVipProgramConfig(ctx, tx)
if err != nil {
return nil, err
}
if program.Status != ledger.VipStatusActive {
return result, tx.Commit()
}
type benefitAccess struct {
TrialEnabled bool
}
benefits := make(map[string]benefitAccess, len(benefitCodes)*int(program.LevelCount))
benefitArgs := append([]any{appcode.FromContext(ctx)}, stringAnyArgs(benefitCodes)...)
benefitArgs = append(benefitArgs, ledger.VipStatusActive)
benefitRows, err := tx.QueryContext(ctx, `
SELECT level, benefit_code, trial_enabled
FROM vip_level_benefits FORCE INDEX (idx_vip_benefit_code)
WHERE app_code = ? AND benefit_code IN (`+placeholders(len(benefitCodes))+`)
AND status = ?`, benefitArgs...)
if err != nil {
return nil, err
}
for benefitRows.Next() {
var level int32
var code string
var trialEnabled bool
if err := benefitRows.Scan(&level, &code, &trialEnabled); err != nil {
_ = benefitRows.Close()
return nil, err
}
benefits[fmt.Sprintf("%d|%s", level, strings.ToLower(strings.TrimSpace(code)))] = benefitAccess{TrialEnabled: trialEnabled}
}
if err := benefitRows.Err(); err != nil {
_ = benefitRows.Close()
return nil, err
}
if err := benefitRows.Close(); err != nil {
return nil, err
}
levels, err := r.batchVipEffectiveLevelsFromSnapshot(ctx, tx, userIDs, nowMS)
if err != nil {
return nil, err
}
settings := make(map[int64]ledger.VipUserSettings, len(userIDs))
for _, userID := range userIDs {
settings[userID] = ledger.DefaultVipUserSettings(appcode.FromContext(ctx), userID)
}
settingArgs := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
settingRows, err := tx.QueryContext(ctx, `
SELECT user_id, room_entry_notice_enabled, online_global_notice_enabled,
hide_profile_data_enabled, anonymous_profile_visit_enabled,
leaderboard_invisible_enabled, updated_at_ms
FROM user_vip_settings FORCE INDEX (PRIMARY)
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, settingArgs...)
if err != nil {
return nil, err
}
for settingRows.Next() {
var value ledger.VipUserSettings
value.AppCode = appcode.FromContext(ctx)
if err := settingRows.Scan(
&value.UserID, &value.RoomEntryNoticeEnabled, &value.OnlineGlobalNoticeEnabled,
&value.HideProfileDataEnabled, &value.AnonymousProfileVisitEnabled,
&value.LeaderboardInvisibleEnabled, &value.UpdatedAtMS,
); err != nil {
_ = settingRows.Close()
return nil, err
}
settings[value.UserID] = value
}
if err := settingRows.Err(); err != nil {
_ = settingRows.Close()
return nil, err
}
if err := settingRows.Close(); err != nil {
return nil, err
}
for _, userID := range userIDs {
access := levels[userID]
effectiveLevel := access.Paid
effectiveSource := ledger.VipEffectiveSourcePaid
if program.TrialCardEnabled && access.Trial > 0 {
effectiveLevel = access.Trial
effectiveSource = ledger.VipEffectiveSourceTrial
}
if effectiveLevel <= 0 {
continue
}
for _, code := range benefitCodes {
benefit, exists := benefits[fmt.Sprintf("%d|%s", effectiveLevel, code)]
if !exists || !settings[userID].AllowsBenefitSetting(code) {
continue
}
// 体验卡只能执行该等级显式允许 trial 的权益;金币返现保持付费会员硬边界。
if effectiveSource == ledger.VipEffectiveSourceTrial && (!benefit.TrialEnabled || code == ledger.VipBenefitCodeDailyCoinRebate) {
continue
}
result[userID] = append(result[userID], code)
}
}
return result, tx.Commit()
}
func normalizeVipBenefitCodes(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
code := strings.ToLower(strings.TrimSpace(value))
if code == "" {
continue
}
if _, exists := seen[code]; exists {
continue
}
seen[code] = struct{}{}
result = append(result, code)
}
return result
}
// batchVipEffectiveLevelsFromSnapshot 复用 GetVipState 的 paid/trial 优先级事实,但按一批用户
// 只读取一次 membership 和一次已佩戴体验卡集合。所有时间条件与单用户读取保持一致。
func (r *Repository) batchVipEffectiveLevelsFromSnapshot(ctx context.Context, querier vipSnapshotQuerier, userIDs []int64, nowMS int64) (map[int64]vipBatchEffectiveLevels, error) {
accessArgs := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
accessArgs = append(accessArgs, nowMS, appcode.FromContext(ctx))
accessArgs = append(accessArgs, int64AnyArgs(userIDs)...)
accessArgs = append(accessArgs, resourcedomain.TypeVIPTrialCard, ledger.VipTrialCardStatusActive,
nowMS, nowMS, nowMS, nowMS)
rows, err := querier.QueryContext(ctx, `
SELECT membership.user_id, membership.level, 'paid' AS effective_source
FROM user_vip_memberships AS membership FORCE INDEX (PRIMARY)
WHERE membership.app_code = ? AND membership.user_id IN (`+placeholders(len(userIDs))+`)
AND membership.level > 0 AND membership.expires_at_ms > ?
UNION ALL
SELECT STRAIGHT_JOIN card.user_id, card.level, 'trial' AS effective_source
FROM user_resource_equipment AS equipment FORCE INDEX (PRIMARY)
JOIN user_vip_trial_cards AS card FORCE INDEX (uk_vip_trial_card_entitlement) ON card.app_code = equipment.app_code
AND card.user_id = equipment.user_id AND card.entitlement_id = equipment.entitlement_id
JOIN user_resource_entitlements AS entitlement ON entitlement.app_code = equipment.app_code
AND entitlement.user_id = equipment.user_id AND entitlement.entitlement_id = equipment.entitlement_id
JOIN resources AS resource ON resource.app_code = equipment.app_code AND resource.resource_id = equipment.resource_id
WHERE equipment.app_code = ? AND equipment.user_id IN (`+placeholders(len(userIDs))+`)
AND equipment.resource_type = ? AND card.status = ?
AND card.effective_at_ms <= ? AND card.expires_at_ms > ?
AND entitlement.status = 'active' AND entitlement.effective_at_ms <= ?
AND (entitlement.expires_at_ms = 0 OR entitlement.expires_at_ms > ?)
AND entitlement.remaining_quantity > 0 AND resource.status = 'active'`, accessArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
levels := make(map[int64]vipBatchEffectiveLevels, len(userIDs))
for rows.Next() {
var userID int64
var level int32
var source string
if err := rows.Scan(&userID, &level, &source); err != nil {
return nil, err
}
access := levels[userID]
if source == ledger.VipEffectiveSourceTrial {
access.Trial = level
} else {
access.Paid = level
}
levels[userID] = access
}
return levels, rows.Err()
}
// requireVipProgramConfig 从调用者指定的快照读取 App 配置,并把缺迁移/错 app_code 明确映射为业务错误。 // requireVipProgramConfig 从调用者指定的快照读取 App 配置,并把缺迁移/错 app_code 明确映射为业务错误。
func (r *Repository) requireVipProgramConfig(ctx context.Context, querier vipSnapshotQuerier) (ledger.VipProgramConfig, error) { func (r *Repository) requireVipProgramConfig(ctx context.Context, querier vipSnapshotQuerier) (ledger.VipProgramConfig, error) {
config, err := r.queryVipProgramConfig(ctx, querier.QueryRowContext(ctx, vipProgramSelectSQL(), appcode.FromContext(ctx))) config, err := r.queryVipProgramConfig(ctx, querier.QueryRowContext(ctx, vipProgramSelectSQL(), appcode.FromContext(ctx)))

View File

@ -1073,7 +1073,7 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
if err != nil { if err != nil {
r.t.Fatalf("seed host salary policy failed: %v", err) r.t.Fatalf("seed host salary policy failed: %v", err)
} }
if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, appCode, policy.PolicyID); err != nil { if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ? AND policy_version = ?`, appCode, policy.PolicyID, policy.PolicyVersion); err != nil {
r.t.Fatalf("clear host salary policy levels failed: %v", err) r.t.Fatalf("clear host salary policy levels failed: %v", err)
} }
for _, level := range policy.Levels { for _, level := range policy.Levels {
@ -1082,10 +1082,10 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
} }
if _, err := r.schema.DB.ExecContext(context.Background(), ` if _, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO host_agency_salary_policy_levels ( INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor, app_code, policy_id, policy_version, level_no, required_diamonds, host_salary_usd_minor,
host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor, appCode, policy.PolicyID, policy.PolicyVersion, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs, level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs,
); err != nil { ); err != nil {
r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err) r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err)

View File

@ -418,6 +418,7 @@ func (s *Server) BatchGetUserEquippedResources(ctx context.Context, req *walletv
AppCode: req.GetAppCode(), AppCode: req.GetAppCode(),
UserIDs: req.GetUserIds(), UserIDs: req.GetUserIds(),
ResourceTypes: req.GetResourceTypes(), ResourceTypes: req.GetResourceTypes(),
VIPBenefitCodes: req.GetVipBenefitCodes(),
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
@ -427,6 +428,7 @@ func (s *Server) BatchGetUserEquippedResources(ctx context.Context, req *walletv
user := &walletv1.UserEquippedResources{ user := &walletv1.UserEquippedResources{
UserId: item.UserID, UserId: item.UserID,
Resources: make([]*walletv1.UserResourceEntitlement, 0, len(item.Resources)), Resources: make([]*walletv1.UserResourceEntitlement, 0, len(item.Resources)),
AllowedVipBenefitCodes: append([]string(nil), item.AllowedVIPBenefitCodes...),
} }
for _, resource := range item.Resources { for _, resource := range item.Resources {
user.Resources = append(user.Resources, userResourceToProto(resource)) user.Resources = append(user.Resources, userResourceToProto(resource))

View File

@ -238,16 +238,16 @@ func (r *runner) ensureSalaryConfig(ctx context.Context) error {
} }
_, err = r.walletDB.ExecContext(ctx, ` _, err = r.walletDB.ExecContext(ctx, `
INSERT INTO host_agency_salary_policy_levels ( INSERT INTO host_agency_salary_policy_levels (
app_code, policy_id, level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, app_code, policy_id, policy_version, level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, 1, 100, 150, 0, 50, 'active', 1, ?, ?) ) VALUES (?, ?, ?, 1, 100, 150, 0, 50, 'active', 1, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
required_diamonds = VALUES(required_diamonds), required_diamonds = VALUES(required_diamonds),
host_salary_usd_minor = VALUES(host_salary_usd_minor), host_salary_usd_minor = VALUES(host_salary_usd_minor),
agency_salary_usd_minor = VALUES(agency_salary_usd_minor), agency_salary_usd_minor = VALUES(agency_salary_usd_minor),
status = 'active', status = 'active',
updated_at_ms = VALUES(updated_at_ms)`, updated_at_ms = VALUES(updated_at_ms)`,
r.cfg.appCode, policyID, now, now) r.cfg.appCode, policyID, policyID, now, now)
if err != nil { if err != nil {
return fmt.Errorf("seed salary policy level: %w", err) return fmt.Errorf("seed salary policy level: %w", err)
} }