2026-07-17 16:55:22 +08:00

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