Files
backend/internal/handler/user_handler.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00

1068 lines
30 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 handler
import (
"cmp"
"context"
"crypto/sha256"
"fmt"
"strconv"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/middleware"
"with_you/internal/model"
apperrors "with_you/internal/errors"
"with_you/internal/pkg/auth"
"with_you/internal/pkg/response"
"with_you/internal/service"
)
const (
maxPageSize = 100
defaultPageSize = 20
)
func normalizePagination(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > maxPageSize {
pageSize = defaultPageSize
}
return page, pageSize
}
// UserHandler 用户处理器
type UserHandler struct {
userService service.UserService
activityService service.UserActivityService
profileAuditService service.UserProfileAuditService
jwtService service.JWTService
sessionService service.SessionService
cache cache.Cache
logService *service.LogService
}
func NewUserHandler(userService service.UserService, activityService service.UserActivityService, logService *service.LogService) *UserHandler {
return &UserHandler{
userService: userService,
activityService: activityService,
logService: logService,
}
}
func (h *UserHandler) SetProfileAuditService(profileAuditService service.UserProfileAuditService) {
h.profileAuditService = profileAuditService
}
// SetJWTService 设置JWT服务
func (h *UserHandler) SetJWTService(jwtService service.JWTService) {
h.jwtService = jwtService
}
// SetActivityService 设置活跃统计服务
func (h *UserHandler) SetActivityService(activityService service.UserActivityService) {
h.activityService = activityService
}
// SetSessionService 设置会话服务(用于登出与令牌轮换)。
func (h *UserHandler) SetSessionService(sessionService service.SessionService) {
h.sessionService = sessionService
}
// SetCache 注入缓存实例(用于登出时失效 Principal/Session 缓存)。
func (h *UserHandler) SetCache(c cache.Cache) {
h.cache = c
}
// generateTokenID 生成Token ID
func generateTokenID(token string) string {
h := sha256.New()
h.Write([]byte(token))
return fmt.Sprintf("%x", h.Sum(nil))[:16]
}
// Register 用户注册
func (h *UserHandler) Register(c *gin.Context) {
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname" binding:"required"`
Phone string `json:"phone"`
VerificationCode string `json:"verification_code" binding:"required"`
}
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
user, err := h.userService.Register(c.Request.Context(), req.Username, req.Email, req.Password, req.Nickname, req.Phone, req.VerificationCode)
if err != nil {
response.HandleError(c, err, "failed to register")
return
}
// 签发带会话的令牌对sid 写入 JWT支持登出/封禁后撤销。
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
refreshExpire := time.Duration(7*24) * time.Hour
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
if err != nil {
// 会话签发失败视为登录不可用,避免签发无 sid 的旧式令牌。
response.InternalServerError(c, "failed to issue session")
return
}
// 记录用户活跃
if h.activityService != nil {
go func() {
ctx := context.Background()
loginType := model.LoginTypeLogin
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
}()
}
// 记录注册后首次登录
if h.logService != nil {
go func() {
h.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
LoginType: string(model.LoginTypePassword),
Event: string(model.LoginEventLogin),
IP: ip,
UserAgent: userAgent,
Result: string(model.LoginResultSuccess),
TokenID: generateTokenID(refreshToken),
})
}()
}
response.Success(c, gin.H{
"user": dto.ConvertUserToSelfResponse(user, 0),
"token": accessToken,
"refresh_token": refreshToken,
})
}
// Login 用户登录
func (h *UserHandler) Login(c *gin.Context) {
type LoginRequest struct {
Username string `json:"username"`
Account string `json:"account"`
Password string `json:"password" binding:"required"`
}
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
account := cmp.Or(req.Account, req.Username)
if account == "" {
response.BadRequest(c, "username or account is required")
return
}
user, err := h.userService.Login(c.Request.Context(), account, req.Password)
if err != nil {
middleware.RecordLoginFailure(c.ClientIP())
response.HandleError(c, err, "failed to login")
return
}
middleware.ResetLoginFailures(c.ClientIP())
// 签发带会话的令牌对。
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
refreshExpire := time.Duration(7*24) * time.Hour
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
if err != nil {
response.InternalServerError(c, "failed to issue session")
return
}
// 记录用户活跃
if h.activityService != nil {
go func() {
ctx := context.Background()
loginType := model.LoginTypeLogin
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
}()
}
// 更新登录日志补充IP和UserAgent
if h.logService != nil {
go func() {
h.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
LoginType: string(model.LoginTypePassword),
Event: string(model.LoginEventLogin),
IP: ip,
UserAgent: userAgent,
Result: string(model.LoginResultSuccess),
})
}()
}
response.Success(c, gin.H{
"user": dto.ConvertUserToSelfResponse(user, int(user.PostsCount)),
"token": accessToken,
"refresh_token": refreshToken,
})
}
// SendRegisterCode 发送注册验证码
func (h *UserHandler) SendRegisterCode(c *gin.Context) {
type SendCodeRequest struct {
Email string `json:"email" binding:"required,email"`
}
var req SendCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
clientIP := c.ClientIP()
if err := h.userService.SendRegisterCode(c.Request.Context(), req.Email, clientIP); err != nil {
response.HandleError(c, err, "failed to send register verification code")
return
}
response.Success(c, gin.H{"success": true})
}
func (h *UserHandler) SendPasswordResetCode(c *gin.Context) {
type SendCodeRequest struct {
Email string `json:"email" binding:"required,email"`
}
var req SendCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
clientIP := c.ClientIP()
if err := h.userService.SendPasswordResetCode(c.Request.Context(), req.Email, clientIP); err != nil {
response.HandleError(c, err, "failed to send reset verification code")
return
}
response.Success(c, gin.H{"success": true})
}
// ResetPassword 找回密码并重置
func (h *UserHandler) ResetPassword(c *gin.Context) {
type ResetPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
VerificationCode string `json:"verification_code" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.userService.ResetPasswordByEmail(c.Request.Context(), req.Email, req.VerificationCode, req.NewPassword); err != nil {
response.HandleError(c, err, "failed to reset password")
return
}
response.Success(c, gin.H{"success": true})
}
// GetCurrentUser 获取当前用户
func (h *UserHandler) GetCurrentUser(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
user, err := h.userService.GetUserByID(c.Request.Context(), userID)
if err != nil {
response.NotFound(c, "user not found")
return
}
postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), userID)
if err != nil {
postsCount = int64(user.PostsCount)
}
response.Success(c, dto.ConvertUserToSelfResponse(user, int(postsCount)))
}
// GetUserByID 获取指定用户
func (h *UserHandler) GetUserByID(c *gin.Context) {
id := c.Param("id")
currentUserID := c.GetString("user_id")
// 获取用户信息,包含双向关注状态
user, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), id, currentUserID)
if err != nil {
response.NotFound(c, "user not found")
return
}
// 实时计算帖子数量
postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), id)
if err != nil {
// 如果获取失败,使用数据库中的值
postsCount = int64(user.PostsCount)
}
// 转换为响应格式,包含双向关注状态和实时计算的帖子数量
userResponse := dto.ConvertUserToResponse(user, dto.WithFollowing(isFollowing, isFollowingMe), dto.WithPostsCount(int(postsCount)))
response.Success(c, userResponse)
}
// UpdateUser 更新用户
func (h *UserHandler) UpdateUser(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
type UpdateRequest struct {
Nickname string `json:"nickname"`
Bio string `json:"bio"`
Website string `json:"website"`
Location string `json:"location"`
Avatar string `json:"avatar"`
CoverURL string `json:"cover_url"`
Phone *string `json:"phone"`
Email *string `json:"email"`
}
var req UpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
user, err := h.userService.GetUserByID(c.Request.Context(), userID)
if err != nil {
response.NotFound(c, "user not found")
return
}
if req.Nickname != "" {
user.Nickname = req.Nickname
}
if req.Website != "" {
user.Website = req.Website
}
if req.Location != "" {
user.Location = req.Location
}
if req.Phone != nil {
user.Phone = req.Phone
}
if req.Email != nil {
if user.Email == nil || *user.Email != *req.Email {
user.EmailVerified = false
}
user.Email = req.Email
}
avatarNeedsAudit := req.Avatar != "" && req.Avatar != user.Avatar && h.profileAuditService != nil
coverNeedsAudit := req.CoverURL != "" && req.CoverURL != user.CoverURL && h.profileAuditService != nil
bioNeedsAudit := req.Bio != "" && req.Bio != user.Bio && h.profileAuditService != nil
if avatarNeedsAudit {
if _, err := h.profileAuditService.SubmitAvatarAudit(c.Request.Context(), userID, req.Avatar); err != nil {
avatarNeedsAudit = false
user.Avatar = req.Avatar
}
} else if req.Avatar != "" {
user.Avatar = req.Avatar
}
if coverNeedsAudit {
if _, err := h.profileAuditService.SubmitCoverAudit(c.Request.Context(), userID, req.CoverURL); err != nil {
coverNeedsAudit = false
user.CoverURL = req.CoverURL
}
} else if req.CoverURL != "" {
user.CoverURL = req.CoverURL
}
if bioNeedsAudit {
if _, err := h.profileAuditService.SubmitBioAudit(c.Request.Context(), userID, req.Bio); err != nil {
bioNeedsAudit = false
user.Bio = req.Bio
}
} else if req.Bio != "" {
user.Bio = req.Bio
}
err = h.userService.UpdateUser(c.Request.Context(), user)
if err != nil {
response.InternalServerError(c, "failed to update user")
return
}
postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), userID)
if err != nil {
postsCount = int64(user.PostsCount)
}
resp := gin.H{"user": dto.ConvertUserToDetailResponseWithPostsCount(user, int(postsCount))}
if avatarNeedsAudit || coverNeedsAudit || bioNeedsAudit {
resp["audit_pending"] = true
}
response.Success(c, resp)
}
// SendEmailVerifyCode 发送当前用户邮箱验证码
func (h *UserHandler) SendEmailVerifyCode(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
type SendCodeRequest struct {
Email string `json:"email" binding:"required,email"`
}
var req SendCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.userService.SendCurrentUserEmailVerifyCode(c.Request.Context(), userID, req.Email, c.ClientIP()); err != nil {
response.HandleError(c, err, "failed to send email verify code")
return
}
response.Success(c, gin.H{"success": true})
}
// VerifyEmail 验证当前用户邮箱
func (h *UserHandler) VerifyEmail(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
type VerifyEmailRequest struct {
Email string `json:"email" binding:"required,email"`
VerificationCode string `json:"verification_code" binding:"required"`
}
var req VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.userService.VerifyCurrentUserEmail(c.Request.Context(), userID, req.Email, req.VerificationCode); err != nil {
response.HandleError(c, err, "failed to verify email")
return
}
response.Success(c, gin.H{"success": true})
}
// RefreshToken 刷新Token
//
// 安全语义:
// - 仅接受 refresh tokenjwt.ParseRefreshToken 严格校验 typ=refresh
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
// - 不接受 access tokenaccess token 不能在刷新端点换取新令牌。
func (h *UserHandler) RefreshToken(c *gin.Context) {
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
// 获取用户信息用于填充新令牌的 username 声明。
// 先解析 refresh 拿 userID再查 DB不直接信任 JWT 中的 username。
claims, err := h.jwtService.ParseRefreshToken(req.RefreshToken)
if err != nil {
response.HandleError(c, apperrors.ErrInvalidToken, "invalid refresh token")
return
}
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
if err != nil {
response.HandleError(c, apperrors.ErrUserNotFound, "user not found")
return
}
if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion {
response.HandleError(c, apperrors.ErrUserBanned, "user banned")
return
}
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
refreshExpire := time.Duration(7*24) * time.Hour
_, accessToken, refreshToken, err := h.sessionService.Rotate(c.Request.Context(), req.RefreshToken, user.Username, userAgent, ip, refreshExpire)
if err != nil {
response.HandleError(c, err, "failed to refresh token")
return
}
// 记录用户活跃
if h.activityService != nil {
go func() {
ctx := context.Background()
loginType := model.LoginTypeRefresh
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
}()
}
// 记录Token刷新日志
if h.logService != nil {
go func() {
h.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
LoginType: string(model.LoginTypePassword),
Event: string(model.LoginEventRefresh),
IP: ip,
UserAgent: userAgent,
Result: string(model.LoginResultSuccess),
TokenID: generateTokenID(req.RefreshToken),
})
}()
}
response.Success(c, gin.H{
"token": accessToken,
"refresh_token": refreshToken,
})
}
// FollowUser 关注用户
func (h *UserHandler) FollowUser(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.GetString("user_id")
if userID == currentUserID {
response.BadRequest(c, "cannot follow yourself")
return
}
err := h.userService.FollowUser(c.Request.Context(), currentUserID, userID)
if err != nil {
response.InternalServerError(c, "failed to follow user")
return
}
response.Success(c, gin.H{"success": true})
}
// UnfollowUser 取消关注用户
func (h *UserHandler) UnfollowUser(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.GetString("user_id")
err := h.userService.UnfollowUser(c.Request.Context(), currentUserID, userID)
if err != nil {
response.InternalServerError(c, "failed to unfollow user")
return
}
response.Success(c, gin.H{"success": true})
}
// BlockUser 拉黑用户
func (h *UserHandler) BlockUser(c *gin.Context) {
targetUserID := c.Param("id")
currentUserID := c.GetString("user_id")
if targetUserID == currentUserID {
response.BadRequest(c, "cannot block yourself")
return
}
err := h.userService.BlockUser(c.Request.Context(), currentUserID, targetUserID)
if err != nil {
response.HandleError(c, err, "failed to block user")
return
}
response.Success(c, gin.H{"success": true})
}
// UnblockUser 取消拉黑
func (h *UserHandler) UnblockUser(c *gin.Context) {
targetUserID := c.Param("id")
currentUserID := c.GetString("user_id")
if targetUserID == currentUserID {
response.BadRequest(c, "cannot unblock yourself")
return
}
err := h.userService.UnblockUser(c.Request.Context(), currentUserID, targetUserID)
if err != nil {
response.HandleError(c, err, "failed to unblock user")
return
}
response.Success(c, gin.H{"success": true})
}
// GetBlockedUsers 获取黑名单列表
func (h *UserHandler) GetBlockedUsers(c *gin.Context) {
currentUserID := c.GetString("user_id")
if currentUserID == "" {
response.Unauthorized(c, "")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
users, total, err := h.userService.GetBlockedUsers(c.Request.Context(), currentUserID, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get blocked users")
return
}
userIDs := make([]string, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
userResponses := dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
response.Paginated(c, userResponses, total, page, pageSize)
}
// GetBlockStatus 获取拉黑状态
func (h *UserHandler) GetBlockStatus(c *gin.Context) {
targetUserID := c.Param("id")
currentUserID := c.GetString("user_id")
if currentUserID == "" {
response.Unauthorized(c, "")
return
}
if targetUserID == "" {
response.BadRequest(c, "target user id is required")
return
}
isBlocked, err := h.userService.IsBlocked(c.Request.Context(), currentUserID, targetUserID)
if err != nil {
response.InternalServerError(c, "failed to get block status")
return
}
response.Success(c, gin.H{"is_blocked": isBlocked})
}
// GetFollowingList 获取关注列表
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")
keyword := c.Query("keyword")
users, err := h.userService.GetFollowingList(c.Request.Context(), userID, page, pageSize, keyword)
if err != nil {
response.InternalServerError(c, "failed to get following list")
return
}
var userResponses []*dto.UserResponse
if len(users) > 0 {
userIDs := make([]string, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
if currentUserID != "" && userID == currentUserID {
statusMap, _ := h.userService.GetMutualFollowStatus(c.Request.Context(), currentUserID, userIDs)
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, statusMap, postsCountMap)
} else {
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
}
} else {
userResponses = dto.ConvertUsersToResponse(users)
}
response.Success(c, gin.H{
"list": userResponses,
})
}
// GetFollowersList 获取粉丝列表
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")
keyword := c.Query("keyword")
users, err := h.userService.GetFollowersList(c.Request.Context(), userID, page, pageSize, keyword)
if err != nil {
response.InternalServerError(c, "failed to get followers list")
return
}
var userResponses []*dto.UserResponse
if len(users) > 0 {
userIDs := make([]string, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
if currentUserID != "" && userID == currentUserID {
statusMap, _ := h.userService.GetMutualFollowStatus(c.Request.Context(), currentUserID, userIDs)
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, statusMap, postsCountMap)
} else {
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
}
} else {
userResponses = dto.ConvertUsersToResponse(users)
}
response.Success(c, gin.H{
"list": userResponses,
})
}
// CheckUsername 检查用户名是否可用
func (h *UserHandler) CheckUsername(c *gin.Context) {
username := c.Query("username")
if username == "" {
response.BadRequest(c, "username is required")
return
}
available, err := h.userService.CheckUsernameAvailable(c.Request.Context(), username)
if err != nil {
response.InternalServerError(c, "failed to check username")
return
}
response.Success(c, gin.H{"available": available})
}
// ChangePassword 修改密码
func (h *UserHandler) ChangePassword(c *gin.Context) {
currentUserID := c.GetString("user_id")
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
VerificationCode string `json:"verification_code" binding:"required"`
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
err := h.userService.ChangePassword(c.Request.Context(), currentUserID, req.OldPassword, req.NewPassword, req.VerificationCode)
if err != nil {
response.HandleError(c, err, "failed to change password")
return
}
response.Success(c, gin.H{"success": true})
}
// SendChangePasswordCode 发送修改密码验证码
func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
currentUserID := c.GetString("user_id")
if currentUserID == "" {
response.Unauthorized(c, "")
return
}
err := h.userService.SendChangePasswordCode(c.Request.Context(), currentUserID, c.ClientIP())
if err != nil {
response.HandleError(c, err, "failed to send change password code")
return
}
response.Success(c, gin.H{"success": true})
}
// Logout 用户登出
//
// 安全语义:撤销当前会话,使 refresh token 立即失效。
// access token 在剩余 TTL 内仍可用(无状态 JWT 的固有限制),但 refresh token 已撤销,
// 攻击者无法用 refresh token 续期access token 也会在短 TTL 内自然过期。
// 如需 access token 即时失效,可后续引入 jti 黑名单。
func (h *UserHandler) Logout(c *gin.Context) {
principal, ok := auth.GetPrincipal(c)
if !ok || principal == nil {
response.Unauthorized(c, "not logged in")
return
}
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
// 撤销当前会话。
if h.sessionService != nil && principal.SessionID != "" {
if err := h.sessionService.Revoke(c.Request.Context(), principal.SessionID); err != nil {
// 撤销失败不阻塞登出流程,但记日志便于排查。
zap.L().Warn("failed to revoke session on logout",
zap.String("session_id", principal.SessionID),
zap.Error(err),
)
}
}
// 失效 Principal/Session 缓存,避免登出后短时间内旧 token 仍命中缓存。
auth.InvalidatePrincipalCache(h.cache, principal.UserID)
auth.InvalidateSessionCache(h.cache, principal.SessionID)
// 获取用户信息
user, err := h.userService.GetUserByID(c.Request.Context(), principal.UserID)
if err != nil {
response.InternalServerError(c, "user not found")
return
}
// 记录登出日志
if h.logService != nil {
h.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
Event: string(model.LoginEventLogout),
IP: ip,
UserAgent: userAgent,
Result: string(model.LoginResultSuccess),
})
}
response.Success(c, gin.H{"success": true})
}
// Search 搜索用户
func (h *UserHandler) Search(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
users, total, err := h.userService.Search(c.Request.Context(), keyword, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to search users")
return
}
// 获取实时计算的帖子数量
var userResponses []*dto.UserResponse
if len(users) > 0 {
userIDs := make([]string, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
} else {
userResponses = dto.ConvertUsersToResponse(users)
}
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)
}