feat(privacy): add user privacy settings and account deletion system
All checks were successful
Build Backend / build (push) Successful in 5m16s
Build Backend / build-docker (push) Successful in 3m44s

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.
This commit is contained in:
lafay
2026-04-08 14:55:50 +08:00
parent 9440df66ba
commit 539bec6f63
9 changed files with 642 additions and 76 deletions

View File

@@ -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

View File

@@ -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)
}