From 539bec6f63b0e8dd97090044099cbc80ccb39f98 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Wed, 8 Apr 2026 14:55:50 +0800 Subject: [PATCH] feat(privacy): add user privacy settings and account deletion system Add privacy settings allowing users to control visibility of their followers, following, posts, and favorites lists with three visibility levels: everyone, following, and self. Also implement account deletion workflow with 30-day cooldown period. - Add PrivacySettings model with visibility levels - Add DTOs for privacy settings and account deletion - Add repository methods for privacy settings and deletion - Add service methods to check visibility permissions - Add handlers for get/update privacy settings and account deletion - Add new routes for privacy settings and account management - Add account cleanup service for hard-deleting expired accounts - Modify login to cancel deletion when user logs in BREAKING CHANGE: Users with pending_deletion status can no longer log in; logging in now automatically cancels deletion requests. --- internal/dto/dto.go | 34 ++++ internal/dto/user_converter.go | 109 +++++++------ internal/handler/post_handler.go | 69 +++++--- internal/handler/user_handler.go | 170 ++++++++++++++++++++ internal/model/user.go | 30 +++- internal/repository/user_repo.go | 62 +++++++ internal/router/router.go | 9 ++ internal/service/account_cleanup_service.go | 109 +++++++++++++ internal/service/user_service.go | 126 ++++++++++++++- 9 files changed, 642 insertions(+), 76 deletions(-) create mode 100644 internal/service/account_cleanup_service.go diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 80c0735..476fda5 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -44,6 +44,8 @@ type UserSelfResponse struct { FollowingCount int `json:"following_count"` IsVerified bool `json:"is_verified"` CreatedAt string `json:"created_at"` + // 隐私设置 + PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"` } // UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息) @@ -66,6 +68,8 @@ type UserDetailResponse struct { IsFollowing bool `json:"is_following"` IsFollowingMe bool `json:"is_following_me"` CreatedAt string `json:"created_at"` + // 隐私设置 + PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"` } // ==================== Admin User DTOs ==================== @@ -1403,3 +1407,33 @@ type AdminReviewVerificationRequest struct { Approve bool `json:"approve" binding:"required"` RejectReason string `json:"reject_reason" binding:"omitempty,max=500"` } + +// ==================== Privacy & Account Deletion DTOs ==================== + +// PrivacySettingsRequest 隐私设置请求 +type PrivacySettingsRequest struct { + FollowersVisibility *string `json:"followers_visibility" binding:"omitempty,oneof=everyone following self"` + FollowingVisibility *string `json:"following_visibility" binding:"omitempty,oneof=everyone following self"` + PostsVisibility *string `json:"posts_visibility" binding:"omitempty,oneof=everyone following self"` + FavoritesVisibility *string `json:"favorites_visibility" binding:"omitempty,oneof=everyone following self"` +} + +// PrivacySettingsResponse 隐私设置响应 +type PrivacySettingsResponse struct { + FollowersVisibility string `json:"followers_visibility"` + FollowingVisibility string `json:"following_visibility"` + PostsVisibility string `json:"posts_visibility"` + FavoritesVisibility string `json:"favorites_visibility"` +} + +// RequestDeletionRequest 申请注销请求 +type RequestDeletionRequest struct { + Password string `json:"password" binding:"required"` +} + +// DeletionStatusResponse 注销状态响应 +type DeletionStatusResponse struct { + IsPendingDeletion bool `json:"is_pending_deletion"` + DeletionRequestedAt string `json:"deletion_requested_at,omitempty"` + CoolDownDays int `json:"cool_down_days,omitempty"` +} diff --git a/internal/dto/user_converter.go b/internal/dto/user_converter.go index 2e5ce03..1cacb10 100644 --- a/internal/dto/user_converter.go +++ b/internal/dto/user_converter.go @@ -40,22 +40,35 @@ func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfRespon return nil } return &UserSelfResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: postsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsVerified: user.IsVerified, - CreatedAt: FormatTime(user.CreatedAt), + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: postsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsVerified: user.IsVerified, + CreatedAt: FormatTime(user.CreatedAt), + PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings), + } +} + +func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse { + if ps == nil { + return nil + } + return &PrivacySettingsResponse{ + FollowersVisibility: string(ps.FollowersVisibility), + FollowingVisibility: string(ps.FollowingVisibility), + PostsVisibility: string(ps.PostsVisibility), + FavoritesVisibility: string(ps.FavoritesVisibility), } } @@ -159,21 +172,22 @@ func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse { return nil } return &UserDetailResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: user.PostsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsVerified: user.IsVerified, - CreatedAt: FormatTime(user.CreatedAt), + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: user.PostsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsVerified: user.IsVerified, + CreatedAt: FormatTime(user.CreatedAt), + PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings), } } @@ -183,22 +197,23 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) return nil } return &UserDetailResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - EmailVerified: user.EmailVerified, - Phone: user.Phone, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: postsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsVerified: user.IsVerified, - CreatedAt: FormatTime(user.CreatedAt), + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + EmailVerified: user.EmailVerified, + Phone: user.Phone, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: postsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsVerified: user.IsVerified, + CreatedAt: FormatTime(user.CreatedAt), + PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings), } } diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index ba65c68..1e42a12 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -17,9 +17,9 @@ import ( // PostHandler 帖子处理器 type PostHandler struct { - postService service.PostService - userService service.UserService - channelService service.ChannelService + postService service.PostService + userService service.UserService + channelService service.ChannelService } // NewPostHandler 创建帖子处理器 @@ -116,27 +116,27 @@ func (h *PostHandler) GetByID(c *gin.Context) { // 构建响应 responseData := &dto.PostResponse{ - ID: post.ID, - UserID: post.UserID, - ChannelID: post.ChannelID, - Title: post.Title, - Content: post.Content, - Images: dto.ConvertPostImagesToResponse(post.Images), - Status: string(post.Status), - LikesCount: post.LikesCount, - CommentsCount: post.CommentsCount, - FavoritesCount: post.FavoritesCount, - SharesCount: post.SharesCount, - ViewsCount: post.ViewsCount, - IsPinned: post.IsPinned, - IsLocked: post.IsLocked, - IsVote: post.IsVote, + ID: post.ID, + UserID: post.UserID, + ChannelID: post.ChannelID, + Title: post.Title, + Content: post.Content, + Images: dto.ConvertPostImagesToResponse(post.Images), + Status: string(post.Status), + LikesCount: post.LikesCount, + CommentsCount: post.CommentsCount, + FavoritesCount: post.FavoritesCount, + SharesCount: post.SharesCount, + ViewsCount: post.ViewsCount, + IsPinned: post.IsPinned, + IsLocked: post.IsLocked, + IsVote: post.IsVote, CreatedAt: dto.FormatTime(post.CreatedAt), UpdatedAt: dto.FormatTime(post.UpdatedAt), ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt), Author: authorWithFollowStatus, - IsLiked: isLiked, - IsFavorited: isFavorited, + IsLiked: isLiked, + IsFavorited: isFavorited, } chMap := h.channelMapForPosts([]*model.Post{post}) @@ -553,6 +553,19 @@ func (h *PostHandler) Unfavorite(c *gin.Context) { // GetUserPosts 获取用户帖子列表(支持游标分页和偏移分页) func (h *PostHandler) GetUserPosts(c *gin.Context) { + userID := c.Param("id") + currentUserID := c.GetString("user_id") + + canView, err := h.userService.CanViewPosts(c.Request.Context(), currentUserID, userID) + if err != nil { + response.InternalServerError(c, "failed to check privacy settings") + return + } + if !canView { + response.Forbidden(c, "该用户的帖子列表不对外公开") + return + } + // 检查是否使用游标分页 cursorStr := c.Query("cursor") if cursorStr != "" { @@ -561,12 +574,10 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) { } // 偏移分页(向后兼容) - userID := c.Param("id") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) page, pageSize = normalizePagination(page, pageSize) - currentUserID := c.GetString("user_id") includePending := currentUserID != "" && currentUserID == userID posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending) if err != nil { @@ -629,6 +640,18 @@ func (h *PostHandler) GetUserPostsByCursor(c *gin.Context) { // GetFavorites 获取收藏列表 func (h *PostHandler) GetFavorites(c *gin.Context) { userID := c.Param("id") + currentUserID := c.GetString("user_id") + + canView, err := h.userService.CanViewFavorites(c.Request.Context(), currentUserID, userID) + if err != nil { + response.InternalServerError(c, "failed to check privacy settings") + return + } + if !canView { + response.Forbidden(c, "该用户的收藏列表不对外公开") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) page, pageSize = normalizePagination(page, pageSize) @@ -639,8 +662,6 @@ func (h *PostHandler) GetFavorites(c *gin.Context) { return } - // 批量获取交互状态 - currentUserID := c.GetString("user_id") postIDs := make([]string, len(posts)) for i, post := range posts { postIDs[i] = post.ID diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 7f5dc4c..bd1ebaf 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -615,6 +615,17 @@ func (h *UserHandler) GetBlockStatus(c *gin.Context) { func (h *UserHandler) GetFollowingList(c *gin.Context) { userID := c.Param("id") currentUserID := c.GetString("user_id") + + canView, err := h.userService.CanViewFollowing(c.Request.Context(), currentUserID, userID) + if err != nil { + response.InternalServerError(c, "failed to check privacy settings") + return + } + if !canView { + response.Forbidden(c, "该用户的关注列表不对外公开") + return + } + page := c.DefaultQuery("page", "1") pageSize := c.DefaultQuery("page_size", "20") @@ -654,6 +665,17 @@ func (h *UserHandler) GetFollowingList(c *gin.Context) { func (h *UserHandler) GetFollowersList(c *gin.Context) { userID := c.Param("id") currentUserID := c.GetString("user_id") + + canView, err := h.userService.CanViewFollowers(c.Request.Context(), currentUserID, userID) + if err != nil { + response.InternalServerError(c, "failed to check privacy settings") + return + } + if !canView { + response.Forbidden(c, "该用户的粉丝列表不对外公开") + return + } + page := c.DefaultQuery("page", "1") pageSize := c.DefaultQuery("page_size", "20") @@ -810,3 +832,151 @@ func (h *UserHandler) Search(c *gin.Context) { response.Paginated(c, userResponses, total, page, pageSize) } + +// GetPrivacySettings 获取隐私设置 +func (h *UserHandler) GetPrivacySettings(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + settings, err := h.userService.GetPrivacySettings(c.Request.Context(), userID) + if err != nil { + response.InternalServerError(c, "failed to get privacy settings") + return + } + + response.Success(c, &dto.PrivacySettingsResponse{ + FollowersVisibility: string(settings.FollowersVisibility), + FollowingVisibility: string(settings.FollowingVisibility), + PostsVisibility: string(settings.PostsVisibility), + FavoritesVisibility: string(settings.FavoritesVisibility), + }) +} + +// UpdatePrivacySettings 更新隐私设置 +func (h *UserHandler) UpdatePrivacySettings(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + var req dto.PrivacySettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + settings := &model.PrivacySettings{} + if req.FollowersVisibility != nil { + settings.FollowersVisibility = model.VisibilityLevel(*req.FollowersVisibility) + } else { + settings.FollowersVisibility = model.VisibilityEveryone + } + if req.FollowingVisibility != nil { + settings.FollowingVisibility = model.VisibilityLevel(*req.FollowingVisibility) + } else { + settings.FollowingVisibility = model.VisibilityEveryone + } + if req.PostsVisibility != nil { + settings.PostsVisibility = model.VisibilityLevel(*req.PostsVisibility) + } else { + settings.PostsVisibility = model.VisibilityEveryone + } + if req.FavoritesVisibility != nil { + settings.FavoritesVisibility = model.VisibilityLevel(*req.FavoritesVisibility) + } else { + settings.FavoritesVisibility = model.VisibilityEveryone + } + + if err := h.userService.UpdatePrivacySettings(c.Request.Context(), userID, settings); err != nil { + response.InternalServerError(c, "failed to update privacy settings") + return + } + + response.Success(c, &dto.PrivacySettingsResponse{ + FollowersVisibility: string(settings.FollowersVisibility), + FollowingVisibility: string(settings.FollowingVisibility), + PostsVisibility: string(settings.PostsVisibility), + FavoritesVisibility: string(settings.FavoritesVisibility), + }) +} + +// RequestAccountDeletion 申请注销账号 +func (h *UserHandler) RequestAccountDeletion(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + var req dto.RequestDeletionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.userService.RequestAccountDeletion(c.Request.Context(), userID, req.Password); err != nil { + response.HandleError(c, err, "failed to request account deletion") + return + } + + isPending, requestedAt, daysLeft, err := h.userService.GetDeletionStatus(c.Request.Context(), userID) + if err != nil { + response.InternalServerError(c, "failed to get deletion status") + return + } + + resp := &dto.DeletionStatusResponse{ + IsPendingDeletion: isPending, + CoolDownDays: daysLeft, + } + if requestedAt != nil { + resp.DeletionRequestedAt = dto.FormatTime(*requestedAt) + } + + response.Success(c, resp) +} + +// CancelAccountDeletion 取消注销 +func (h *UserHandler) CancelAccountDeletion(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + if err := h.userService.CancelAccountDeletion(c.Request.Context(), userID); err != nil { + response.InternalServerError(c, "failed to cancel account deletion") + return + } + + response.Success(c, gin.H{"success": true}) +} + +// GetDeletionStatus 获取注销状态 +func (h *UserHandler) GetDeletionStatus(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + isPending, requestedAt, daysLeft, err := h.userService.GetDeletionStatus(c.Request.Context(), userID) + if err != nil { + response.InternalServerError(c, "failed to get deletion status") + return + } + + resp := &dto.DeletionStatusResponse{ + IsPendingDeletion: isPending, + CoolDownDays: daysLeft, + } + if requestedAt != nil { + resp.DeletionRequestedAt = dto.FormatTime(*requestedAt) + } + + response.Success(c, resp) +} diff --git a/internal/model/user.go b/internal/model/user.go index 05d2b80..ac1561b 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -11,11 +11,29 @@ import ( type UserStatus string const ( - UserStatusActive UserStatus = "active" - UserStatusBanned UserStatus = "banned" - UserStatusInactive UserStatus = "inactive" + UserStatusActive UserStatus = "active" + UserStatusBanned UserStatus = "banned" + UserStatusInactive UserStatus = "inactive" + UserStatusPendingDeletion UserStatus = "pending_deletion" ) +// VisibilityLevel 可见性级别 +type VisibilityLevel string + +const ( + VisibilityEveryone VisibilityLevel = "everyone" + VisibilityFollowing VisibilityLevel = "following" + VisibilitySelf VisibilityLevel = "self" +) + +// PrivacySettings 隐私设置 +type PrivacySettings struct { + FollowersVisibility VisibilityLevel `json:"followers_visibility" gorm:"type:varchar(20);default:everyone"` + FollowingVisibility VisibilityLevel `json:"following_visibility" gorm:"type:varchar(20);default:everyone"` + PostsVisibility VisibilityLevel `json:"posts_visibility" gorm:"type:varchar(20);default:everyone"` + FavoritesVisibility VisibilityLevel `json:"favorites_visibility" gorm:"type:varchar(20);default:everyone"` +} + // UserIdentity 用户身份类型 type UserIdentity string @@ -73,6 +91,12 @@ type User struct { LastLoginAt *time.Time `json:"last_login_at" gorm:"type:timestamp"` LastLoginIP string `json:"last_login_ip" gorm:"type:varchar(45)"` + // 账号注销 + DeletionRequestedAt *time.Time `json:"deletion_requested_at" gorm:"type:timestamp"` + + // 隐私设置 + PrivacySettings PrivacySettings `json:"privacy_settings" gorm:"embedded;embeddedPrefix:privacy_"` + // 时间戳 CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index c1448a7..90bd384 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -3,6 +3,7 @@ package repository import ( "carrot_bbs/internal/model" "strings" + "time" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -43,6 +44,16 @@ type UserRepository interface { AdminList(page, pageSize int, keyword, status string) ([]*model.User, int64, error) GetCommentsCount(userID string) (int64, error) UpdateStatus(userID string, status model.UserStatus) error + + // 隐私设置 + UpdatePrivacySettings(userID string, settings *model.PrivacySettings) error + GetPrivacySettings(userID string) (*model.PrivacySettings, error) + + // 账号注销 + RequestDeletion(userID string) error + CancelDeletion(userID string) error + GetPendingDeletionUsers(beforeTime time.Time) ([]*model.User, error) + HardDeleteUser(userID string) error } // userRepository 用户仓储实现 @@ -544,3 +555,54 @@ func (r *userRepository) GetCommentsCount(userID string) (int64, error) { func (r *userRepository) UpdateStatus(userID string, status model.UserStatus) error { return r.db.Model(&model.User{}).Where("id = ?", userID).Update("status", status).Error } + +// UpdatePrivacySettings 更新隐私设置 +func (r *userRepository) UpdatePrivacySettings(userID string, settings *model.PrivacySettings) error { + return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "privacy_followers_visibility": settings.FollowersVisibility, + "privacy_following_visibility": settings.FollowingVisibility, + "privacy_posts_visibility": settings.PostsVisibility, + "privacy_favorites_visibility": settings.FavoritesVisibility, + }).Error +} + +// GetPrivacySettings 获取隐私设置 +func (r *userRepository) GetPrivacySettings(userID string) (*model.PrivacySettings, error) { + var user model.User + err := r.db.Select("privacy_followers_visibility, privacy_following_visibility, privacy_posts_visibility, privacy_favorites_visibility"). + First(&user, "id = ?", userID).Error + if err != nil { + return nil, err + } + return &user.PrivacySettings, nil +} + +// RequestDeletion 申请注销账号 +func (r *userRepository) RequestDeletion(userID string) error { + now := time.Now() + return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "deletion_requested_at": &now, + "status": model.UserStatusPendingDeletion, + }).Error +} + +// CancelDeletion 取消注销 +func (r *userRepository) CancelDeletion(userID string) error { + return r.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "deletion_requested_at": nil, + "status": model.UserStatusActive, + }).Error +} + +// GetPendingDeletionUsers 获取待删除的用户列表(注销申请时间早于指定时间) +func (r *userRepository) GetPendingDeletionUsers(beforeTime time.Time) ([]*model.User, error) { + var users []*model.User + err := r.db.Where("deletion_requested_at IS NOT NULL AND deletion_requested_at < ?", beforeTime). + Find(&users).Error + return users, err +} + +// HardDeleteUser 硬删除用户(永久删除) +func (r *userRepository) HardDeleteUser(userID string) error { + return r.db.Unscoped().Delete(&model.User{}, "id = ?", userID).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index adb63f2..b7d1ab5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -187,6 +187,15 @@ func (r *Router) setupRoutes() { users.POST("/change-password/send-code", authMiddleware, r.userHandler.SendChangePasswordCode) users.POST("/change-password", authMiddleware, r.userHandler.ChangePassword) + // 隐私设置 + users.GET("/me/privacy", authMiddleware, r.userHandler.GetPrivacySettings) + users.PUT("/me/privacy", authMiddleware, r.userHandler.UpdatePrivacySettings) + + // 账号注销 + users.POST("/me/deactivate", authMiddleware, r.userHandler.RequestAccountDeletion) + users.DELETE("/me/deactivate", authMiddleware, r.userHandler.CancelAccountDeletion) + users.GET("/me/deletion-status", authMiddleware, r.userHandler.GetDeletionStatus) + // 当前用户角色 if r.roleHandler != nil { users.GET("/me/roles", authMiddleware, r.roleHandler.GetMyRoles) diff --git a/internal/service/account_cleanup_service.go b/internal/service/account_cleanup_service.go new file mode 100644 index 0000000..51ad59b --- /dev/null +++ b/internal/service/account_cleanup_service.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "log" + "time" + + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" + + "gorm.io/gorm" +) + +const AccountDeletionCoolDownDays = 90 + +type AccountCleanupService interface { + CleanupExpiredAccounts(ctx context.Context) (int64, error) +} + +type accountCleanupServiceImpl struct { + db *gorm.DB + userRepo repository.UserRepository + logService *LogService +} + +func NewAccountCleanupService( + db *gorm.DB, + userRepo repository.UserRepository, + logService *LogService, +) AccountCleanupService { + return &accountCleanupServiceImpl{ + db: db, + userRepo: userRepo, + logService: logService, + } +} + +func (s *accountCleanupServiceImpl) CleanupExpiredAccounts(ctx context.Context) (int64, error) { + cutoffTime := time.Now().AddDate(0, 0, -AccountDeletionCoolDownDays) + + users, err := s.userRepo.GetPendingDeletionUsers(cutoffTime) + if err != nil { + return 0, err + } + + if len(users) == 0 { + return 0, nil + } + + var deletedCount int64 + for _, user := range users { + if err := s.anonymizeAndDeleteUser(ctx, user); err != nil { + log.Printf("[AccountCleanup] failed to cleanup user %s: %v", user.ID, err) + continue + } + deletedCount++ + } + + log.Printf("[AccountCleanup] cleaned up %d expired accounts", deletedCount) + return deletedCount, nil +} + +func (s *accountCleanupServiceImpl) anonymizeAndDeleteUser(ctx context.Context, user *model.User) error { + deletedUserID := "deleted_user" + + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Post{}).Where("user_id = ?", user.ID).Update("user_id", deletedUserID).Error; err != nil { + return err + } + + if err := tx.Model(&model.Comment{}).Where("user_id = ?", user.ID).Update("user_id", deletedUserID).Error; err != nil { + return err + } + + if err := tx.Where("follower_id = ? OR following_id = ?", user.ID, user.ID).Delete(&model.Follow{}).Error; err != nil { + return err + } + + if err := tx.Where("user_id = ?", user.ID).Delete(&model.Favorite{}).Error; err != nil { + return err + } + + if err := tx.Where("user_id = ?", user.ID).Delete(&model.PostLike{}).Error; err != nil { + return err + } + + if err := tx.Where("user_id = ?", user.ID).Delete(&model.CommentLike{}).Error; err != nil { + return err + } + + if err := tx.Unscoped().Delete(&model.User{}, "id = ?", user.ID).Error; err != nil { + return err + } + + if s.logService != nil { + s.logService.OperationLog.RecordOperation(&model.OperationLog{ + UserID: user.ID, + UserName: user.Username, + Operation: "account_deleted", + Module: "user", + TargetType: "user", + TargetID: user.ID, + Status: "success", + }) + } + + return nil + }) +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 2f6d8c9..1e8f837 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "carrot_bbs/internal/cache" apperrors "carrot_bbs/internal/errors" @@ -52,6 +53,20 @@ type UserService interface { UnblockUser(ctx context.Context, blockerID, blockedID string) error GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error) IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error) + + // 隐私设置 + GetPrivacySettings(ctx context.Context, userID string) (*model.PrivacySettings, error) + UpdatePrivacySettings(ctx context.Context, userID string, settings *model.PrivacySettings) error + CheckVisibility(ctx context.Context, viewerID, targetUserID string, visibility model.VisibilityLevel) (bool, error) + CanViewFollowers(ctx context.Context, viewerID, targetUserID string) (bool, error) + CanViewFollowing(ctx context.Context, viewerID, targetUserID string) (bool, error) + CanViewPosts(ctx context.Context, viewerID, targetUserID string) (bool, error) + CanViewFavorites(ctx context.Context, viewerID, targetUserID string) (bool, error) + + // 账号注销 + RequestAccountDeletion(ctx context.Context, userID, password string) error + CancelAccountDeletion(ctx context.Context, userID string) error + GetDeletionStatus(ctx context.Context, userID string) (isPending bool, requestedAt *time.Time, daysLeft int, err error) } // userServiceImpl 用户服务实现 @@ -320,11 +335,10 @@ func (s *userServiceImpl) Login(ctx context.Context, account, password string) ( return nil, ErrInvalidCredentials } - if user.Status != model.UserStatusActive { + if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion { loginResult = string(model.LoginResultFail) failReason = string(model.FailReasonAccountBanned) - // 记录失败登录日志 if s.logService != nil { s.logService.LoginLog.RecordLogin(&model.LoginLog{ UserID: user.ID, @@ -341,6 +355,13 @@ func (s *userServiceImpl) Login(ctx context.Context, account, password string) ( return nil, ErrUserBanned } + // 如果用户已申请注销,登录即取消注销 + if user.DeletionRequestedAt != nil { + _ = s.userRepo.CancelDeletion(user.ID) + user.DeletionRequestedAt = nil + user.Status = model.UserStatusActive + } + // 记录成功登录日志 if s.logService != nil { s.logService.LoginLog.RecordLogin(&model.LoginLog{ @@ -700,6 +721,107 @@ func (s *userServiceImpl) Search(ctx context.Context, keyword string, page, page return s.userRepo.Search(keyword, page, pageSize) } +// GetPrivacySettings 获取隐私设置 +func (s *userServiceImpl) GetPrivacySettings(ctx context.Context, userID string) (*model.PrivacySettings, error) { + return s.userRepo.GetPrivacySettings(userID) +} + +// UpdatePrivacySettings 更新隐私设置 +func (s *userServiceImpl) UpdatePrivacySettings(ctx context.Context, userID string, settings *model.PrivacySettings) error { + return s.userRepo.UpdatePrivacySettings(userID, settings) +} + +// CheckVisibility 检查可见性权限 +func (s *userServiceImpl) CheckVisibility(ctx context.Context, viewerID, targetUserID string, visibility model.VisibilityLevel) (bool, error) { + switch visibility { + case model.VisibilityEveryone: + return true, nil + case model.VisibilityFollowing: + if viewerID == "" { + return false, nil + } + return s.userRepo.IsFollowing(viewerID, targetUserID) + case model.VisibilitySelf: + return viewerID == targetUserID, nil + default: + return false, nil + } +} + +// CanViewFollowers 检查是否可以查看粉丝列表 +func (s *userServiceImpl) CanViewFollowers(ctx context.Context, viewerID, targetUserID string) (bool, error) { + settings, err := s.userRepo.GetPrivacySettings(targetUserID) + if err != nil { + return false, err + } + return s.CheckVisibility(ctx, viewerID, targetUserID, settings.FollowersVisibility) +} + +// CanViewFollowing 检查是否可以查看关注列表 +func (s *userServiceImpl) CanViewFollowing(ctx context.Context, viewerID, targetUserID string) (bool, error) { + settings, err := s.userRepo.GetPrivacySettings(targetUserID) + if err != nil { + return false, err + } + return s.CheckVisibility(ctx, viewerID, targetUserID, settings.FollowingVisibility) +} + +// CanViewPosts 检查是否可以查看帖子列表 +func (s *userServiceImpl) CanViewPosts(ctx context.Context, viewerID, targetUserID string) (bool, error) { + settings, err := s.userRepo.GetPrivacySettings(targetUserID) + if err != nil { + return false, err + } + return s.CheckVisibility(ctx, viewerID, targetUserID, settings.PostsVisibility) +} + +// CanViewFavorites 检查是否可以查看收藏列表 +func (s *userServiceImpl) CanViewFavorites(ctx context.Context, viewerID, targetUserID string) (bool, error) { + settings, err := s.userRepo.GetPrivacySettings(targetUserID) + if err != nil { + return false, err + } + return s.CheckVisibility(ctx, viewerID, targetUserID, settings.FavoritesVisibility) +} + +// RequestAccountDeletion 申请注销账号 +func (s *userServiceImpl) RequestAccountDeletion(ctx context.Context, userID, password string) error { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return ErrUserNotFound + } + + if !utils.CheckPasswordHash(password, user.PasswordHash) { + return ErrInvalidCredentials + } + + return s.userRepo.RequestDeletion(userID) +} + +// CancelAccountDeletion 取消注销 +func (s *userServiceImpl) CancelAccountDeletion(ctx context.Context, userID string) error { + return s.userRepo.CancelDeletion(userID) +} + +// GetDeletionStatus 获取注销状态 +func (s *userServiceImpl) GetDeletionStatus(ctx context.Context, userID string) (isPending bool, requestedAt *time.Time, daysLeft int, err error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return false, nil, 0, err + } + + if user.DeletionRequestedAt == nil { + return false, nil, 0, nil + } + + daysLeft = 90 - int(time.Since(*user.DeletionRequestedAt).Hours()/24) + if daysLeft < 0 { + daysLeft = 0 + } + + return true, user.DeletionRequestedAt, daysLeft, nil +} + // 错误定义 - 使用统一的 AppError var ( ErrInvalidUsername = apperrors.ErrInvalidUsername