Files
VLoop/docs/plans/2025-04-25-p1-implementation.md
2026-04-25 18:39:54 +08:00

12 KiB
Raw Blame History

P1 用户基石 实施计划

For Claude: REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task.

Goal: 扩展 Account 模型(头像+简介)、实现双 Token 登录态优化Access + Refresh Token

Architecture: Account 模型加 avatar_url/bio/refresh_token 字段;复用 UploadCover 的文件上传逻辑做头像JWT 双 Token 机制 — Access 15min / Refresh 7天

Tech Stack: Go + Gin + GORM + JWT + Vue 3 + Pinia


Task 1: Account 模型扩展

Files:

  • Modify: backend/internal/account/entity.go:3-8

Step 1: 修改 Account struct

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

Step 2: 编译验证

Run: go build ./... Expected: 编译通过AutoMigrate 自动加列)

Step 3: Commit

git add backend/internal/account/entity.go
git commit -m "feat: Account 模型加 avatar_url/bio/refresh_token 字段"

Task 2: 头像上传 Handler

Files:

  • Modify: backend/internal/account/handler.go — 新增 UploadAvatar 方法
  • Modify: backend/internal/http/router.go — 注册路由

Step 1: 添加 UploadAvatar handler

参考 video/video_handler.goUploadCover,在 account/handler.go 中新增:

func (ah *AccountHandler) UploadAvatar(c *gin.Context) {
    accountID, err := jwt.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 := ah.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})
}

需要新增 import: "os", "path", "path/filepath", "crypto/rand", "encoding/hex", "strconv", "strings", "net/http" — 但 account/handler.go 已有部分,按需补。

同时需要从 video_handler.go 复制 randHexbuildAbsoluteURL 函数(或提取到公共 util

Step 2: 在 router.go 注册路由

protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar)

Step 3: 添加 AccountService.UpdateAvatar 方法

func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
    return as.accountRepo.UpdateAvatar(ctx, accountID, avatarURL)
}

Step 4: 添加 AccountRepository.UpdateAvatar 方法

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
}

Step 5: 编译验证

Run: go build ./... Expected: 通过

Step 6: Commit

git add backend/internal/account/handler.go backend/internal/account/service.go backend/internal/account/repo.go backend/internal/http/router.go
git commit -m "feat: 头像上传接口 /account/uploadAvatar"

Task 3: 更新个人简介接口

Files:

  • Modify: backend/internal/account/handler.go — 新增 UpdateProfile
  • Modify: backend/internal/http/router.go — 注册路由

Step 1: 新增 request struct + handler

entity.go 加:

type UpdateProfileRequest struct {
    AvatarURL string `json:"avatar_url"`
    Bio       string `json:"bio"`
}

Handler:

func (ah *AccountHandler) UpdateProfile(c *gin.Context) {
    accountID, err := jwt.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(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    if err := ah.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"})
}

Step 2: Service + Repo 层

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.accountRepo.UpdateFields(ctx, accountID, updates)
}

Step 3: 注册路由

protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile)

Step 4: 编译 + 提交

Run: go build ./... Expected: 通过

git add backend/internal/account/ && git add backend/internal/http/router.go
git commit -m "feat: 个人简介更新接口 /account/updateProfile"

Task 4: Refresh Token 机制

Files:

  • Modify: backend/internal/auth/jwt.go — 新增 GenerateRefreshToken + ValidateRefreshToken
  • Modify: backend/internal/account/handler.go — 新增 Refresh handler
  • Modify: backend/internal/account/service.go — Login 返回双 token
  • Modify: backend/internal/http/router.go — 注册 refresh 路由

Step 1: auth/jwt.go 增加 Refresh Token

const (
    AccessTokenTTL  = 15 * time.Minute
    RefreshTokenTTL = 7 * 24 * time.Hour
)

func GenerateAccessToken(accountID uint, username string) (string, error) {
    // 原 GenerateToken 逻辑TTL 改为 15min
}

