diff --git a/README.md b/README.md index b506e8e..70c5771 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ ```bash docker compose -f deploy/docker-compose.dev.yml up -d +docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/migrations/000001_init.sql + cd backend cp .env.example .env go run ./cmd/api diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index b4128af..adc0d23 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -9,6 +9,7 @@ import ( "time" "hfb_sys/backend/internal/config" + "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/router" "go.uber.org/zap" @@ -27,7 +28,28 @@ func main() { _ = logger.Sync() }() - engine := router.New(cfg, logger) + var deps router.Dependencies + db, err := database.OpenMySQL(cfg.MySQLDSN) + if err != nil { + logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err)) + } else { + deps.DB = db + } + redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{ + Addr: cfg.RedisAddr, + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + if err != nil { + logger.Warn("redis unavailable; redis-backed APIs will return 503", zap.Error(err)) + } else { + deps.Redis = redisClient + defer func() { + _ = redisClient.Close() + }() + } + + engine := router.New(cfg, deps, logger) server := &http.Server{ Addr: cfg.AppAddr, Handler: engine, diff --git a/backend/go.mod b/backend/go.mod index c27ca0c..cdedea1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/redis/go-redis/v9 v9.17.0 go.uber.org/zap v1.27.0 gorm.io/driver/mysql v1.6.0 diff --git a/backend/go.sum b/backend/go.sum index 5e3cfae..6ee61f3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -37,6 +37,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..124f847 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "strings" + + "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +const ( + ContextUserID = "user_id" + ContextPhone = "phone" +) + +func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + if tokenText == "" || tokenText == header { + response.Unauthorized(c, "缺少访问令牌") + c.Abort() + return + } + + claims, err := jwtManager.Parse(tokenText, "access") + if err != nil { + response.Unauthorized(c, "访问令牌无效或已过期") + c.Abort() + return + } + + c.Set(ContextUserID, claims.UserID) + c.Set(ContextPhone, claims.Phone) + c.Next() + } +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go new file mode 100644 index 0000000..be6cae2 --- /dev/null +++ b/backend/internal/model/user.go @@ -0,0 +1,21 @@ +package model + +import "time" + +type User struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` + Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` + AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` + RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` + RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"` + CreditScore int `gorm:"not null;default:100" json:"credit_score"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (User) TableName() string { + return "users" +} diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go new file mode 100644 index 0000000..180a5f9 --- /dev/null +++ b/backend/internal/modules/auth/handler.go @@ -0,0 +1,94 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +type SendSMSRequest struct { + Phone string `json:"phone" binding:"required"` +} + +type LoginRequest struct { + Phone string `json:"phone" binding:"required"` + Code string `json:"code" binding:"required,len=6"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) SendSMS(c *gin.Context) { + var req SendSMSRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "手机号不能为空") + return + } + if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone)); err != nil { + writeAuthError(c, err) + return + } + response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300}) +} + +func (h *Handler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "手机号和验证码不能为空") + return + } + result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code)) + if err != nil { + writeAuthError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) Refresh(c *gin.Context) { + var req RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "refresh_token 不能为空") + return + } + tokens, err := h.service.RefreshToken(req.RefreshToken) + if err != nil { + response.Unauthorized(c, "刷新令牌无效或已过期") + return + } + response.OK(c, tokens) +} + +func (h *Handler) Logout(c *gin.Context) { + response.OK(c, gin.H{"logged_out": true}) +} + +func writeAuthError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库或 Redis 未连接") + case errors.Is(err, ErrInvalidPhone): + response.BadRequest(c, "手机号格式不正确") + case errors.Is(err, ErrCodeRateLimited): + response.Error(c, http.StatusTooManyRequests, "rate_limited", "验证码发送过于频繁") + case errors.Is(err, ErrCodeInvalid): + response.BadRequest(c, "验证码错误或已过期") + case errors.Is(err, ErrUserDisabled): + response.Error(c, http.StatusForbidden, "user_disabled", "用户已被冻结") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用") + } +} diff --git a/backend/internal/modules/auth/jwt.go b/backend/internal/modules/auth/jwt.go new file mode 100644 index 0000000..ed7d988 --- /dev/null +++ b/backend/internal/modules/auth/jwt.go @@ -0,0 +1,95 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + tokenTypeAccess = "access" + tokenTypeRefresh = "refresh" +) + +var ErrInvalidToken = errors.New("invalid token") + +type JWTManager struct { + secret []byte + accessTTL time.Duration + refreshTTL time.Duration + signingMethod jwt.SigningMethod +} + +type Claims struct { + UserID uint64 `json:"uid"` + Phone string `json:"phone"` + TokenType string `json:"typ"` + jwt.RegisteredClaims +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresInSeconds int64 `json:"expires_in"` +} + +func NewJWTManager(secret string) *JWTManager { + return &JWTManager{ + secret: []byte(secret), + accessTTL: 2 * time.Hour, + refreshTTL: 14 * 24 * time.Hour, + signingMethod: jwt.SigningMethodHS256, + } +} + +func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error) { + accessToken, err := m.generate(userID, phone, tokenTypeAccess, m.accessTTL) + if err != nil { + return TokenPair{}, err + } + refreshToken, err := m.generate(userID, phone, tokenTypeRefresh, m.refreshTTL) + if err != nil { + return TokenPair{}, err + } + return TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + ExpiresInSeconds: int64(m.accessTTL.Seconds()), + }, nil +} + +func (m *JWTManager) Parse(tokenText, expectedType string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) { + if token.Method != m.signingMethod { + return nil, ErrInvalidToken + } + return m.secret, nil + }) + if err != nil || !token.Valid { + return nil, ErrInvalidToken + } + if claims.TokenType != expectedType { + return nil, ErrInvalidToken + } + return claims, nil +} + +func (m *JWTManager) generate(userID uint64, phone string, tokenType string, ttl time.Duration) (string, error) { + now := time.Now() + claims := Claims{ + UserID: userID, + Phone: phone, + TokenType: tokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: phone, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + token := jwt.NewWithClaims(m.signingMethod, claims) + return token.SignedString(m.secret) +} diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go new file mode 100644 index 0000000..78e4d4b --- /dev/null +++ b/backend/internal/modules/auth/repository.go @@ -0,0 +1,58 @@ +package auth + +import ( + "errors" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) FindByID(id uint64) (*model.User, error) { + var user model.User + if err := r.db.First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) FindOrCreateByPhone(phone string) (*model.User, error) { + now := time.Now() + user := model.User{ + Phone: phone, + Nickname: "用户" + phone[len(phone)-4:], + RealnameStatus: "unverified", + RiskStatus: "normal", + CreditScore: 100, + Status: "active", + LastLoginAt: &now, + } + + err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "phone"}}, + DoUpdates: clause.AssignmentColumns([]string{"last_login_at", "updated_at"}), + }).Create(&user).Error + if err != nil { + return nil, err + } + + var found model.User + if err := r.db.Where("phone = ?", phone).First(&found).Error; err != nil { + return nil, err + } + return &found, nil +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go new file mode 100644 index 0000000..32fad74 --- /dev/null +++ b/backend/internal/modules/auth/service.go @@ -0,0 +1,144 @@ +package auth + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "regexp" + "time" + + "hfb_sys/backend/internal/model" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidPhone = errors.New("invalid phone") + ErrCodeRateLimited = errors.New("sms code rate limited") + ErrCodeInvalid = errors.New("sms code invalid") + ErrUserDisabled = errors.New("user disabled") +) + +type Service struct { + users *UserRepository + redis *redis.Client + jwt *JWTManager + log *zap.Logger +} + +type LoginResult struct { + User *model.User `json:"user"` + Tokens TokenPair `json:"tokens"` +} + +func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, log *zap.Logger) *Service { + return &Service{users: users, redis: redis, jwt: jwt, log: log} +} + +func (s *Service) SendSMSCode(ctx context.Context, phone string) error { + if s.redis == nil { + return ErrDependencyUnavailable + } + if !isPhone(phone) { + return ErrInvalidPhone + } + + cooldownKey := "sms:cooldown:login:" + phone + exists, err := s.redis.Exists(ctx, cooldownKey).Result() + if err != nil { + return err + } + if exists > 0 { + return ErrCodeRateLimited + } + + code, err := randomDigits(6) + if err != nil { + return err + } + + pipe := s.redis.TxPipeline() + pipe.Set(ctx, codeKey(phone), code, 5*time.Minute) + pipe.Set(ctx, cooldownKey, "1", 60*time.Second) + pipe.Incr(ctx, "sms:daily:login:"+phone) + pipe.Expire(ctx, "sms:daily:login:"+phone, 24*time.Hour) + if _, err := pipe.Exec(ctx); err != nil { + return err + } + + // Development SMS adapter: keep the real provider boundary, but log only in local development. + s.log.Info("mock sms code generated", zap.String("phone", phone), zap.String("code", code)) + return nil +} + +func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (LoginResult, error) { + if s.redis == nil || s.users == nil { + return LoginResult{}, ErrDependencyUnavailable + } + if !isPhone(phone) { + return LoginResult{}, ErrInvalidPhone + } + + stored, err := s.redis.Get(ctx, codeKey(phone)).Result() + if errors.Is(err, redis.Nil) || stored != code { + return LoginResult{}, ErrCodeInvalid + } + if err != nil { + return LoginResult{}, err + } + + user, err := s.users.FindOrCreateByPhone(phone) + if err != nil { + return LoginResult{}, err + } + if user.Status != "active" { + return LoginResult{}, ErrUserDisabled + } + + tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + if err != nil { + return LoginResult{}, err + } + _ = s.redis.Del(ctx, codeKey(phone)).Err() + + return LoginResult{User: user, Tokens: tokens}, nil +} + +func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) { + claims, err := s.jwt.Parse(refreshToken, tokenTypeRefresh) + if err != nil { + return TokenPair{}, err + } + return s.jwt.GeneratePair(claims.UserID, claims.Phone) +} + +func codeKey(phone string) string { + return "sms:code:login:" + phone +} + +func isPhone(phone string) bool { + return regexp.MustCompile(`^1[3-9]\d{9}$`).MatchString(phone) +} + +func randomDigits(length int) (string, error) { + result := make([]byte, length) + for i := range result { + n, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return "", err + } + result[i] = byte('0' + n.Int64()) + } + return string(result), nil +} + +func PublicPhone(phone string) string { + if len(phone) < 7 { + return phone + } + return fmt.Sprintf("%s****%s", phone[:3], phone[len(phone)-4:]) +} diff --git a/backend/internal/modules/user/handler.go b/backend/internal/modules/user/handler.go new file mode 100644 index 0000000..181febd --- /dev/null +++ b/backend/internal/modules/user/handler.go @@ -0,0 +1,42 @@ +package user + +import ( + "errors" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Handler struct { + users *auth.UserRepository +} + +func NewHandler(users *auth.UserRepository) *Handler { + return &Handler{users: users} +} + +func (h *Handler) Me(c *gin.Context) { + if h.users == nil { + response.ServiceUnavailable(c, "数据库未连接") + return + } + userID, ok := c.Get(middleware.ContextUserID) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + user, err := h.users.FindByID(userID.(uint64)) + if errors.Is(err, gorm.ErrRecordNotFound) { + response.Unauthorized(c, "用户不存在") + return + } + if err != nil { + response.ServiceUnavailable(c, "用户查询失败") + return + } + response.OK(c, user) +} diff --git a/backend/internal/router/dependencies.go b/backend/internal/router/dependencies.go new file mode 100644 index 0000000..c8c89fb --- /dev/null +++ b/backend/internal/router/dependencies.go @@ -0,0 +1,11 @@ +package router + +import ( + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type Dependencies struct { + DB *gorm.DB + Redis *redis.Client +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a8ade92..b3b760b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -4,12 +4,14 @@ import ( "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/handler" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/internal/modules/user" "github.com/gin-gonic/gin" "go.uber.org/zap" ) -func New(cfg config.Config, logger *zap.Logger) *gin.Engine { +func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } @@ -21,9 +23,29 @@ func New(cfg config.Config, logger *zap.Logger) *gin.Engine { health := handler.NewHealthHandler() engine.GET("/health", health.Check) + jwtManager := auth.NewJWTManager(cfg.JWTSecret) + var userRepo *auth.UserRepository + if deps.DB != nil { + userRepo = auth.NewUserRepository(deps.DB) + } + authService := auth.NewService(userRepo, deps.Redis, jwtManager, logger) + authHandler := auth.NewHandler(authService) + userHandler := user.NewHandler(userRepo) + requireAuth := middleware.Auth(jwtManager) + api := engine.Group("/api") { api.GET("/health", health.Check) + + authRoutes := api.Group("/auth") + { + authRoutes.POST("/sms/send", authHandler.SendSMS) + authRoutes.POST("/sms/login", authHandler.Login) + authRoutes.POST("/refresh", authHandler.Refresh) + authRoutes.POST("/logout", authHandler.Logout) + } + + api.GET("/me", requireAuth, userHandler.Me) } return engine diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go new file mode 100644 index 0000000..3292c2e --- /dev/null +++ b/backend/pkg/response/response.go @@ -0,0 +1,48 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Body struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func OK(c *gin.Context, data any) { + c.JSON(http.StatusOK, Body{ + Code: "ok", + Message: "ok", + Data: data, + }) +} + +func Created(c *gin.Context, data any) { + c.JSON(http.StatusCreated, Body{ + Code: "ok", + Message: "created", + Data: data, + }) +} + +func Error(c *gin.Context, status int, code, message string) { + c.JSON(status, Body{ + Code: code, + Message: message, + }) +} + +func BadRequest(c *gin.Context, message string) { + Error(c, http.StatusBadRequest, "bad_request", message) +} + +func Unauthorized(c *gin.Context, message string) { + Error(c, http.StatusUnauthorized, "unauthorized", message) +} + +func ServiceUnavailable(c *gin.Context, message string) { + Error(c, http.StatusServiceUnavailable, "service_unavailable", message) +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..30c7b27 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,46 @@ +import { apiClient } from './client' + +export interface AuthUser { + id: number + phone: string + nickname: string + realname_status: string + risk_status: string + credit_score: number + status: string +} + +export interface TokenPair { + access_token: string + refresh_token: string + token_type: string + expires_in: number +} + +export interface LoginData { + user: AuthUser + tokens: TokenPair +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function sendSmsCode(phone: string) { + const { data } = await apiClient.post>('/auth/sms/send', { + phone, + }) + return data.data +} + +export async function loginWithSms(phone: string, code: string) { + const { data } = await apiClient.post>('/auth/sms/login', { phone, code }) + return data.data +} + +export async function fetchMe() { + const { data } = await apiClient.get>('/me') + return data.data +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e1150a6..c753c5b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,3 +4,11 @@ export const apiClient = axios.create({ baseURL: '/api', timeout: 10000, }) + +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index ea47b5e..d38aa42 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 567ac8e..2838f36 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -4,6 +4,7 @@ const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'home', component: () => import('@/views/public/HomeView.vue') }, + { path: '/login', name: 'login', component: () => import('@/views/account/LoginView.vue') }, { path: '/listings', name: 'listings', component: () => import('@/views/public/ListingsView.vue') }, { path: '/listings/:id', name: 'listing-detail', component: () => import('@/views/public/ListingDetailView.vue') }, { path: '/orders/create', name: 'order-create', component: () => import('@/views/account/OrderCreateView.vue') }, diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index 1cd78e2..fca6291 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -1,9 +1,43 @@ import { defineStore } from 'pinia' +import { fetchMe, loginWithSms, type AuthUser } from '@/api/auth' + export const useSessionStore = defineStore('session', { state: () => ({ - token: '', + token: localStorage.getItem('access_token') || '', + refreshToken: localStorage.getItem('refresh_token') || '', phone: '', realnameStatus: 'unknown', }), + actions: { + async login(phone: string, code: string) { + const result = await loginWithSms(phone, code) + this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token) + return result + }, + async loadMe() { + const user = await fetchMe() + this.applyUser(user) + return user + }, + logout() { + this.token = '' + this.refreshToken = '' + this.phone = '' + this.realnameStatus = 'unknown' + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + }, + applySession(user: AuthUser, accessToken: string, refreshToken: string) { + this.token = accessToken + this.refreshToken = refreshToken + localStorage.setItem('access_token', accessToken) + localStorage.setItem('refresh_token', refreshToken) + this.applyUser(user) + }, + applyUser(user: AuthUser) { + this.phone = user.phone + this.realnameStatus = user.realname_status + }, + }, }) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index e285966..edb4a60 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -135,6 +135,22 @@ h1 { font-size: 20px; } +.login-form { + max-width: 420px; + margin-top: 28px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.code-row { + display: grid; + grid-template-columns: 1fr 96px; + gap: 10px; + width: 100%; +} + @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; diff --git a/frontend/src/views/account/LoginView.vue b/frontend/src/views/account/LoginView.vue new file mode 100644 index 0000000..7f0de0b --- /dev/null +++ b/frontend/src/views/account/LoginView.vue @@ -0,0 +1,70 @@ + + +