From 2e0c5dd632ae9e039614177aa7501669f0b2cbb4 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 18:39:54 +0800 Subject: [PATCH] =?UTF-8?q?plan:=20P1=20=E7=94=A8=E6=88=B7=E5=9F=BA?= =?UTF-8?q?=E7=9F=B3=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=886=20Task?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2025-04-25-p1-implementation.md | 411 +++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/plans/2025-04-25-p1-implementation.md diff --git a/docs/plans/2025-04-25-p1-implementation.md b/docs/plans/2025-04-25-p1-implementation.md new file mode 100644 index 0000000..2774179 --- /dev/null +++ b/docs/plans/2025-04-25-p1-implementation.md @@ -0,0 +1,411 @@ +# 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** + +```go +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** + +```bash +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.go` 的 `UploadCover`,在 `account/handler.go` 中新增: + +```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` 复制 `randHex` 和 `buildAbsoluteURL` 函数(或提取到公共 util)。 + +**Step 2: 在 router.go 注册路由** + +```go +protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar) +``` + +**Step 3: 添加 AccountService.UpdateAvatar 方法** + +```go +func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { + return as.accountRepo.UpdateAvatar(ctx, accountID, avatarURL) +} +``` + +**Step 4: 添加 AccountRepository.UpdateAvatar 方法** + +```go +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** + +```bash +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` 加: +```go +type UpdateProfileRequest struct { + AvatarURL string `json:"avatar_url"` + Bio string `json:"bio"` +} +``` + +Handler: +```go +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 层** + +```go +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: 注册路由** + +```go +protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile) +``` + +**Step 4: 编译 + 提交** + +Run: `go build ./...` +Expected: 通过 + +```bash +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** + +```go +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.go` 的 `Login` 方法,返回值从 `(string, error)` 改为 `(accessToken, refreshToken string, err error)`,并更新 `entity.go` 中的 `LoginResponse`。 + +```go +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 时生成两个 token,access token 落库 `account.token`,refresh token 落库 `account.refresh_token`,两者都缓存到 Redis。 + +**Step 3: Refresh handler** + +新增 `POST /account/refresh`: + +```go +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** + +在 `Logout` 和 `ChangePassword` 中增加 `Del("account:{id}:refresh")`。 + +**Step 5: 编译 + 提交** + +```bash +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** + +```typescript +const ACCESS_KEY = 'access_token' +const REFRESH_KEY = 'refresh_token' + +// 新增字段 +const refreshToken = ref(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 自动刷新** + +```typescript +async function tryRefresh(): Promise { + 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 + } +} +``` + +在 `postJson` 和 `postForm` 的 `!res.ok` 分支中,401 时先尝试刷新,成功则重试原请求。 + +**Step 3: 编译验证** + +Run: `npm run build` +Expected: 通过 + +**Step 4: Commit** + +```bash +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** + +```vue + + +``` + +**Step 2: AccountView 加头像上传 + bio 编辑** + +在登录后的 AccountView 中增加:头像上传按钮(调用 `/account/uploadAvatar`)、bio 编辑输入框(调用 `/account/updateProfile`)。 + +**Step 3: 编译验证** + +Run: `npm run build` +Expected: 通过 + +**Step 4: Commit** + +```bash +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 后: + +```bash +cd backend && go build ./... && go vet ./... && go test ./... +cd frontend && npm run build +``` + +Expected: 全部通过