相关bug修复
This commit is contained in:
parent
838a76f0cc
commit
f354aa7d88
@ -10212,6 +10212,9 @@ type BatchGetUserEquippedResourcesRequest struct {
|
||||
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"`
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -10274,10 +10277,19 @@ func (x *BatchGetUserEquippedResourcesRequest) GetResourceTypes() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *BatchGetUserEquippedResourcesRequest) GetVipBenefitCodes() []string {
|
||||
if x != nil {
|
||||
return x.VipBenefitCodes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserEquippedResources struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
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"`
|
||||
// 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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -10326,6 +10338,13 @@ func (x *UserEquippedResources) GetResources() []*UserResourceEntitlement {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *UserEquippedResources) GetAllowedVipBenefitCodes() []string {
|
||||
if x != nil {
|
||||
return x.AllowedVipBenefitCodes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BatchGetUserEquippedResourcesResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
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" +
|
||||
"unequipped\x18\x02 \x01(\bR\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" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
|
||||
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x19\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" +
|
||||
"\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" +
|
||||
"\x05users\x18\x01 \x03(\v2&.hyapp.wallet.v1.UserEquippedResourcesR\x05users\"\xc4\x01\n" +
|
||||
"\x19ListResourceGrantsRequest\x12\x1d\n" +
|
||||
|
||||
@ -1207,11 +1207,16 @@ message BatchGetUserEquippedResourcesRequest {
|
||||
string app_code = 2;
|
||||
repeated int64 user_ids = 3;
|
||||
repeated string resource_types = 4;
|
||||
// vip_benefit_codes 只在调用方需要批量装配展示权限时传入;wallet 会同时校验
|
||||
// 当前 effective VIP、精确等级权益、体验卡可用性和用户开关,避免调用方逐用户 N+1 校验。
|
||||
repeated string vip_benefit_codes = 5;
|
||||
}
|
||||
|
||||
message UserEquippedResources {
|
||||
int64 user_id = 1;
|
||||
repeated UserResourceEntitlement resources = 2;
|
||||
// allowed_vip_benefit_codes 只返回请求中最终可执行的权益;设置值本身不能越权进入此集合。
|
||||
repeated string allowed_vip_benefit_codes = 3;
|
||||
}
|
||||
|
||||
message BatchGetUserEquippedResourcesResponse {
|
||||
|
||||
@ -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/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/018_host_salary_policy_revisions.sql"
|
||||
"services/lucky-gift-service/deploy/mysql/migrations/013_jackpot_token_version_rate_limit.sql"
|
||||
)
|
||||
|
||||
|
||||
@ -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"`
|
||||
// RegionIDs 是这份政策实际覆盖的区域集合;RegionID 只保留首个值用于旧客户端和旧索引兼容。
|
||||
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"`
|
||||
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"`
|
||||
|
||||
@ -201,14 +201,12 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
originalPolicyVersion := item.PolicyVersion
|
||||
runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item)
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished {
|
||||
// 运行侧已经按周期绑定该版本;编辑会让历史结算口径漂移,只能为新周期新建政策。
|
||||
return policyDTO{}, errors.New("已发布的周期政策版本不可修改,请为新周期创建政策")
|
||||
}
|
||||
publishedRevision := item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished
|
||||
if strings.TrimSpace(req.PolicyType) == "" {
|
||||
// Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。
|
||||
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 {
|
||||
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.PolicyType = updated.PolicyType
|
||||
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.Description = updated.Description
|
||||
item.UpdatedByAdminID = actorID
|
||||
// 编辑后运行快照可能已经落后于 admin 配置,因此保留上次发布时间但把状态重新标成 draft。
|
||||
// 保存修订只更新 admin 草稿;运营重新发布后 wallet 才原子写入新快照并切换当前周期绑定。
|
||||
item.PublishStatus = publishStatusDraft
|
||||
item.PublishError = ""
|
||||
// 等级明细按整包替换处理:前端提交的是一套完整政策梯度,不做局部 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 {
|
||||
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 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 {
|
||||
if strings.TrimSpace(requested) == "" {
|
||||
return existing
|
||||
@ -284,7 +326,7 @@ func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 {
|
||||
if item.PublishStatus == publishStatusPublished {
|
||||
// 重复点击发布按幂等成功返回;不再覆盖运行表,也不生成新的周期绑定。
|
||||
return policyFromModel(item), nil
|
||||
}
|
||||
@ -314,6 +356,9 @@ func normalizePolicyPersistenceError(err error) error {
|
||||
if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
|
||||
return errors.New("同一区域同一工资周期只能存在一个启用政策")
|
||||
}
|
||||
if errors.Is(err, repository.ErrHostAgencySalaryPolicyConcurrentUpdate) {
|
||||
return errors.New("政策已被其他人修改,请刷新后重试")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@ -382,13 +427,13 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
|
||||
// 仅兼容迁移前创建但尚未发布的草稿;新记录在 admin 仓储创建事务中固定为 policy ID。
|
||||
policyVersion = uint64(item.ID)
|
||||
}
|
||||
// 跨库状态回写失败后重试时,wallet 可能已经拥有完整快照;内容一致视为幂等成功,
|
||||
// 但任何业务字段或等级不一致都拒绝覆盖,继续保持已发布版本不可变。
|
||||
// 跨库状态回写失败后重试时,wallet 可能已经拥有同版本快照;内容一致视为幂等成功,
|
||||
// 新修订使用新的 policy_version,因此旧版本始终保留且任何同版本内容都不能被覆盖。
|
||||
if err := ensureRuntimeHostSalaryPolicySnapshot(ctx, tx, item, policyVersion, publishedAtMS); err != nil {
|
||||
return err
|
||||
}
|
||||
// 每个区域各自拥有不可覆盖的周期绑定,但同一政策快照和等级只写入一次,避免多区域版本漂移。
|
||||
// 系统继承绑定的 source_cycle_key 指向上月,允许随后自动生成或人工创建的本月独立版本接管;显式绑定永不覆盖。
|
||||
// 每个区域各自拥有周期绑定;继承版本可由本月显式版本接管,同一政策的更高修订版本也可切换当前绑定。
|
||||
// 旧运行快照不删除,工资记录中的 policy_version 仍能准确指回当时使用的口径。
|
||||
for _, regionID := range item.RegionIDs {
|
||||
if err := bindExplicitHostSalaryPolicy(ctx, tx, item, regionID, policyVersion, publishedAtMS); err != nil {
|
||||
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 {
|
||||
// 先以完整快照尝试原子占位;主键已存在时只做 policy_id 自赋值,绝不改写任何业务字段。
|
||||
// 先以完整快照尝试原子占位;复合主键包含 policy_version,已存在时只做自赋值,绝不改写业务字段。
|
||||
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
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,
|
||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
|
||||
FROM host_agency_salary_policies
|
||||
WHERE app_code = ? AND policy_id = ?
|
||||
FOR UPDATE`, item.AppCode, item.ID).Scan(
|
||||
WHERE app_code = ? AND policy_id = ? AND policy_version = ?
|
||||
FOR UPDATE`, item.AppCode, item.ID, policyVersion).Scan(
|
||||
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status,
|
||||
&existing.SettlementMode, &existing.SettlementTriggerMode,
|
||||
&existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD,
|
||||
@ -484,7 +529,7 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID)
|
||||
existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID, policyVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -495,17 +540,17 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
|
||||
// 运行表保存美分整数,避免 wallet-service 结算时再次解析 decimal 字符串造成口径漂移。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode, item.ID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.AppCode, item.ID, policyVersion, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder,
|
||||
publishedAtMS, publishedAtMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID)
|
||||
existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID, policyVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -554,13 +599,13 @@ func runtimeHostSalaryLevelsFromModel(levels []model.HostAgencySalaryLevel) ([]r
|
||||
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, `
|
||||
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
|
||||
agency_salary_usd_minor, status, sort_order
|
||||
FROM host_agency_salary_policy_levels
|
||||
WHERE app_code = ? AND policy_id = ?
|
||||
ORDER BY level_no ASC`, appCode, policyID)
|
||||
WHERE app_code = ? AND policy_id = ? AND policy_version = ?
|
||||
ORDER BY level_no ASC`, appCode, policyID, policyVersion)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return errors.New("该区域工资月已经绑定其他显式政策版本")
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package hostagencypolicy
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
"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}},
|
||||
}
|
||||
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",
|
||||
"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",
|
||||
}).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",
|
||||
}).AddRow(1, 100, 150, 20, 50, "active", 1))
|
||||
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) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@ -292,7 +321,7 @@ func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
|
||||
t.Fatalf("open gorm: %v", err)
|
||||
}
|
||||
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))
|
||||
if err := store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished("fami", 91, "duplicate publish", 7); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -556,7 +556,7 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
|
||||
if strings.TrimSpace(policy.PolicyType) != policyTypeSalaryDiamond {
|
||||
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 {
|
||||
return hostPolicy{}, false, err
|
||||
}
|
||||
@ -564,12 +564,12 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
|
||||
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, `
|
||||
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, agency_salary_usd_minor
|
||||
FROM host_agency_salary_policy_levels
|
||||
WHERE app_code = ? AND policy_id = ? AND status = 'active'
|
||||
ORDER BY required_diamonds ASC, level_no ASC`, appCode, policyID)
|
||||
WHERE app_code = ? AND policy_id = ? AND policy_version = ? AND status = 'active'
|
||||
ORDER BY required_diamonds ASC, level_no ASC`, appCode, policyID, policyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -9,9 +9,13 @@ import (
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"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 {
|
||||
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 {
|
||||
// 同一已发布政策可在当前月生成新版本;先锁主行并校验调用方读到的版本,避免两个运营提交互相覆盖配置和等级。
|
||||
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;主表保存成功后删除旧明细,再插入新梯度。
|
||||
levels := append([]model.HostAgencySalaryLevel(nil), item.Levels...)
|
||||
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 {
|
||||
// wallet 提交与 admin 状态回写跨库,失败回写必须带状态条件:并发成功发布已经写入 published 后,
|
||||
// 较晚返回的重复发布错误不能把真实成功状态覆盖成 failed,也不能清空成功时间。
|
||||
// 较晚返回的重复发布错误不能把真实成功状态覆盖成 failed;published_at_ms 保留上次成功时间,表明该政策身份已经发布过。
|
||||
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{
|
||||
"publish_status": "failed",
|
||||
"publish_error": strings.TrimSpace(publishError),
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/userleaderboard"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/client"
|
||||
@ -18,7 +19,7 @@ type Handler struct {
|
||||
achievementClient client.AchievementClient
|
||||
userProfileClient UserProfileReader
|
||||
roomQueryClient client.RoomQueryClient
|
||||
walletClient client.WalletClient
|
||||
walletClient ActivityWalletClient
|
||||
userLeaderboard UserLeaderboardStore
|
||||
runtimeCapabilities appconfig.RuntimeCapabilityReader
|
||||
registrationReward client.RegistrationRewardClient
|
||||
@ -49,6 +50,15 @@ type UserProfileReader interface {
|
||||
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 {
|
||||
TaskClient client.TaskClient
|
||||
GrowthLevelClient client.GrowthLevelClient
|
||||
|
||||
@ -2,6 +2,7 @@ package activityapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -48,6 +49,7 @@ type userLeaderboardResponse struct {
|
||||
type userLeaderboardItem struct {
|
||||
Rank int64 `json:"rank"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
IdentityHidden bool `json:"identity_hidden,omitempty"`
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
GiftValue int64 `json:"gift_value"`
|
||||
GiftCount int64 `json:"gift_count"`
|
||||
@ -284,17 +286,19 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortBadgeCovers, err := h.userLeaderboardShortBadgeCovers(request, userIDs)
|
||||
presentation, err := h.userLeaderboardPresentation(request, userIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range items {
|
||||
applyUserLeaderboardProfile(&items[i], profiles)
|
||||
applyUserLeaderboardShortBadgeCovers(&items[i], shortBadgeCovers)
|
||||
applyUserLeaderboardShortBadgeCovers(&items[i], presentation.ShortBadgeCovers)
|
||||
applyUserLeaderboardPrivacy(&items[i], presentation.LeaderboardInvisible)
|
||||
}
|
||||
if myRank != nil {
|
||||
applyUserLeaderboardProfile(myRank, profiles)
|
||||
applyUserLeaderboardShortBadgeCovers(myRank, shortBadgeCovers)
|
||||
applyUserLeaderboardShortBadgeCovers(myRank, presentation.ShortBadgeCovers)
|
||||
applyUserLeaderboardPrivacy(myRank, presentation.LeaderboardInvisible)
|
||||
}
|
||||
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) {
|
||||
result := make(map[int64][]string)
|
||||
type userLeaderboardPresentation struct {
|
||||
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)
|
||||
if h.walletClient == nil || len(userIDs) == 0 {
|
||||
// 榜单基础资料不依赖装扮服务;未接 wallet 的环境保持兼容,只是不返回短徽章。
|
||||
if len(userIDs) == 0 {
|
||||
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{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserIds: userIDs,
|
||||
ResourceTypes: []string{"badge"},
|
||||
VipBenefitCodes: []string{"leaderboard_invisible"},
|
||||
})
|
||||
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 {
|
||||
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 {
|
||||
return result
|
||||
}
|
||||
@ -421,6 +444,12 @@ func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEqui
|
||||
if user.GetUserId() <= 0 {
|
||||
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{})
|
||||
for _, entitlement := range user.GetResources() {
|
||||
resource := entitlement.GetResource()
|
||||
@ -436,12 +465,29 @@ func userLeaderboardShortBadgeCoversFromResponse(resp *walletv1.BatchGetUserEqui
|
||||
continue
|
||||
}
|
||||
seen[coverURL] = struct{}{}
|
||||
result[user.GetUserId()] = append(result[user.GetUserId()], coverURL)
|
||||
result.ShortBadgeCovers[user.GetUserId()] = append(result.ShortBadgeCovers[user.GetUserId()], coverURL)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
metadata := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(rawMetadata)), &metadata); err != nil {
|
||||
|
||||
@ -18,6 +18,35 @@ import (
|
||||
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) {
|
||||
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) {
|
||||
response := &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{{
|
||||
UserId: 20002,
|
||||
AllowedVipBenefitCodes: []string{"leaderboard_invisible"},
|
||||
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: 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 {
|
||||
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) {
|
||||
@ -147,9 +186,14 @@ func TestListUserLeaderboardsReadsRedisStore(t *testing.T) {
|
||||
ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeViewerRegion,
|
||||
ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional,
|
||||
}}
|
||||
walletReader := &fakeUserLeaderboardPresentationReader{response: &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{
|
||||
{UserId: 20002, AllowedVipBenefitCodes: []string{"leaderboard_invisible"}},
|
||||
{UserId: 10001},
|
||||
}}}
|
||||
handler := &Handler{
|
||||
userLeaderboard: store,
|
||||
runtimeCapabilities: capabilityReader,
|
||||
walletClient: walletReader,
|
||||
userProfileClient: &fakeInviteActivityUserProfileClient{user: &userv1.User{
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
@ -217,6 +264,7 @@ func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) {
|
||||
}}
|
||||
handler := &Handler{
|
||||
userLeaderboard: store,
|
||||
walletClient: &fakeUserLeaderboardPresentationReader{},
|
||||
runtimeCapabilities: &fakeRuntimeCapabilityReader{capabilities: appconfig.RuntimeCapabilities{
|
||||
ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal,
|
||||
ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional,
|
||||
|
||||
@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID,来自后台配置',
|
||||
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 '政策名称',
|
||||
policy_type VARCHAR(32) NOT NULL DEFAULT 'SALARY_DIAMOND' COMMENT '政策类型:SALARY_DIAMOND/POINT_DIAMOND',
|
||||
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 表示长期有效',
|
||||
created_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)
|
||||
) 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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表';
|
||||
|
||||
-- 周期绑定一旦插入不可更新;复合主键保证新月份政策无法覆盖历史月份结算口径。
|
||||
-- 周期绑定按月唯一;历史月不更新,当前/未来月同一逻辑政策发布更高版本时只切换版本号。
|
||||
CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
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 (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
policy_id BIGINT UNSIGNED NOT NULL COMMENT '工资政策 ID',
|
||||
policy_version BIGINT UNSIGNED NOT NULL COMMENT '所属不可变政策版本',
|
||||
level_no INT NOT NULL COMMENT '等级',
|
||||
required_diamonds BIGINT NOT NULL 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 '排序权重',
|
||||
created_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),
|
||||
KEY idx_host_agency_salary_policy_level_required (app_code, policy_id, status, required_diamonds)
|
||||
PRIMARY KEY (app_code, policy_id, policy_version, level_no),
|
||||
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='主播代理工资政策等级运行快照表';
|
||||
|
||||
-- 结算进度按主播+月份独立记录累计已发值;日结/半月结只更新累计,月底清算只打 cleared 标记。
|
||||
|
||||
@ -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;
|
||||
@ -76,9 +76,11 @@ type BatchGetUserEquippedResourcesQuery struct {
|
||||
AppCode string
|
||||
UserIDs []int64
|
||||
ResourceTypes []string
|
||||
VIPBenefitCodes []string
|
||||
}
|
||||
|
||||
type UserEquippedResources struct {
|
||||
UserID int64
|
||||
Resources []UserResourceEntitlement
|
||||
AllowedVIPBenefitCodes []string
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||||
"hyapp/services/wallet-service/internal/testutil/mysqltest"
|
||||
)
|
||||
@ -128,6 +129,22 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
|
||||
if err != nil || !roomCheck.Allowed {
|
||||
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。
|
||||
const trialUserID int64 = 940002
|
||||
|
||||
@ -530,7 +530,7 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar
|
||||
if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond && policy.PolicyType != ledger.HostPolicyTypePointDiamond {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 依赖这个顺序找到最高已达档位。
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
|
||||
agency_salary_usd_minor, status, sort_order
|
||||
FROM host_agency_salary_policy_levels
|
||||
WHERE app_code = ? AND policy_id = ? AND status = 'active'
|
||||
ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID)
|
||||
WHERE app_code = ? AND policy_id = ? AND policy_version = ? AND status = 'active'
|
||||
ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID, policyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -523,6 +523,11 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
|
||||
if len(userIDs) == 0 {
|
||||
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)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
allowedBenefits, err := r.batchAllowedVipBenefits(ctx, userIDs, benefitCodes, nowMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
result = append(result, resourcedomain.UserEquippedResources{
|
||||
UserID: userID,
|
||||
Resources: grouped[userID],
|
||||
AllowedVIPBenefitCodes: allowedBenefits[userID],
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@ -63,7 +63,12 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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, `
|
||||
INSERT IGNORE INTO host_salary_policy_cycle_bindings (
|
||||
app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
|
||||
|
||||
@ -685,57 +685,10 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
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)
|
||||
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...)
|
||||
levels, err := r.batchVipEffectiveLevelsFromSnapshot(ctx, tx, userIDs, nowMS)
|
||||
if err != nil {
|
||||
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)
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 明确映射为业务错误。
|
||||
func (r *Repository) requireVipProgramConfig(ctx context.Context, querier vipSnapshotQuerier) (ledger.VipProgramConfig, error) {
|
||||
config, err := r.queryVipProgramConfig(ctx, querier.QueryRowContext(ctx, vipProgramSelectSQL(), appcode.FromContext(ctx)))
|
||||
|
||||
@ -1073,7 +1073,7 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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(), `
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
appCode, policy.PolicyID, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
appCode, policy.PolicyID, policy.PolicyVersion, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
|
||||
level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder, nowMs, nowMs,
|
||||
); err != nil {
|
||||
r.t.Fatalf("seed host salary policy level %d failed: %v", level.LevelNo, err)
|
||||
|
||||
@ -418,6 +418,7 @@ func (s *Server) BatchGetUserEquippedResources(ctx context.Context, req *walletv
|
||||
AppCode: req.GetAppCode(),
|
||||
UserIDs: req.GetUserIds(),
|
||||
ResourceTypes: req.GetResourceTypes(),
|
||||
VIPBenefitCodes: req.GetVipBenefitCodes(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
@ -427,6 +428,7 @@ func (s *Server) BatchGetUserEquippedResources(ctx context.Context, req *walletv
|
||||
user := &walletv1.UserEquippedResources{
|
||||
UserId: item.UserID,
|
||||
Resources: make([]*walletv1.UserResourceEntitlement, 0, len(item.Resources)),
|
||||
AllowedVipBenefitCodes: append([]string(nil), item.AllowedVIPBenefitCodes...),
|
||||
}
|
||||
for _, resource := range item.Resources {
|
||||
user.Resources = append(user.Resources, userResourceToProto(resource))
|
||||
|
||||
@ -238,16 +238,16 @@ func (r *runner) ensureSalaryConfig(ctx context.Context) error {
|
||||
}
|
||||
_, err = r.walletDB.ExecContext(ctx, `
|
||||
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
|
||||
) VALUES (?, ?, 1, 100, 150, 0, 50, 'active', 1, ?, ?)
|
||||
) VALUES (?, ?, ?, 1, 100, 150, 0, 50, 'active', 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
required_diamonds = VALUES(required_diamonds),
|
||||
host_salary_usd_minor = VALUES(host_salary_usd_minor),
|
||||
agency_salary_usd_minor = VALUES(agency_salary_usd_minor),
|
||||
status = 'active',
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
r.cfg.appCode, policyID, now, now)
|
||||
r.cfg.appCode, policyID, policyID, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed salary policy level: %w", err)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user