230 lines
10 KiB
Go
230 lines
10 KiB
Go
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
|
||
}
|