package service import ( "context" "fmt" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" ) const ( // tieredPrivilegeVIPProgram 是 P1 权益制 VIP 的稳定策略名。App 选择哪套体系由 wallet 配置决定, // room-service 禁止再按 fami/lalu 写死分支,否则后续 App 无法复用同一房间权限实现。 tieredPrivilegeVIPProgram = "tiered_privilege_v1" vipBenefitCustomRoomBackground = "custom_room_background" vipBenefitRoomImageMessage = "room_image_message" vipBenefitRoomBorder = "room_border" vipBenefitColoredRoomName = "colored_room_name" vipBenefitAntiKick = "anti_kick" vipBenefitAntiMute = "anti_mute" vipBenefitRoomEntryNotice = "room_entry_notice" // VIP 查询会进入进房展示或低频房管动作,必须给同步依赖设置短上限,避免 wallet 抖动长期占住房间请求。 roomVIPLookupTimeout = time.Second ) // effectiveVIPAccess 是 room-service 从 wallet effective state 裁剪出的房间权限快照。 // paid/trial 合并、等级继承和体验卡过滤都已经由 wallet 完成,这里只做精确 benefit_code membership 判断。 type effectiveVIPAccess struct { programType string level int32 name string benefits map[string]struct{} roomEntryNoticeSettingEnabled bool } func (access effectiveVIPAccess) hasBenefit(code string) bool { _, exists := access.benefits[normalizeVIPCode(code)] return exists } func (access effectiveVIPAccess) roomEntryNoticeEnabled() bool { return access.roomEntryNoticeSettingEnabled && access.hasBenefit(vipBenefitRoomEntryNotice) } func (access effectiveVIPAccess) roomSnapshot() *roomv1.RoomEffectiveVipSnapshot { return &roomv1.RoomEffectiveVipSnapshot{ ProgramType: access.programType, Level: access.level, Name: access.name, RoomEntryNoticeEnabled: access.roomEntryNoticeEnabled(), } } // loadEffectiveVIPAccess 调 wallet GetMyVip 一次并只接受完整 effective state。 // 空 VIP 是合法结果,但 program_config 不能为空;缺配置时无法判断当前 App 是否应执行 P1 权限,必须按上游失败处理。 func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string, userID int64) (effectiveVIPAccess, error) { if s == nil || s.wallet == nil { return effectiveVIPAccess{}, xerr.New(xerr.Unavailable, "wallet vip client is unavailable") } if userID <= 0 { return effectiveVIPAccess{}, xerr.New(xerr.InvalidArgument, "vip user_id is required") } lookupCtx, cancel := context.WithTimeout(ctx, roomVIPLookupTimeout) defer cancel() response, err := s.wallet.GetMyVip(lookupCtx, &walletv1.GetMyVipRequest{ RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, }) if err != nil { return effectiveVIPAccess{}, xerr.New(xerr.Unavailable, fmt.Sprintf("get effective vip failed: %v", err)) } if response == nil || response.GetState() == nil || response.GetState().GetProgramConfig() == nil { return effectiveVIPAccess{}, xerr.New(xerr.Unavailable, "wallet vip state is incomplete") } state := response.GetState() access := effectiveVIPAccess{ programType: normalizeVIPCode(state.GetProgramConfig().GetProgramType()), benefits: make(map[string]struct{}, len(state.GetEffectiveBenefits())), roomEntryNoticeSettingEnabled: true, } // 旧 wallet 在滚动升级期间没有 user_settings tag;nil 必须兼容为产品默认开启。 if settings := state.GetUserSettings(); settings != nil { access.roomEntryNoticeSettingEnabled = settings.GetRoomEntryNoticeEnabled() } if access.programType == "" { return effectiveVIPAccess{}, xerr.New(xerr.Unavailable, "wallet vip program is missing") } if effective := state.GetEffectiveVip(); effective != nil && effective.GetActive() { access.level = effective.GetLevel() access.name = strings.TrimSpace(effective.GetName()) } for _, benefit := range state.GetEffectiveBenefits() { if benefit == nil { continue } if code := normalizeVIPCode(benefit.GetBenefitCode()); code != "" { access.benefits[code] = struct{}{} } } return access, nil } // requireCustomRoomBackgroundBenefit 只对配置为 P1 权益制的 App 执行自定义背景门禁。 // action 由调用点传入精确动作,Flutter 才能区分上传、保存和切换失败并保留对应本地状态。 // legacy_timed 保持 Lalu 原行为;新 App 只需在 wallet 切换 program_type,无需再修改 room-service。 func (s *Service) requireCustomRoomBackgroundBenefit(ctx context.Context, requestID string, ownerUserID int64, action string) error { return s.requireVIPBenefit(ctx, requestID, ownerUserID, vipBenefitCustomRoomBackground, action) } // requireVIPBenefit 优先调用 wallet 的单项权益判定,并把升级页需要的等级事实放进错误 metadata。 // legacy_timed App 沿用旧行为;P1 App 在 wallet 不可用或没有权益时都失败关闭,避免故障窗口越权。 func (s *Service) requireVIPBenefit(ctx context.Context, requestID string, userID int64, benefitCode string, action string) error { if checker, ok := s.wallet.(integration.WalletVIPBenefitClient); ok { lookupCtx, cancel := context.WithTimeout(ctx, roomVIPLookupTimeout) defer cancel() response, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{ RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: normalizeVIPCode(benefitCode), }) if err != nil { // 对外错误只暴露稳定的业务语义,底层 gRPC 状态和地址不能进入 HTTP envelope。 return xerr.New(xerr.Unavailable, "check vip benefit failed") } if response == nil || response.GetState() == nil || response.GetState().GetProgramConfig() == nil { return xerr.New(xerr.Unavailable, "wallet vip benefit state is incomplete") } if normalizeVIPCode(response.GetState().GetProgramConfig().GetProgramType()) != tieredPrivilegeVIPProgram { return nil } if response.GetAllowed() { return nil } return xerr.NewWithMetadata(xerr.VIPBenefitRequired, "vip benefit is required", map[string]string{ "benefit_code": normalizeVIPCode(benefitCode), "required_level": fmt.Sprintf("%d", response.GetRequiredLevel()), "current_effective_level": fmt.Sprintf("%d", response.GetCurrentEffectiveLevel()), "action": strings.TrimSpace(action), }) } // 兼容滚动升级和现有测试 fake:旧 client 仍可从 effective_benefits 得到同一授权事实。 access, err := s.loadEffectiveVIPAccess(ctx, requestID, userID) if err != nil { // 权限事实不可用时必须失败关闭;放行会让无权益用户在 wallet 故障窗口获得可持久化背景权限。 return err } if access.programType != tieredPrivilegeVIPProgram { return nil } if !access.hasBenefit(benefitCode) { return xerr.NewWithMetadata(xerr.VIPBenefitRequired, "vip benefit is required", map[string]string{ "benefit_code": normalizeVIPCode(benefitCode), "required_level": "0", "current_effective_level": fmt.Sprintf("%d", access.level), "action": strings.TrimSpace(action), }) } return nil } // requireTargetWithoutVIPBenefit 保护普通房主/管理员治理入口;平台治理必须继续走 SystemEvictUser,不能复用该守卫。 func (s *Service) requireTargetWithoutVIPBenefit(ctx context.Context, requestID string, targetUserID int64, benefitCode string) error { access, err := s.loadEffectiveVIPAccess(ctx, requestID, targetUserID) if err != nil { // anti_kick/anti_mute 属于付费保护,wallet 不可判定时宁可拒绝普通房管动作,也不能错误剥夺已生效权益。 return err } if access.hasBenefit(benefitCode) { switch normalizeVIPCode(benefitCode) { case vipBenefitAntiKick: return xerr.New(xerr.VIPAntiKick, "target vip benefit prevents kick") case vipBenefitAntiMute: return xerr.New(xerr.VIPAntiMute, "target vip benefit prevents mute") default: return xerr.New(xerr.VIPBenefitRequired, "target vip benefit prevents this room moderation action") } } return nil } func normalizeVIPCode(value string) string { return strings.ToLower(strings.TrimSpace(value)) }