245 lines
11 KiB
Go
245 lines
11 KiB
Go
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
|
||
}
|