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

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

View File

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