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
1068 lines
30 KiB
Go
1068 lines
30 KiB
Go
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 token(jwt.ParseRefreshToken 严格校验 typ=refresh)。
|
||
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
|
||
// - 不接受 access token,access 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)
|
||
}
|