package user import ( "context" "strings" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" ) const ( defaultSocialPageSize = 20 maxSocialPageSize = 100 // 这两个 code 必须与 wallet-service 的可配置 VIP benefit code 保持一致; // user-service 只消费最终 allowed 结果,不复制等级、体验卡和用户开关规则。 hideProfileDataBenefitCode = "hide_profile_data" anonymousProfileVisitBenefitCode = "anonymous_profile_visit" ) // ProfileVisitResult 是一次资料访问的隐私收敛结果。 // TargetStatsHidden=true 时 TargetStats 必须保持零值,transport 也必须省略该对象,形成双层防泄漏。 type ProfileVisitResult struct { Recorded bool TargetStats userdomain.ProfileStats TargetStatsHidden bool } // RecordProfileVisit 记录访问关系;自己访问自己不进入访问计数。 func (s *Service) RecordProfileVisit(ctx context.Context, requestID string, visitorUserID int64, targetUserID int64) (ProfileVisitResult, error) { if visitorUserID <= 0 || targetUserID <= 0 { return ProfileVisitResult{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if visitorUserID == targetUserID { return ProfileVisitResult{}, xerr.New(xerr.InvalidArgument, "cannot visit self") } if s.userRepository == nil { return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "user repository is not configured") } // 两项隐私都必须在访问发生时实时判断:目标用户的数据隐藏影响本次响应;访客匿名一旦生效, // 本次访问就不能新增或刷新任何访客事实。匿名权益查询失败时中止请求,绝不能降级公开写入泄漏身份。 anonymousVisit, err := s.anonymousVisitAllowed(ctx, requestID, visitorUserID) if err != nil { return ProfileVisitResult{}, err } // 匿名门禁先成功,才读取目标用户的当前隐藏设置;这样 wallet 整体不可用时不做无意义第二次 RPC, // 正常链路下 target_stats 的判定也更接近最终响应时点。 targetStatsHidden := s.targetStatsMustBeHidden(ctx, requestID, targetUserID) if anonymousVisit { // 匿名访问虽然不留足迹,仍必须确认目标用户真实存在;否则伪造 user_id 会被错误返回为成功。 if _, err := s.userRepository.GetUser(ctx, targetUserID); err != nil { return ProfileVisitResult{}, err } result := ProfileVisitResult{Recorded: false, TargetStatsHidden: targetStatsHidden} if !targetStatsHidden { // 目标资料统计来自既有聚合表,只读查询不会改变 visitors_count,也不会触碰公开或历史匿名记录。 stats, err := s.userRepository.GetMyProfileStats(ctx, targetUserID) if err != nil { return ProfileVisitResult{}, err } result.TargetStats = stats } return result, nil } // 未启用匿名权益时保持公开访问的原子写入:足迹聚合和 visitors_count 在同一事务内更新。 recorded, stats, err := s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, s.now().UnixMilli()) if err != nil { return ProfileVisitResult{}, err } result := ProfileVisitResult{Recorded: recorded, TargetStatsHidden: targetStatsHidden} if !targetStatsHidden { result.TargetStats = stats } return result, nil } // targetStatsMustBeHidden 收敛目标资料统计的可见性。 // checker 缺失或 RPC 失败都隐藏 stats:这是只读保护,不会因此授予目标用户任何可执行付费能力。 func (s *Service) targetStatsMustBeHidden(ctx context.Context, requestID string, userID int64) bool { if s.privacyBenefitChecker == nil { return true } allowed, err := s.privacyBenefitChecker.CheckVipBenefit(ctx, requestID, userID, hideProfileDataBenefitCode) return err != nil || allowed } // anonymousVisitAllowed 判断本次足迹能否匿名。 // 依赖失败时不能回退公开写入,也不能把失败当 allowed 授予付费权益,因此明确返回 UNAVAILABLE 且不落任何访问事实。 func (s *Service) anonymousVisitAllowed(ctx context.Context, requestID string, userID int64) (bool, error) { if s.privacyBenefitChecker == nil { return false, xerr.New(xerr.Unavailable, "privacy benefit checker is not configured") } allowed, err := s.privacyBenefitChecker.CheckVipBenefit(ctx, requestID, userID, anonymousProfileVisitBenefitCode) if err != nil { return false, xerr.New(xerr.Unavailable, "anonymous profile visit benefit is unavailable") } return allowed, nil } func (s *Service) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) { if userID <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured") } page, pageSize = normalizeSocialPage(page, pageSize) return s.userRepository.ListProfileVisitors(ctx, userID, page, pageSize) } func (s *Service) FollowUser(ctx context.Context, followerUserID int64, followeeUserID int64) (userdomain.ProfileStats, error) { if followerUserID <= 0 || followeeUserID <= 0 { return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if followerUserID == followeeUserID { return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "cannot follow self") } if s.userRepository == nil { return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured") } return s.userRepository.FollowUser(ctx, followerUserID, followeeUserID, s.now().UnixMilli()) } func (s *Service) UnfollowUser(ctx context.Context, followerUserID int64, followeeUserID int64) (userdomain.ProfileStats, error) { if followerUserID <= 0 || followeeUserID <= 0 { return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if followerUserID == followeeUserID { return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "cannot unfollow self") } if s.userRepository == nil { return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured") } return s.userRepository.UnfollowUser(ctx, followerUserID, followeeUserID, s.now().UnixMilli()) } func (s *Service) ListFollowing(ctx context.Context, userID int64, page int32, pageSize int32, cursorUpdatedAtMS int64, cursorUserID int64) ([]userdomain.FollowRecord, int64, error) { if userID <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured") } page, pageSize = normalizeSocialPage(page, pageSize) return s.userRepository.ListFollowing(ctx, userID, page, pageSize, cursorUpdatedAtMS, cursorUserID) } func (s *Service) ApplyFriend(ctx context.Context, requesterUserID int64, targetUserID int64) (userdomain.FriendApplication, bool, error) { if requesterUserID <= 0 || targetUserID <= 0 { return userdomain.FriendApplication{}, false, xerr.New(xerr.InvalidArgument, "user_id is required") } if requesterUserID == targetUserID { return userdomain.FriendApplication{}, false, xerr.New(xerr.InvalidArgument, "cannot add self as friend") } if s.userRepository == nil { return userdomain.FriendApplication{}, false, xerr.New(xerr.Unavailable, "user repository is not configured") } return s.userRepository.ApplyFriend(ctx, requesterUserID, targetUserID, s.now().UnixMilli()) } func (s *Service) AcceptFriendApplication(ctx context.Context, accepterUserID int64, requesterUserID int64) (userdomain.FriendRecord, error) { if accepterUserID <= 0 || requesterUserID <= 0 { return userdomain.FriendRecord{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if accepterUserID == requesterUserID { return userdomain.FriendRecord{}, xerr.New(xerr.InvalidArgument, "cannot accept self friend request") } if s.userRepository == nil { return userdomain.FriendRecord{}, xerr.New(xerr.Unavailable, "user repository is not configured") } return s.userRepository.AcceptFriendApplication(ctx, accepterUserID, requesterUserID, s.now().UnixMilli()) } func (s *Service) DeleteFriend(ctx context.Context, userID int64, friendUserID int64) (bool, error) { if userID <= 0 || friendUserID <= 0 { return false, xerr.New(xerr.InvalidArgument, "user_id is required") } if userID == friendUserID { return false, xerr.New(xerr.InvalidArgument, "cannot delete self friend") } if s.userRepository == nil { return false, xerr.New(xerr.Unavailable, "user repository is not configured") } return s.userRepository.DeleteFriend(ctx, userID, friendUserID, s.now().UnixMilli()) } func (s *Service) ListFriends(ctx context.Context, userID int64, page int32, pageSize int32, cursorUpdatedAtMS int64, cursorUserID int64) ([]userdomain.FriendRecord, int64, error) { if userID <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.userRepository == nil { return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured") } page, pageSize = normalizeSocialPage(page, pageSize) return s.userRepository.ListFriends(ctx, userID, page, pageSize, cursorUpdatedAtMS, cursorUserID) } func (s *Service) ListFriendApplications(ctx context.Context, userID int64, direction string, page int32, pageSize int32) ([]userdomain.FriendApplication, int64, error) { if userID <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") } direction = strings.ToLower(strings.TrimSpace(direction)) if direction == "" { direction = "incoming" } if direction != "incoming" && direction != "outgoing" { return nil, 0, xerr.New(xerr.InvalidArgument, "direction is invalid") } if s.userRepository == nil { return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured") } page, pageSize = normalizeSocialPage(page, pageSize) return s.userRepository.ListFriendApplications(ctx, userID, direction, page, pageSize) } func normalizeSocialPage(page int32, pageSize int32) (int32, int32) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = defaultSocialPageSize } if pageSize > maxSocialPageSize { pageSize = maxSocialPageSize } return page, pageSize }