From fb56475faf36b0029097eb39832676f9e9681263 Mon Sep 17 00:00:00 2001 From: dark Date: Sat, 14 Feb 2026 08:58:47 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E7=83=AD=E9=87=8D?= =?UTF-8?q?=E8=BD=BD+=E7=99=BB=E5=BD=95=E6=94=B9=E9=80=A0=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-02-14-hot-reload-login-redesign-impl.md | 854 ++++++++++++++++++ 1 file changed, 854 insertions(+) create mode 100644 docs/plans/2026-02-14-hot-reload-login-redesign-impl.md diff --git a/docs/plans/2026-02-14-hot-reload-login-redesign-impl.md b/docs/plans/2026-02-14-hot-reload-login-redesign-impl.md new file mode 100644 index 0000000..4915a97 --- /dev/null +++ b/docs/plans/2026-02-14-hot-reload-login-redesign-impl.md @@ -0,0 +1,854 @@ +# 后端热重载 + 登录方式改造 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Air hot reload for backend development, and change login from email to phone/username with JWT email removal. + +**Architecture:** Two independent changes — (1) Air config file + .gitignore, zero code changes; (2) LoginRequest.Email→Account, new Model queries FindOneByPhone/FindOneByUsername, backend regex auto-detect, JWT Claims remove Email, frontend login form adapt. + +**Tech Stack:** Air (hot reload), go-zero, GORM, React, TypeScript + +--- + +### Task 1: Air 热重载配置 + +**Files:** +- Create: `backend/.air.toml` +- Create: `backend/.gitignore` + +**Step 1: Install Air** + +Run: `go install github.com/air-verse/air@latest` +Expected: Binary installed to `$GOPATH/bin/air` + +**Step 2: Create `.air.toml`** + +Create `backend/.air.toml`: +```toml +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/base.exe base.go" + bin = "./tmp/base.exe -f etc/base-api.yaml" + include_ext = ["go", "yaml"] + exclude_dir = ["tmp", "vendor", "tests"] + delay = 1000 + +[log] + time = false + +[misc] + clean_on_exit = true +``` + +**Step 3: Create `backend/.gitignore`** + +Create `backend/.gitignore`: +``` +tmp/ +``` + +**Step 4: Verify Air works** + +Run: `cd backend && air` +Expected: Compiles and starts server on :8888, logs show "Starting server" +Kill with Ctrl+C after confirming. + +**Step 5: Commit** + +```bash +git add backend/.air.toml backend/.gitignore +git commit -m "feat: add Air hot reload config for backend development" +``` + +--- + +### Task 2: Model 层 — 新增 FindOneByPhone 和 FindOneByUsername + +**Files:** +- Modify: `backend/model/user_model.go` + +**Step 1: Add FindOneByPhone and FindOneByUsername** + +Add to `backend/model/user_model.go` after `FindOneByEmail` (after line 59): + +```go +// FindOneByPhone 根据手机号查询用户 +func FindOneByPhone(ctx context.Context, db *gorm.DB, phone string) (*User, error) { + var user User + result := db.WithContext(ctx).Where("phone = ?", phone).First(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, result.Error + } + return &user, nil +} + +// FindOneByUsername 根据用户名查询用户 +func FindOneByUsername(ctx context.Context, db *gorm.DB, username string) (*User, error) { + var user User + result := db.WithContext(ctx).Where("username = ?", username).First(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, result.Error + } + return &user, nil +} +``` + +**Step 2: Verify build** + +Run: `cd backend && go build ./...` +Expected: No errors + +**Step 3: Commit** + +```bash +git add backend/model/user_model.go +git commit -m "feat: add FindOneByPhone and FindOneByUsername model queries" +``` + +--- + +### Task 3: JWT — 移除 Email 字段 + +**Files:** +- Modify: `backend/internal/util/jwt/jwt.go` +- Modify: `backend/internal/util/jwt/jwt_test.go` + +**Step 1: Update Claims struct and GenerateToken** + +In `backend/internal/util/jwt/jwt.go`: + +Replace Claims struct (lines 18-24): +```go +type Claims struct { + UserID int64 `json:"userId"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} +``` + +Replace GenerateToken signature (line 27): +```go +func GenerateToken(userId int64, username, role string) (string, error) { +``` + +Replace Claims initialization (lines 28-32): +```go + claims := Claims{ + UserID: userId, + Username: username, + Role: role, +``` + +**Step 2: Update jwt_test.go** + +Replace entire `backend/internal/util/jwt/jwt_test.go`: +```go +package jwt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateToken_ContainsRole(t *testing.T) { + token, err := GenerateToken(1, "testuser", "admin") + require.NoError(t, err) + require.NotEmpty(t, token) + + claims, err := ParseToken(token) + require.NoError(t, err) + assert.Equal(t, int64(1), claims.UserID) + assert.Equal(t, "testuser", claims.Username) + assert.Equal(t, "admin", claims.Role) +} + +func TestGenerateToken_SuperAdminRole(t *testing.T) { + token, err := GenerateToken(99, "admin", "super_admin") + require.NoError(t, err) + + claims, err := ParseToken(token) + require.NoError(t, err) + assert.Equal(t, "super_admin", claims.Role) +} + +func TestGenerateToken_EmptyRole(t *testing.T) { + token, err := GenerateToken(1, "user", "") + require.NoError(t, err) + + claims, err := ParseToken(token) + require.NoError(t, err) + assert.Equal(t, "", claims.Role) +} +``` + +**Step 3: Run JWT tests** + +Run: `cd backend && go test -v ./internal/util/jwt/...` +Expected: 3/3 PASS + +**Step 4: Commit** + +```bash +git add backend/internal/util/jwt/jwt.go backend/internal/util/jwt/jwt_test.go +git commit -m "refactor: remove email from JWT Claims" +``` + +--- + +### Task 4: Auth Middleware — 移除 email context + +**Files:** +- Modify: `backend/internal/middleware/authmiddleware.go` + +**Step 1: Remove email context line** + +In `backend/internal/middleware/authmiddleware.go`, delete line 43: +```go +ctx = context.WithValue(ctx, "email", claims.Email) +``` + +**Step 2: Verify build** + +Run: `cd backend && go build ./...` +Expected: No errors + +**Step 3: Commit** + +```bash +git add backend/internal/middleware/authmiddleware.go +git commit -m "refactor: remove email from auth middleware context" +``` + +--- + +### Task 5: API 定义 — LoginRequest 和 RegisterRequest 改造 + +**Files:** +- Modify: `backend/base.api` (lines 37-40) +- Modify: `backend/internal/types/types.go` (auto-generated) +- Modify: `backend/internal/handler/routes.go` (auto-generated) + +**Step 1: Update LoginRequest in base.api** + +In `backend/base.api`, replace lines 37-40: +``` + // 登录请求 + LoginRequest { + Account string `json:"account" validate:"required"` // 手机号或用户名 + Password string `json:"password" validate:"required,min=6,max=32"` // 密码 + } +``` + +**Step 2: Update RegisterRequest in base.api** + +In `backend/base.api`, replace lines 30-35: +``` + // 注册请求 + RegisterRequest { + Username string `json:"username" validate:"required,min=3,max=32"` // 用户名 + Password string `json:"password" validate:"required,min=6,max=32"` // 密码 + Phone string `json:"phone" validate:"required"` // 手机号(必填) + Email string `json:"email,optional"` // 邮箱(可选) + } +``` + +**Step 3: Regenerate types and routes** + +Run: `cd backend && goctl api go -api base.api -dir .` +Expected: types.go and routes.go regenerated + +**Step 4: Verify build** + +Run: `cd backend && go build ./...` +Expected: Compile errors in loginlogic.go (req.Email → req.Account) — expected, will fix in next task + +**Step 5: Commit** + +```bash +git add backend/base.api backend/internal/types/types.go backend/internal/handler/routes.go +git commit -m "refactor: change LoginRequest to account field, RegisterRequest phone required" +``` + +--- + +### Task 6: Login Logic — 手机号/用户名自动识别 + +**Files:** +- Modify: `backend/internal/logic/auth/loginlogic.go` + +**Step 1: Rewrite loginlogic.go** + +Replace entire `backend/internal/logic/auth/loginlogic.go`: +```go +package auth + +import ( + "context" + "crypto/md5" + "fmt" + "regexp" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/youruser/base/internal/util/jwt" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +var phoneRegex = regexp.MustCompile(`^\d{11}$`) + +type LoginLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// 用户登录 +func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { + return &LoginLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, err error) { + // 根据输入自动识别:11位数字为手机号,否则为用户名 + var user *model.User + if phoneRegex.MatchString(req.Account) { + user, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Account) + } else { + user, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Account) + } + + if err != nil { + if err == model.ErrNotFound { + return &types.LoginResponse{ + Code: 404, + Message: "用户不存在或密码错误", + Success: false, + }, nil + } + return nil, fmt.Errorf("查询用户失败: %v", err) + } + + // SSO 用户不允许使用密码登录 + if user.UserType == "casdoor" { + return &types.LoginResponse{ + Code: 400, + Message: "该账号已绑定 SSO,请使用 SSO 方式登录", + Success: false, + }, nil + } + + // 加密输入的密码并与数据库密码对比 + inputPassword := fmt.Sprintf("%x", md5.Sum([]byte(req.Password))) + if user.Password != inputPassword { + return &types.LoginResponse{ + Code: 400, + Message: "用户不存在或密码错误", + Success: false, + }, nil + } + + // 生成 Token + token, err := jwt.GenerateToken(user.Id, user.Username, user.Role) + if err != nil { + return nil, fmt.Errorf("生成Token失败: %v", err) + } + + l.Infof("登录成功,userId=%d", user.Id) + + return &types.LoginResponse{ + Code: 200, + Message: "登录成功", + Success: true, + Token: token, + }, nil +} +``` + +**Step 2: Verify build** + +Run: `cd backend && go build ./...` +Expected: May still have errors in other logic files — continue to next tasks + +**Step 3: Commit** + +```bash +git add backend/internal/logic/auth/loginlogic.go +git commit -m "feat: login by phone or username with auto-detection" +``` + +--- + +### Task 7: Register Logic — 移除邮箱必填,手机号唯一性检查 + +**Files:** +- Modify: `backend/internal/logic/auth/registerlogic.go` + +**Step 1: Rewrite registerlogic.go** + +Replace entire `backend/internal/logic/auth/registerlogic.go`: +```go +package auth + +import ( + "context" + "crypto/md5" + "fmt" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// 用户注册 +func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { + return &RegisterLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterLogic) Register(req *types.RegisterRequest) (resp *types.UserInfo, err error) { + // 检查用户名是否已存在 + _, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Username) + if err == nil { + return nil, fmt.Errorf("用户名已被注册") + } + if err != model.ErrNotFound { + return nil, fmt.Errorf("检查用户名失败: %v", err) + } + + // 检查手机号是否已存在 + _, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Phone) + if err == nil { + return nil, fmt.Errorf("手机号已被注册") + } + if err != model.ErrNotFound { + return nil, fmt.Errorf("检查手机号失败: %v", err) + } + + // 创建用户模型 + user := &model.User{ + Username: req.Username, + Email: req.Email, + Password: fmt.Sprintf("%x", md5.Sum([]byte(req.Password))), + Phone: req.Phone, + Role: model.RoleUser, + Source: model.SourceRegister, + Status: 1, + } + + // 插入数据库 + id, err := model.Insert(l.ctx, l.svcCtx.DB, user) + if err != nil { + return nil, fmt.Errorf("创建用户失败: %v", err) + } + + // 查询创建的用户 + user, err = model.FindOne(l.ctx, l.svcCtx.DB, id) + if err != nil { + return nil, fmt.Errorf("查询用户失败: %v", err) + } + + resp = &types.UserInfo{ + Id: user.Id, + Username: user.Username, + Email: user.Email, + Phone: user.Phone, + Role: user.Role, + Source: user.Source, + Remark: user.Remark, + Status: int(user.Status), + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"), + } + + l.Infof("注册成功,userId=%d", user.Id) + return resp, nil +} +``` + +**Step 2: Commit** + +```bash +git add backend/internal/logic/auth/registerlogic.go +git commit -m "refactor: register checks username+phone uniqueness, email optional" +``` + +--- + +### Task 8: RefreshToken + SSO Logic — 更新 GenerateToken 调用 + +**Files:** +- Modify: `backend/internal/logic/auth/refreshtokenlogic.go:55` +- Modify: `backend/internal/logic/auth/ssologic.go:179` + +**Step 1: Fix refreshtokenlogic.go** + +In `backend/internal/logic/auth/refreshtokenlogic.go`, line 55, change: +```go + newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Email, user.Role) +``` +to: +```go + newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Role) +``` + +**Step 2: Fix ssologic.go** + +In `backend/internal/logic/auth/ssologic.go`, line 179, change: +```go + token, err := jwt.GenerateToken(localUser.Id, localUser.Username, localUser.Email, localUser.Role) +``` +to: +```go + token, err := jwt.GenerateToken(localUser.Id, localUser.Username, localUser.Role) +``` + +**Step 3: Verify build** + +Run: `cd backend && go build ./...` +Expected: No errors (all GenerateToken calls now match new signature) + +**Step 4: Commit** + +```bash +git add backend/internal/logic/auth/refreshtokenlogic.go backend/internal/logic/auth/ssologic.go +git commit -m "refactor: update GenerateToken calls to remove email param" +``` + +--- + +### Task 9: 种子超级管理员 — 改为用户名查找 + 手机号 + +**Files:** +- Modify: `backend/internal/svc/servicecontext.go` (seedSuperAdmin function) + +**Step 1: Update seedSuperAdmin** + +In `backend/internal/svc/servicecontext.go`, replace the `seedSuperAdmin` function: + +```go +// seedSuperAdmin 首次启动创建超级管理员 +func seedSuperAdmin(db *gorm.DB) { + ctx := context.Background() + + // 先检查 admin 用户是否已存在 + existing, err := model.FindOneByUsername(ctx, db, "admin") + if err == nil { + // 用户已存在,确保角色为 super_admin + if existing.Role != model.RoleSuperAdmin { + existing.Role = model.RoleSuperAdmin + existing.Source = model.SourceSystem + model.Update(ctx, db, existing) + log.Println("[Seed] Updated admin to super_admin role") + } + return + } + + // 创建超级管理员 + password := fmt.Sprintf("%x", md5.Sum([]byte("admin123"))) + admin := &model.User{ + Username: "admin", + Phone: "13800000000", + Email: "", + Password: password, + Role: model.RoleSuperAdmin, + Source: model.SourceSystem, + Remark: "系统自动创建的超级管理员", + Status: 1, + } + + _, err = model.Insert(ctx, db, admin) + if err != nil { + log.Printf("[Seed] Failed to create super admin: %v", err) + return + } + log.Println("[Seed] Super admin created: admin / admin123") +} +``` + +**Step 2: Verify build** + +Run: `cd backend && go build ./...` +Expected: No errors + +**Step 3: Commit** + +```bash +git add backend/internal/svc/servicecontext.go +git commit -m "refactor: seed super admin uses username lookup and phone number" +``` + +--- + +### Task 10: Go 单元测试修复 — rbac_test.go + +**Files:** +- Modify: `backend/internal/logic/user/rbac_test.go` + +**Step 1: Update test to not require email** + +In `backend/internal/logic/user/rbac_test.go`, find all `CreateUserRequest` that have `Email` field and ensure they still work. The `CreateUserRequest` in `user.api` still has `Email` as a field (it's part of user management, not login). Check if the test needs any changes after the API type regeneration. + +Run: `cd backend && go test -v ./internal/logic/user/...` +Expected: If tests pass, no changes needed. If they fail due to type changes, fix accordingly. + +**Step 2: Commit (if changes needed)** + +```bash +git add backend/internal/logic/user/rbac_test.go +git commit -m "fix: update RBAC tests for new type definitions" +``` + +--- + +### Task 11: 后端集成测试 — 启动并验证登录 + +**Step 1: Start backend** + +Run: `cd backend && go run base.go -f etc/base-api.yaml` + +**Step 2: Test login with username** + +```bash +curl -s -X POST http://localhost:8888/api/v1/login \ + -H "Content-Type: application/json" \ + -d '{"account":"admin","password":"admin123"}' +``` +Expected: `{"code":200,"message":"登录成功","success":true,"token":"..."}` + +**Step 3: Test login with phone** + +```bash +curl -s -X POST http://localhost:8888/api/v1/login \ + -H "Content-Type: application/json" \ + -d '{"account":"13800000000","password":"admin123"}' +``` +Expected: `{"code":200,"message":"登录成功","success":true,"token":"..."}` + +**Step 4: Verify JWT has no email** + +Decode the token payload (base64 decode middle section) and confirm no `email` field. + +**Step 5: Test register with phone** + +```bash +curl -s -X POST http://localhost:8888/api/v1/register \ + -H "Content-Type: application/json" \ + -d '{"username":"testphone","password":"test123456","phone":"13900001111"}' +``` +Expected: `{"id":...,"username":"testphone","phone":"13900001111",...}` + +--- + +### Task 12: 前端 — TypeScript 类型更新 + +**Files:** +- Modify: `frontend/react-shadcn/pc/src/types/index.ts` + +**Step 1: Update LoginRequest** + +In `frontend/react-shadcn/pc/src/types/index.ts`, replace lines 33-36: +```typescript +export interface LoginRequest { + account: string + password: string +} +``` + +**Step 2: Update RegisterRequest** + +Replace lines 38-43: +```typescript +export interface RegisterRequest { + username: string + password: string + phone: string + email?: string +} +``` + +**Step 3: Commit** + +```bash +git add frontend/react-shadcn/pc/src/types/index.ts +git commit -m "refactor: frontend types - LoginRequest.account, RegisterRequest.phone required" +``` + +--- + +### Task 13: 前端 — API Client 和 AuthContext 更新 + +**Files:** +- Modify: `frontend/react-shadcn/pc/src/services/api.ts:70-80` +- Modify: `frontend/react-shadcn/pc/src/contexts/AuthContext.tsx` + +**Step 1: Update api.ts login method** + +In `frontend/react-shadcn/pc/src/services/api.ts`, the `login` method (line 70) accepts `LoginRequest` which now has `account` instead of `email`. No code change needed here since it just passes the object through — the type change in Task 12 handles it. + +**Step 2: Update AuthContext login function** + +In `frontend/react-shadcn/pc/src/contexts/AuthContext.tsx`: + +Change interface (line 10): +```typescript + login: (account: string, password: string) => Promise +``` + +Change login function (line 41): +```typescript + const login = async (account: string, password: string) => { +``` + +Change apiClient call (line 43): +```typescript + const response = await apiClient.login({ account, password }) +``` + +Change JWT parsing fallback username (line 52, inside the if block): +```typescript + username: payload.username || account, +``` + +Remove email from userData (line 53-54): +```typescript + email: payload.email || '', +``` + +And in the catch block fallback (lines 62-66): +```typescript + const userData: User = { + id: 0, + username: account, + email: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } +``` + +**Step 3: Commit** + +```bash +git add frontend/react-shadcn/pc/src/services/api.ts frontend/react-shadcn/pc/src/contexts/AuthContext.tsx +git commit -m "refactor: frontend auth uses account field, remove email from JWT parsing" +``` + +--- + +### Task 14: 前端 — LoginPage UI 改造 + +**Files:** +- Modify: `frontend/react-shadcn/pc/src/pages/LoginPage.tsx` + +**Step 1: Update imports** + +Replace `Mail` import with `User` (or `UserRound`): +```typescript +import { UserRound, Lock, AlertCircle, Eye, EyeOff, ArrowRight, Shield, Cpu, BarChart3 } from 'lucide-react' +``` + +**Step 2: Update state variable** + +Line 10, change: +```typescript + const [account, setAccount] = useState('') +``` + +**Step 3: Update handleSubmit** + +Line 53, change: +```typescript + await login(account, password) +``` + +**Step 4: Update email input to account input** + +Replace the email Input component (lines 198-206): +```tsx + setAccount(e.target.value)} + required + leftIcon={} + disabled={isLoading} + /> +``` + +**Step 5: Verify frontend compiles** + +Run: `cd frontend/react-shadcn/pc && npm run build` +Expected: No TypeScript errors + +**Step 6: Commit** + +```bash +git add frontend/react-shadcn/pc/src/pages/LoginPage.tsx +git commit -m "feat: login page accepts phone/username instead of email" +``` + +--- + +### Task 15: E2E 验证 — 全流程测试 + +**Step 1: Start backend and frontend** + +```bash +# Terminal 1 (use air if installed, otherwise go run) +cd backend && air +# Terminal 2 +cd frontend/react-shadcn/pc && npm run dev +``` + +**Step 2: Test login via browser** + +Navigate to http://localhost:5173/login +- Enter "admin" + "admin123" → should redirect to dashboard +- Logout, enter "13800000000" + "admin123" → should redirect to dashboard + +**Step 3: Test hot reload** + +Edit any .go file (e.g., add a comment), save, verify Air rebuilds and restarts automatically. + +**Step 4: Final commit with test config update** + +Update `frontend/react-shadcn/pc/tests/config.ts` superAdmin credentials: +```typescript + superAdmin: { + account: 'admin', + password: 'admin123', + }, +``` + +```bash +git add frontend/react-shadcn/pc/tests/config.ts +git commit -m "chore: update test config for account-based login" +```