package user import ( "context" "crypto/rand" "encoding/hex" "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") } // 两项隐私都必须在访问发生时实时判断:目标用户的数据隐藏影响本次响应,访客匿名则决定 // 本次事实写入哪张表。目标权益查询失败时仅隐藏 stats;匿名权益查询失败时中止写入,既不泄漏身份,也不越权授予付费匿名能力。 anonymousVisit, err := s.anonymousVisitAllowed(ctx, requestID, visitorUserID) if err != nil { return ProfileVisitResult{}, err } // 匿名门禁先成功,才读取目标用户的当前隐藏设置;这样 wallet 整体不可用时不做无意义第二次 RPC, // 正常链路下 target_stats 的判定也更接近最终响应时点。 targetStatsHidden := s.targetStatsMustBeHidden(ctx, requestID, targetUserID) nowMs := s.now().UnixMilli() var ( recorded bool stats userdomain.ProfileStats writeErr error ) if anonymousVisit { if s.anonymousProfileVisitRepository == nil { // 匿名 owner 表缺失时必须拒绝写入,绝不能悄悄回退到公开足迹表暴露真实 visitor_user_id。 return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "anonymous profile visit repository is not configured") } anonymousVisitID, generateErr := newAnonymousVisitID() if generateErr != nil { return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "anonymous visit id generation failed") } recorded, stats, writeErr = s.anonymousProfileVisitRepository.RecordAnonymousProfileVisit(ctx, visitorUserID, targetUserID, anonymousVisitID, nowMs) } else { // 当前不具备匿名权益时仍写旧公开表;已有匿名历史不会被迁移或反向暴露。 recorded, stats, writeErr = s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, nowMs) } if writeErr != nil { return ProfileVisitResult{}, writeErr } 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 } // newAnonymousVisitID 生成不含 user_id、时间或 target 信息的随机展示 ID。 // ID 在首次插入后由数据库保持不变;同一访客访问不同 target 会生成不同 ID,客户端无法跨用户关联。 func newAnonymousVisitID() (string, error) { var entropy [16]byte if _, err := rand.Read(entropy[:]); err != nil { return "", err } return "anon_" + hex.EncodeToString(entropy[:]), 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 }