From 9b20df4bf36ca12224b7ee0e287334a1df2eca3f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 18:49:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(P1):=20Account=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=89=A9=E5=B1=95=20+=20=E5=A4=B4=E5=83=8F=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=20+=20=E4=B8=AA=E4=BA=BA=E7=AE=80=E4=BB=8B=20+=20Refresh=20Tok?= =?UTF-8?q?en=20=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/account/entity.go | 113 +++++++++------- backend/internal/account/handler.go | 119 ++++++++++++++++- backend/internal/account/repo.go | 192 +++++++++++++++------------- backend/internal/account/service.go | 101 +++++++++++++-- backend/internal/auth/jwt.go | 10 +- backend/internal/http/router.go | 3 + 6 files changed, 392 insertions(+), 146 deletions(-) diff --git a/backend/internal/account/entity.go b/backend/internal/account/entity.go index 045c89a..3c9efa5 100644 --- a/backend/internal/account/entity.go +++ b/backend/internal/account/entity.go @@ -1,46 +1,67 @@ -package account - -type Account struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"unique" json:"username"` - Password string `json:"-"` - Token string `json:"-"` -} - -type CreateAccountRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type RenameRequest struct { - NewUsername string `json:"new_username"` -} - -type FindByIDRequest struct { - ID uint `json:"id"` -} - -type FindByIDResponse struct { - ID uint `json:"id"` - Username string `json:"username"` -} - -type FindByUsernameRequest struct { - Username string `json:"username"` -} - -type FindByUsernameResponse struct { - ID uint `json:"id"` - Username string `json:"username"` -} - -type ChangePasswordRequest struct { - Username string `json:"username"` - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` -} - -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} +package account + +type Account struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"unique" json:"username"` + Password string `json:"-"` + Token string `json:"-"` + RefreshToken string `json:"-"` + AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"` + Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"` +} + +type CreateAccountRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RenameRequest struct { + NewUsername string `json:"new_username"` +} + +type FindByIDRequest struct { + ID uint `json:"id"` +} + +type FindByIDResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url,omitempty"` + Bio string `json:"bio,omitempty"` +} + +type FindByUsernameRequest struct { + Username string `json:"username"` +} + +type FindByUsernameResponse struct { + ID uint `json:"id"` + Username string `json:"username"` +} + +type ChangePasswordRequest struct { + Username string `json:"username"` + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + AccountID uint `json:"account_id"` + Username string `json:"username"` +} + +type UpdateProfileRequest struct { + AvatarURL string `json:"avatar_url"` + Bio string `json:"bio"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} diff --git a/backend/internal/account/handler.go b/backend/internal/account/handler.go index 144154a..ba00a07 100644 --- a/backend/internal/account/handler.go +++ b/backend/internal/account/handler.go @@ -1,7 +1,16 @@ package account import ( + "crypto/rand" + "encoding/hex" "errors" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" "feedsystem_video_go/internal/apierror" @@ -110,12 +119,17 @@ func (h *AccountHandler) Login(c *gin.Context) { c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } - if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil { + account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username) + if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return - } else { - c.JSON(200, gin.H{"token": token}) } + accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username}) } func (h *AccountHandler) Logout(c *gin.Context) { @@ -131,6 +145,105 @@ func (h *AccountHandler) Logout(c *gin.Context) { c.JSON(200, gin.H{"message": "account logged out"}) } +func (h *AccountHandler) UploadAvatar(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + f, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + const maxSize = 10 << 20 + if f.Size <= 0 || f.Size > maxSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) + return + } + ext := strings.ToLower(filepath.Ext(f.Filename)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"}) + return + } + dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10)) + if err := os.MkdirAll(dir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + filename, err := randHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + filename = filename + ext + absPath := filepath.Join(dir, filename) + if err := c.SaveUploadedFile(f, absPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename) + avatarURL := buildAbsoluteURL(c, urlPath) + if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL}) +} + +func (h *AccountHandler) UpdateProfile(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + var req UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "profile updated"}) +} + +func (h *AccountHandler) Refresh(c *gin.Context) { + var req RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username}) +} + +func randHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand.Read: %w", err) + } + return hex.EncodeToString(b), nil +} + +func buildAbsoluteURL(c *gin.Context, p string) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { + scheme = xf + } + return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) +} + func getAccountID(c *gin.Context) (uint, error) { value, exists := c.Get("accountID") if !exists { diff --git a/backend/internal/account/repo.go b/backend/internal/account/repo.go index f4a0e61..316d23a 100644 --- a/backend/internal/account/repo.go +++ b/backend/internal/account/repo.go @@ -1,86 +1,106 @@ -package account - -import ( - "context" - - "gorm.io/gorm" -) - -type AccountRepository struct { - db *gorm.DB -} - -func NewAccountRepository(db *gorm.DB) *AccountRepository { - return &AccountRepository{db: db} -} - -func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error { - if err := ar.db.WithContext(ctx).Create(account).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error { - result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error { - return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil { - return err - } - return nil - }) -} - -func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error { - if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) { - var account Account - if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil { - return nil, err - } - return &account, nil -} - -func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) { - var account Account - if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil { - return nil, err - } - return &account, nil -} - -func (ar *AccountRepository) Login(ctx context.Context, id uint, token string) error { - if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) Logout(ctx context.Context, id uint) error { - if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", "").Error; err != nil { - return err - } - return nil -} +package account + +import ( + "context" + + "gorm.io/gorm" +) + +type AccountRepository struct { + db *gorm.DB +} + +func NewAccountRepository(db *gorm.DB) *AccountRepository { + return &AccountRepository{db: db} +} + +func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error { + if err := ar.db.WithContext(ctx).Create(account).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error { + result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error { + return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil { + return err + } + return nil + }) +} + +func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error { + if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) { + var account Account + if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil { + return nil, err + } + return &account, nil +} + +func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) { + var account Account + if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil { + return nil, err + } + return &account, nil +} + +func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error { + if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": token, "refresh_token": refreshToken}).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) Logout(ctx context.Context, id uint) error { + if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": "", "refresh_token": ""}).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { + return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error +} + +func (ar *AccountRepository) UpdateToken(ctx context.Context, id uint, token string) error { + return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error +} + +func (ar *AccountRepository) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error { + return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(updates).Error +} + +func (ar *AccountRepository) FindAll(ctx context.Context) ([]*Account, error) { + var accounts []*Account + if err := ar.db.WithContext(ctx).Find(&accounts).Error; err != nil { + return nil, err + } + return accounts, nil +} diff --git a/backend/internal/account/service.go b/backend/internal/account/service.go index cab8a3d..406109b 100644 --- a/backend/internal/account/service.go +++ b/backend/internal/account/service.go @@ -5,6 +5,8 @@ import ( "errors" "feedsystem_video_go/internal/auth" "log" + "strconv" + "strings" "time" rediscache "feedsystem_video_go/internal/middleware/redis" @@ -108,31 +110,40 @@ func (as *AccountService) FindByUsername(ctx context.Context, username string) ( } } -func (as *AccountService) Login(ctx context.Context, username, password string) (string, error) { +func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) { account, err := as.FindByUsername(ctx, username) if err != nil { - return "", err + return "", "", err } if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil { - return "", err + return "", "", err } - // generate token - token, err := auth.GenerateToken(account.ID, account.Username) + accessToken, err := auth.GenerateToken(account.ID, account.Username) if err != nil { - return "", err + return "", "", err } - if err := as.accountRepository.Login(ctx, account.ID, token); err != nil { - return "", err + refreshToken, err := auth.GenerateRefreshToken(account.ID) + if err != nil { + return "", "", err + } + if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil { + return "", "", err } if as.cache != nil { cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() - if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(token), 24*time.Hour); err != nil { + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(accessToken), 24*time.Hour); err != nil { log.Printf("failed to set cache: %v", err) } + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil { + log.Printf("failed to set refresh cache: %v", err) + } + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken), []byte(strconv.FormatUint(uint64(account.ID), 10)), 7*24*time.Hour); err != nil { + log.Printf("failed to set refresh lookup: %v", err) + } } - return token, nil + return accessToken, refreshToken, nil } func (as *AccountService) Logout(ctx context.Context, accountID uint) error { @@ -150,6 +161,76 @@ func (as *AccountService) Logout(ctx context.Context, accountID uint) error { if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d", account.ID)); err != nil { log.Printf("failed to del cache: %v", err) } + if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d:refresh", account.ID)); err != nil { + log.Printf("failed to del refresh cache: %v", err) + } + if account.RefreshToken != "" { + as.cache.Del(cacheCtx, as.cache.Key("refresh:%s", account.RefreshToken)) + } } return as.accountRepository.Logout(ctx, account.ID) } + +func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { + return as.accountRepository.UpdateAvatar(ctx, accountID, avatarURL) +} + +func (as *AccountService) FindAll(ctx context.Context) ([]*Account, error) { + return as.accountRepository.FindAll(ctx) +} + +func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error { + updates := map[string]interface{}{} + if req.Bio != "" { + updates["bio"] = strings.TrimSpace(req.Bio) + } + if req.AvatarURL != "" { + updates["avatar_url"] = strings.TrimSpace(req.AvatarURL) + } + if len(updates) == 0 { + return errors.New("nothing to update") + } + return as.accountRepository.UpdateFields(ctx, accountID, updates) +} + +func (as *AccountService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, uint, string, error) { + if refreshToken == "" { + return "", 0, "", errors.New("refresh token is empty") + } + if as.cache != nil { + cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + b, err := as.cache.GetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken)) + if err == nil { + idStr := string(b) + id, parseErr := strconv.ParseUint(idStr, 10, 64) + if parseErr == nil { + account, err := as.FindByID(ctx, uint(id)) + if err == nil && account != nil && account.RefreshToken == refreshToken { + newToken, err := auth.GenerateToken(account.ID, account.Username) + if err != nil { + return "", 0, "", err + } + as.accountRepository.UpdateToken(ctx, account.ID, newToken) + as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(newToken), 24*time.Hour) + return newToken, account.ID, account.Username, nil + } + } + } + } + accounts, err := as.FindAll(ctx) + if err != nil { + return "", 0, "", err + } + for _, acc := range accounts { + if acc.RefreshToken == refreshToken { + newToken, err := auth.GenerateToken(acc.ID, acc.Username) + if err != nil { + return "", 0, "", err + } + as.accountRepository.UpdateToken(ctx, acc.ID, newToken) + return newToken, acc.ID, acc.Username, nil + } + } + return "", 0, "", errors.New("invalid refresh token") +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 956a2e0..a441fc8 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -39,7 +39,7 @@ func GenerateToken(accountID uint, username string) (string, error) { AccountID: accountID, Username: username, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), }, @@ -50,6 +50,14 @@ func GenerateToken(accountID uint, username string) (string, error) { return token.SignedString(jwtSecret()) } +func GenerateRefreshToken(accountID uint) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + func ParseToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims( tokenString, diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go index 483752c..1ca3571 100644 --- a/backend/internal/http/router.go +++ b/backend/internal/http/router.go @@ -41,12 +41,15 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g accountGroup.POST("/changePassword", accountHandler.ChangePassword) accountGroup.POST("/findByID", accountHandler.FindByID) accountGroup.POST("/findByUsername", accountHandler.FindByUsername) + accountGroup.POST("/refresh", accountHandler.Refresh) } protectedAccountGroup := accountGroup.Group("") protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache)) { protectedAccountGroup.POST("/logout", accountHandler.Logout) protectedAccountGroup.POST("/rename", accountHandler.Rename) + protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar) + protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile) } // video videoRepository := video.NewVideoRepository(db)