feat(P1): Account 模型扩展 + 头像上传 + 个人简介 + Refresh Token 机制

This commit is contained in:
Sisyphus
2026-04-25 18:49:16 +08:00
parent 2e0c5dd632
commit 9b20df4bf3
6 changed files with 392 additions and 146 deletions

View File

@@ -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"`
}

View File

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

View File

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

View File

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

View File

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

View File

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