func GenerateRefreshToken(accountID uint) (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

Step 2: Login 返回双 token

修改 account/service.goLogin 方法,返回值从 (string, error) 改为 (accessToken, refreshToken string, err error),并更新 entity.go 中的 LoginResponse

type LoginResponse struct {
    Token        string `json:"token"`         // access token
    RefreshToken string `json:"refresh_token"` // refresh token
    AccountID    uint   `json:"account_id"`
    Username     string `json:"username"`
}

Login 时生成两个 tokenaccess token 落库 account.tokenrefresh token 落库 account.refresh_token,两者都缓存到 Redis。

Step 3: Refresh handler

新增 POST /account/refresh

type RefreshRequest struct {
    RefreshToken string `json:"refresh_token"`
}

func (ah *AccountHandler) Refresh(c *gin.Context) {
    var req RefreshRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    newAccessToken, err := ah.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, gin.H{"token": newAccessToken})
}

AccountService.RefreshAccessToken:查 Redis account:{id}:refresh → 匹配 → 生成新 access token → 更新 token 字段。

Step 4: 登出/改密时同时清空 refresh_token

LogoutChangePassword 中增加 Del("account:{id}:refresh")

Step 5: 编译 + 提交

git add backend/internal/auth/jwt.go backend/internal/account/
git commit -m "feat: Refresh Token 机制 — Access 15min + Refresh 7天"

Task 5: 前端 auth store + client.ts 适配双 Token

Files:

  • Modify: frontend/src/stores/auth.ts
  • Modify: frontend/src/api/client.ts
  • Modify: frontend/src/api/account.ts

Step 1: auth store 存储双 token

const ACCESS_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'

// 新增字段
const refreshToken = ref<string | null>(readToken(REFRESH_KEY))

function setTokens(access: string, refresh: string) {
    token.value = access; refreshToken.value = refresh
    writeToken(ACCESS_KEY, access); writeToken(REFRESH_KEY, refresh)
}

function clearTokens() {
    token.value = null; refreshToken.value = null
    removeToken(ACCESS_KEY); removeToken(REFRESH_KEY)
}

Step 2: client.ts 401 自动刷新

async function tryRefresh(): Promise<string | null> {
    const auth = useAuthStore()
    if (!auth.refreshToken) return null
    try {
        const res = await postJson<{ token: string }>('/account/refresh', { refresh_token: auth.refreshToken })
        auth.setToken(res.token)
        return res.token
    } catch {
        auth.clearTokens()
        return null
    }
}

postJsonpostForm!res.ok 分支中401 时先尝试刷新,成功则重试原请求。

Step 3: 编译验证

Run: npm run build Expected: 通过

Step 4: Commit

git add frontend/src/stores/auth.ts frontend/src/api/client.ts frontend/src/api/account.ts
git commit -m "feat: 前端双 Token 适配 — 401 自动刷新 + Refresh Token 存储"

Task 6: 前端用户 Profile UI

Files:

  • Modify: frontend/src/views/AccountView.vue
  • Modify: frontend/src/components/UserAvatar.vue
  • Modify: frontend/src/views/HomeView.vue — Feed 卡片中 UserAvatar 传递头像 URL
  • Modify: frontend/src/api/account.ts — 新增 API 调用

Step 1: UserAvatar 支持 src

<script setup>
defineProps<{ username: string; id: number; size?: number; src?: string }>()
</script>
<template>
  <img v-if="src" :src="src" :width="size" :height="size" class="avatar-img" />
  <svg v-else ...> <!-- 默认 SVG -->
</template>

Step 2: AccountView 加头像上传 + bio 编辑

在登录后的 AccountView 中增加:头像上传按钮(调用 /account/uploadAvatar、bio 编辑输入框(调用 /account/updateProfile)。

Step 3: 编译验证

Run: npm run build Expected: 通过

Step 4: Commit

git add frontend/src/components/UserAvatar.vue frontend/src/views/AccountView.vue frontend/src/views/HomeView.vue frontend/src/api/account.ts
git commit -m "feat: 前端用户 Profile UI — 头像上传 + bio 编辑 + 登录记住我"

验证清单

完成所有 Task 后:

cd backend && go build ./... && go vet ./... && go test ./...
cd frontend && npm run build

Expected: 全部通过