2026-07-22 17:30:05 +08:00

230 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}