From d975adaf2a89d268160c857fc639ed183830ac1b Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Sat, 28 Feb 2026 16:42:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=8C=E6=95=B4=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E7=B3=BB=E7=BB=9F=20=E2=80=94=20=E6=B3=A8=E5=86=8C/?= =?UTF-8?q?=E7=99=BB=E5=BD=95/JWT/=E8=B7=AF=E7=94=B1=EF=BC=88Task=204=20+?= =?UTF-8?q?=20Task=205=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 — 认证服务层: - pkg/utils/password.go: bcrypt 密码加密与校验 - pkg/utils/jwt.go: Access/Refresh Token 生成与解析(jwt/v5@v5.2.1,兼容 Go 1.23) - app/dto/auth_dto.go: 认证相关请求/响应 DTO(含参数校验 binding tag) - app/auth/service/auth_service.go: 核心业务逻辑(注册、登录、管理员登录、Token 刷新、个人信息、改密码) - pkg/middleware/auth.go: JWT 认证中间件 + 角色权限检查中间件 Task 5 — Controller 与路由注册: - app/auth/controller/auth_controller.go: 前台认证接口(7 个 API) - app/auth/controller/admin_auth_controller.go: 后台管理认证接口 - app/auth/router.go: auth 模块路由定义(模块自包含) - router/router.go: 主路由汇总入口(仅做调度,不含具体路由定义) - cmd/server/main.go: 路由注册迁移到 router.Setup() 已验证全部接口:注册 ✓ 登录 ✓ 获取 profile ✓ 错误密码 ✓ 未认证拒绝 ✓ 非管理员拒绝 ✓ Made-with: Cursor --- .../auth/controller/admin_auth_controller.go | 48 +++ .../app/auth/controller/auth_controller.go | 217 ++++++++++ backend/go-service/app/auth/model/role.go | 18 +- backend/go-service/app/auth/model/user.go | 26 +- backend/go-service/app/auth/provider.go | 6 + backend/go-service/app/auth/router.go | 40 ++ .../app/auth/service/auth_service.go | 390 ++++++++++++++++++ backend/go-service/app/dto/auth_dto.go | 54 +++ backend/go-service/app/provider/provider.go | 37 +- backend/go-service/app/provider/wire.go | 2 + backend/go-service/app/provider/wire_gen.go | 11 +- backend/go-service/cmd/server/main.go | 13 +- backend/go-service/go.mod | 3 +- backend/go-service/go.sum | 2 + backend/go-service/pkg/middleware/auth.go | 129 ++++++ backend/go-service/pkg/utils/jwt.go | 86 ++++ backend/go-service/pkg/utils/password.go | 17 + backend/go-service/router/router.go | 39 ++ 18 files changed, 1097 insertions(+), 41 deletions(-) create mode 100644 backend/go-service/app/auth/controller/admin_auth_controller.go create mode 100644 backend/go-service/app/auth/controller/auth_controller.go create mode 100644 backend/go-service/app/auth/router.go create mode 100644 backend/go-service/app/auth/service/auth_service.go create mode 100644 backend/go-service/app/dto/auth_dto.go create mode 100644 backend/go-service/pkg/middleware/auth.go create mode 100644 backend/go-service/pkg/utils/jwt.go create mode 100644 backend/go-service/pkg/utils/password.go create mode 100644 backend/go-service/router/router.go diff --git a/backend/go-service/app/auth/controller/admin_auth_controller.go b/backend/go-service/app/auth/controller/admin_auth_controller.go new file mode 100644 index 0000000..a1c8a64 --- /dev/null +++ b/backend/go-service/app/auth/controller/admin_auth_controller.go @@ -0,0 +1,48 @@ +package controller + +import ( + "github.com/echochat/backend/app/auth/service" + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AdminAuthController 后台管理认证控制器 +// 与前台 AuthController 的区别:登录后额外检查管理员角色权限 +type AdminAuthController struct { + authService *service.AuthService +} + +// NewAdminAuthController 创建后台认证控制器实例 +func NewAdminAuthController(authService *service.AuthService) *AdminAuthController { + return &AdminAuthController{authService: authService} +} + +// AdminLogin 管理员登录 +// POST /api/v1/admin/auth/login +func (ctrl *AdminAuthController) AdminLogin(c *gin.Context) { + funcName := "controller.admin_auth_controller.AdminLogin" + ctx := c.Request.Context() + + var req dto.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err)) + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + logs.Info(ctx, funcName, "管理员登录请求", + zap.String("account", req.Account), + zap.String("ip", c.ClientIP()), + ) + + resp, err := ctrl.authService.AdminLogin(ctx, &req, c.ClientIP()) + if err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseOK(c, resp) +} diff --git a/backend/go-service/app/auth/controller/auth_controller.go b/backend/go-service/app/auth/controller/auth_controller.go new file mode 100644 index 0000000..45ef011 --- /dev/null +++ b/backend/go-service/app/auth/controller/auth_controller.go @@ -0,0 +1,217 @@ +// Package controller 提供 auth 模块的 HTTP 接口处理 +package controller + +import ( + "github.com/echochat/backend/app/auth/service" + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AuthController 前台认证控制器 +// 处理用户注册、登录、Token 刷新、个人信息管理等接口 +type AuthController struct { + authService *service.AuthService +} + +// NewAuthController 创建前台认证控制器实例 +func NewAuthController(authService *service.AuthService) *AuthController { + return &AuthController{authService: authService} +} + +// Register 用户注册 +// POST /api/v1/auth/register +func (ctrl *AuthController) Register(c *gin.Context) { + funcName := "controller.auth_controller.Register" + ctx := c.Request.Context() + + var req dto.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err)) + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + logs.Info(ctx, funcName, "注册请求", + zap.String("username", req.Username), + zap.String("email", logs.MaskEmail(req.Email)), + ) + + resp, err := ctrl.authService.Register(ctx, &req) + if err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseCreated(c, resp) +} + +// Login 用户登录 +// POST /api/v1/auth/login +func (ctrl *AuthController) Login(c *gin.Context) { + funcName := "controller.auth_controller.Login" + ctx := c.Request.Context() + + var req dto.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err)) + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + logs.Info(ctx, funcName, "登录请求", + zap.String("account", req.Account), + zap.String("ip", c.ClientIP()), + ) + + resp, err := ctrl.authService.Login(ctx, &req, c.ClientIP()) + if err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseOK(c, resp) +} + +// Logout 用户登出 +// POST /api/v1/auth/logout(需认证) +func (ctrl *AuthController) Logout(c *gin.Context) { + funcName := "controller.auth_controller.Logout" + ctx := c.Request.Context() + + userID, _ := middleware.GetCurrentUserID(c) + logs.Info(ctx, funcName, "用户登出", zap.Int64("user_id", userID)) + + // 当前为无状态 JWT,登出由客户端删除 Token 实现 + // 后续可扩展:将 Token 加入 Redis 黑名单 + utils.ResponseOK(c, nil) +} + +// RefreshToken 刷新 Access Token +// POST /api/v1/auth/refresh-token +func (ctrl *AuthController) RefreshToken(c *gin.Context) { + funcName := "controller.auth_controller.RefreshToken" + ctx := c.Request.Context() + + var req dto.RefreshTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err)) + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + resp, err := ctrl.authService.RefreshToken(ctx, req.RefreshToken) + if err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseOK(c, resp) +} + +// GetProfile 获取当前用户信息 +// GET /api/v1/auth/profile(需认证) +func (ctrl *AuthController) GetProfile(c *gin.Context) { + funcName := "controller.auth_controller.GetProfile" + ctx := c.Request.Context() + + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取用户信息") + return + } + + logs.Debug(ctx, funcName, "获取个人信息", zap.Int64("user_id", userID)) + + userInfo, err := ctrl.authService.GetProfile(ctx, userID) + if err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseOK(c, userInfo) +} + +// UpdateProfile 更新个人资料 +// PUT /api/v1/auth/profile(需认证) +func (ctrl *AuthController) UpdateProfile(c *gin.Context) { + funcName := "controller.auth_controller.UpdateProfile" + ctx := c.Request.Context() + + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取用户信息") + return + } + + var req dto.UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err)) + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + logs.Info(ctx, funcName, "更新个人资料", zap.Int64("user_id", userID)) + + userInfo, err := ctrl.authService.UpdateProfile(ctx, userID, &req) + if err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseOK(c, userInfo) +} + +// ChangePassword 修改密码 +// PUT /api/v1/auth/password(需认证) +func (ctrl *AuthController) ChangePassword(c *gin.Context) { + funcName := "controller.auth_controller.ChangePassword" + ctx := c.Request.Context() + + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取用户信息") + return + } + + var req dto.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err)) + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + logs.Info(ctx, funcName, "修改密码", zap.Int64("user_id", userID)) + + if err := ctrl.authService.ChangePassword(ctx, userID, &req); err != nil { + handleAuthError(c, err) + return + } + + utils.ResponseOK(c, nil) +} + +// handleAuthError 统一处理认证相关业务错误,映射到对应的 HTTP 状态码 +func handleAuthError(c *gin.Context, err error) { + switch err { + case service.ErrUserAlreadyExists: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrUserNotFound: + utils.ResponseNotFound(c, err.Error()) + case service.ErrPasswordWrong: + utils.ResponseUnauthorized(c, "账号或密码错误") + case service.ErrUserDisabled: + utils.ResponseForbidden(c, err.Error()) + case service.ErrUserDeleted: + utils.ResponseForbidden(c, err.Error()) + case service.ErrNotAdmin: + utils.ResponseForbidden(c, err.Error()) + case service.ErrRefreshTokenType: + utils.ResponseBadRequest(c, err.Error()) + default: + utils.ResponseError(c, "服务器内部错误") + } +} diff --git a/backend/go-service/app/auth/model/role.go b/backend/go-service/app/auth/model/role.go index 8bbf132..63c8a5e 100644 --- a/backend/go-service/app/auth/model/role.go +++ b/backend/go-service/app/auth/model/role.go @@ -3,12 +3,13 @@ package model import "time" // Role 角色表模型,对应 auth_roles 表 +// 系统预置三种角色:user(普通用户)、admin(管理员)、super_admin(超级管理员) type Role struct { - ID int `json:"id" gorm:"primaryKey;autoIncrement"` - Code string `json:"code" gorm:"uniqueIndex;size:50;not null"` // 角色代码: user/admin/super_admin - Name string `json:"name" gorm:"size:50;not null"` // 角色显示名称 - Description string `json:"description" gorm:"size:200;default:''"` - CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` + ID int `json:"id" gorm:"primaryKey;autoIncrement"` // 角色唯一标识,自增主键 + Code string `json:"code" gorm:"uniqueIndex;size:50;not null"` // 角色代码,全局唯一,如 user/admin/super_admin(对应 constants.RoleCode*) + Name string `json:"name" gorm:"size:50;not null"` // 角色中文显示名称,如「普通用户」「管理员」 + Description string `json:"description" gorm:"size:200;default:''"` // 角色描述说明,用于后台管理界面展示 + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` // 创建时间,由 GORM 自动填充 } // TableName 指定数据库表名 @@ -17,10 +18,11 @@ func (Role) TableName() string { } // UserRole 用户角色关联表模型,对应 auth_user_roles 表 +// 采用联合主键(user_id + role_id),一个用户可拥有多个角色 type UserRole struct { - UserID int64 `json:"user_id" gorm:"primaryKey"` - RoleID int `json:"role_id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` + UserID int64 `json:"user_id" gorm:"primaryKey"` // 关联的用户 ID,外键指向 auth_users.id + RoleID int `json:"role_id" gorm:"primaryKey"` // 关联的角色 ID,外键指向 auth_roles.id + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` // 角色分配时间,由 GORM 自动填充 } // TableName 指定数据库表名 diff --git a/backend/go-service/app/auth/model/user.go b/backend/go-service/app/auth/model/user.go index 6e18af2..b309596 100644 --- a/backend/go-service/app/auth/model/user.go +++ b/backend/go-service/app/auth/model/user.go @@ -5,19 +5,19 @@ import "time" // User 用户主表模型,对应 auth_users 表 type User struct { - ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` - Username string `json:"username" gorm:"uniqueIndex;size:50;not null"` - Email string `json:"email" gorm:"uniqueIndex;size:100;not null"` - PasswordHash string `json:"-" gorm:"column:password_hash;size:255;not null"` // JSON 序列化时隐藏密码 - Nickname string `json:"nickname" gorm:"size:50;not null;default:''"` - Avatar string `json:"avatar" gorm:"size:500;not null;default:''"` - Gender int `json:"gender" gorm:"not null;default:0"` // 0=未知, 1=男, 2=女 - Phone *string `json:"phone" gorm:"size:20"` // 可选字段 - Status int `json:"status" gorm:"not null;default:1"` // 1=正常, 2=禁用, 3=注销 - LastLoginAt *time.Time `json:"last_login_at"` - LastLoginIP *string `json:"last_login_ip" gorm:"column:last_login_ip;size:50"` - CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime"` + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 用户唯一标识,自增主键 + Username string `json:"username" gorm:"uniqueIndex;size:50;not null"` // 登录用户名,全局唯一 + Email string `json:"email" gorm:"uniqueIndex;size:100;not null"` // 邮箱地址,全局唯一,用于登录和找回密码 + PasswordHash string `json:"-" gorm:"column:password_hash;size:255;not null"` // bcrypt 加密后的密码哈希,JSON 序列化时隐藏 + Nickname string `json:"nickname" gorm:"size:50;not null;default:''"` // 用户昵称,用于页面展示,允许重复 + Avatar string `json:"avatar" gorm:"size:500;not null;default:''"` // 头像 URL 地址,为空则使用默认头像 + Gender int `json:"gender" gorm:"not null;default:0"` // 性别:0=未知, 1=男, 2=女(对应 constants.Gender*) + Phone *string `json:"phone" gorm:"size:20"` // 手机号,可选字段,指针类型允许 NULL + Status int `json:"status" gorm:"not null;default:1"` // 账号状态:1=正常, 2=禁用, 3=注销(对应 constants.UserStatus*) + LastLoginAt *time.Time `json:"last_login_at"` // 最后登录时间,首次注册时为 NULL + LastLoginIP *string `json:"last_login_ip" gorm:"column:last_login_ip;size:50"` // 最后登录 IP 地址,用于安全审计 + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` // 账号创建时间,由 GORM 自动填充 + UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime"` // 最后更新时间,由 GORM 自动更新 } // TableName 指定数据库表名 diff --git a/backend/go-service/app/auth/provider.go b/backend/go-service/app/auth/provider.go index df8a90c..7e9e711 100644 --- a/backend/go-service/app/auth/provider.go +++ b/backend/go-service/app/auth/provider.go @@ -2,12 +2,18 @@ package auth import ( + "github.com/echochat/backend/app/auth/controller" "github.com/echochat/backend/app/auth/dao" + "github.com/echochat/backend/app/auth/service" "github.com/google/wire" ) // AuthSet Auth 模块依赖注入 Provider Set +// 提供 DAO 层、Service 层和 Controller 层的所有组件 var AuthSet = wire.NewSet( dao.NewUserDAO, dao.NewRoleDAO, + service.NewAuthService, + controller.NewAuthController, + controller.NewAdminAuthController, ) diff --git a/backend/go-service/app/auth/router.go b/backend/go-service/app/auth/router.go new file mode 100644 index 0000000..c8209f1 --- /dev/null +++ b/backend/go-service/app/auth/router.go @@ -0,0 +1,40 @@ +package auth + +import ( + "github.com/echochat/backend/app/auth/controller" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册 auth 模块的所有路由 +// 包含前台公开路由、前台需认证路由、后台管理认证路由 +// authMiddleware 为 JWT 认证中间件,由外部传入以解耦 +func RegisterRoutes( + r *gin.Engine, + ctrl *controller.AuthController, + adminCtrl *controller.AdminAuthController, + authMiddleware gin.HandlerFunc, +) { + // 前台公开路由(无需认证) + public := r.Group("/api/v1/auth") + { + public.POST("/register", ctrl.Register) + public.POST("/login", ctrl.Login) + public.POST("/refresh-token", ctrl.RefreshToken) + } + + // 前台需认证路由 + authed := r.Group("/api/v1/auth") + authed.Use(authMiddleware) + { + authed.POST("/logout", ctrl.Logout) + authed.GET("/profile", ctrl.GetProfile) + authed.PUT("/profile", ctrl.UpdateProfile) + authed.PUT("/password", ctrl.ChangePassword) + } + + // 后台管理认证路由(无需认证,登录后在 Service 层检查管理员角色) + admin := r.Group("/api/v1/admin/auth") + { + admin.POST("/login", adminCtrl.AdminLogin) + } +} diff --git a/backend/go-service/app/auth/service/auth_service.go b/backend/go-service/app/auth/service/auth_service.go new file mode 100644 index 0000000..f5ea817 --- /dev/null +++ b/backend/go-service/app/auth/service/auth_service.go @@ -0,0 +1,390 @@ +// Package service 提供 auth 模块的核心业务逻辑 +package service + +import ( + "context" + "errors" + + "github.com/echochat/backend/app/auth/dao" + "github.com/echochat/backend/app/auth/model" + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/config" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/utils" + "go.uber.org/zap" + "gorm.io/gorm" +) + +var ( + ErrUserAlreadyExists = errors.New("用户名或邮箱已被注册") + ErrUserNotFound = errors.New("用户不存在") + ErrPasswordWrong = errors.New("密码错误") + ErrUserDisabled = errors.New("账号已被禁用") + ErrUserDeleted = errors.New("账号已注销") + ErrNotAdmin = errors.New("该账号无管理员权限") + ErrRefreshTokenType = errors.New("无效的 Refresh Token 类型") +) + +// AuthService 认证服务,处理注册、登录、Token 管理、个人信息等业务逻辑 +type AuthService struct { + userDAO *dao.UserDAO + roleDAO *dao.RoleDAO + jwtCfg *config.JWTConfig +} + +// NewAuthService 创建认证服务实例 +func NewAuthService(userDAO *dao.UserDAO, roleDAO *dao.RoleDAO, jwtCfg *config.JWTConfig) *AuthService { + return &AuthService{ + userDAO: userDAO, + roleDAO: roleDAO, + jwtCfg: jwtCfg, + } +} + +// Register 用户注册 +// 流程:检查用户名/邮箱是否重复 → 加密密码 → 创建用户 → 分配默认角色 → 生成 Token +func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) { + funcName := "service.auth_service.Register" + logs.Info(ctx, funcName, "开始处理注册", + zap.String("username", req.Username), + zap.String("email", logs.MaskEmail(req.Email)), + ) + + var err error + defer func() { + if err != nil { + logs.Error(ctx, funcName, "注册处理失败", + zap.String("username", req.Username), + zap.Error(err), + ) + } + }() + + // 检查用户名是否已存在 + existing, _ := s.userDAO.FindByUsername(ctx, req.Username) + if existing != nil { + err = ErrUserAlreadyExists + return nil, err + } + + // 检查邮箱是否已存在 + existing, _ = s.userDAO.FindByEmail(ctx, req.Email) + if existing != nil { + err = ErrUserAlreadyExists + return nil, err + } + + // 加密密码 + hashedPassword, hashErr := utils.HashPassword(req.Password) + if hashErr != nil { + err = hashErr + return nil, err + } + + // 创建用户 + nickname := req.Nickname + if nickname == "" { + nickname = req.Username + } + user := &model.User{ + Username: req.Username, + Email: req.Email, + PasswordHash: hashedPassword, + Nickname: nickname, + Status: constants.UserStatusActive, + } + if err = s.userDAO.Create(ctx, user); err != nil { + return nil, err + } + + // 分配默认角色(普通用户) + defaultRole, roleErr := s.roleDAO.FindByCode(ctx, constants.RoleUser) + if roleErr != nil { + logs.Warn(ctx, funcName, "查找默认角色失败,跳过角色分配", zap.Error(roleErr)) + } else { + if assignErr := s.roleDAO.AssignRole(ctx, user.ID, defaultRole.ID); assignErr != nil { + logs.Warn(ctx, funcName, "分配默认角色失败", zap.Error(assignErr)) + } + } + + // 获取角色列表并生成 Token + roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) + resp, err := s.buildLoginResponse(user, roles) + if err != nil { + return nil, err + } + + logs.Info(ctx, funcName, "注册成功", zap.Int64("user_id", user.ID)) + return resp, nil +} + +// Login 用户登录 +// 流程:查找用户 → 校验密码 → 检查账号状态 → 更新登录信息 → 生成 Token +func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) { + funcName := "service.auth_service.Login" + logs.Info(ctx, funcName, "开始处理登录", + zap.String("account", req.Account), + ) + + var err error + defer func() { + if err != nil { + logs.Error(ctx, funcName, "登录处理失败", + zap.String("account", req.Account), + zap.Error(err), + ) + } + }() + + // 按用户名或邮箱查找用户 + user, findErr := s.userDAO.FindByAccount(ctx, req.Account) + if findErr != nil { + if errors.Is(findErr, gorm.ErrRecordNotFound) { + err = ErrUserNotFound + } else { + err = findErr + } + return nil, err + } + + // 校验密码 + if !utils.CheckPassword(req.Password, user.PasswordHash) { + err = ErrPasswordWrong + return nil, err + } + + // 检查账号状态 + if err = s.checkUserStatus(user.Status); err != nil { + return nil, err + } + + // 更新最后登录信息 + _ = s.userDAO.UpdateLastLogin(ctx, user.ID, clientIP) + + // 获取角色列表并生成 Token + roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) + resp, err := s.buildLoginResponse(user, roles) + if err != nil { + return nil, err + } + + logs.Info(ctx, funcName, "登录成功", + zap.Int64("user_id", user.ID), + zap.String("username", user.Username), + ) + return resp, nil +} + +// AdminLogin 管理后台登录 +// 与普通登录相同,但额外检查用户是否拥有 admin 或 super_admin 角色 +func (s *AuthService) AdminLogin(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) { + funcName := "service.auth_service.AdminLogin" + logs.Info(ctx, funcName, "开始处理管理员登录", + zap.String("account", req.Account), + ) + + resp, err := s.Login(ctx, req, clientIP) + if err != nil { + return nil, err + } + + // 检查是否拥有管理员角色 + hasAdmin := false + for _, role := range resp.User.Roles { + if role == constants.RoleAdmin || role == constants.RoleSuperAdmin { + hasAdmin = true + break + } + } + if !hasAdmin { + logs.Warn(ctx, funcName, "非管理员尝试后台登录", + zap.String("account", req.Account), + ) + return nil, ErrNotAdmin + } + + logs.Info(ctx, funcName, "管理员登录成功", + zap.String("account", req.Account), + ) + return resp, nil +} + +// RefreshToken 刷新 Access Token +// 验证 Refresh Token 有效性后,重新生成一对新的 Token +func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*dto.LoginResponse, error) { + funcName := "service.auth_service.RefreshToken" + + // 解析 Refresh Token + claims, err := utils.ParseToken(s.jwtCfg, refreshToken) + if err != nil { + logs.Warn(ctx, funcName, "Refresh Token 解析失败", zap.Error(err)) + return nil, err + } + + // 验证 Token 类型 + if claims.Subject != "refresh" { + return nil, ErrRefreshTokenType + } + + // 查找用户(确保用户仍然有效) + user, err := s.userDAO.FindByID(ctx, claims.UserID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + if err = s.checkUserStatus(user.Status); err != nil { + return nil, err + } + + // 获取角色并生成新 Token + roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) + resp, err := s.buildLoginResponse(user, roles) + if err != nil { + return nil, err + } + + logs.Info(ctx, funcName, "Token 刷新成功", zap.Int64("user_id", user.ID)) + return resp, nil +} + +// GetProfile 获取用户个人信息 +func (s *AuthService) GetProfile(ctx context.Context, userID int64) (*dto.UserInfo, error) { + funcName := "service.auth_service.GetProfile" + logs.Debug(ctx, funcName, "获取用户信息", zap.Int64("user_id", userID)) + + user, err := s.userDAO.FindByID(ctx, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) + + return s.buildUserInfo(user, roles), nil +} + +// UpdateProfile 更新用户个人资料 +func (s *AuthService) UpdateProfile(ctx context.Context, userID int64, req *dto.UpdateProfileRequest) (*dto.UserInfo, error) { + funcName := "service.auth_service.UpdateProfile" + logs.Info(ctx, funcName, "更新用户资料", zap.Int64("user_id", userID)) + + user, err := s.userDAO.FindByID(ctx, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + if req.Nickname != "" { + user.Nickname = req.Nickname + } + if req.Avatar != "" { + user.Avatar = req.Avatar + } + if req.Gender != nil { + user.Gender = *req.Gender + } + if req.Phone != "" { + user.Phone = &req.Phone + } + + if err = s.userDAO.Update(ctx, user); err != nil { + return nil, err + } + + roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID) + logs.Info(ctx, funcName, "用户资料更新成功", zap.Int64("user_id", userID)) + return s.buildUserInfo(user, roles), nil +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(ctx context.Context, userID int64, req *dto.ChangePasswordRequest) error { + funcName := "service.auth_service.ChangePassword" + logs.Info(ctx, funcName, "修改密码", zap.Int64("user_id", userID)) + + user, err := s.userDAO.FindByID(ctx, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrUserNotFound + } + return err + } + + // 校验旧密码 + if !utils.CheckPassword(req.OldPassword, user.PasswordHash) { + return ErrPasswordWrong + } + + // 加密新密码 + hashedPassword, err := utils.HashPassword(req.NewPassword) + if err != nil { + return err + } + + user.PasswordHash = hashedPassword + if err = s.userDAO.Update(ctx, user); err != nil { + return err + } + + logs.Info(ctx, funcName, "密码修改成功", zap.Int64("user_id", userID)) + return nil +} + +// checkUserStatus 检查用户账号状态 +func (s *AuthService) checkUserStatus(status int) error { + switch status { + case constants.UserStatusDisabled: + return ErrUserDisabled + case constants.UserStatusDeleted: + return ErrUserDeleted + default: + return nil + } +} + +// buildLoginResponse 构建登录响应(生成 Token + 用户信息) +func (s *AuthService) buildLoginResponse(user *model.User, roles []string) (*dto.LoginResponse, error) { + if roles == nil { + roles = []string{} + } + + token, err := utils.GenerateToken(s.jwtCfg, user.ID, user.Username, roles) + if err != nil { + return nil, err + } + + refreshToken, err := utils.GenerateRefreshToken(s.jwtCfg, user.ID) + if err != nil { + return nil, err + } + + return &dto.LoginResponse{ + Token: token, + RefreshToken: refreshToken, + ExpiresIn: int64(s.jwtCfg.AccessExpireMin) * 60, + User: *s.buildUserInfo(user, roles), + }, nil +} + +// buildUserInfo 从 model.User 构建 dto.UserInfo +func (s *AuthService) buildUserInfo(user *model.User, roles []string) *dto.UserInfo { + if roles == nil { + roles = []string{} + } + return &dto.UserInfo{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Nickname: user.Nickname, + Avatar: user.Avatar, + Gender: user.Gender, + Roles: roles, + } +} diff --git a/backend/go-service/app/dto/auth_dto.go b/backend/go-service/app/dto/auth_dto.go new file mode 100644 index 0000000..8f39bf9 --- /dev/null +++ b/backend/go-service/app/dto/auth_dto.go @@ -0,0 +1,54 @@ +// Package dto 定义数据传输对象,用于 Controller 层的请求/响应参数绑定 +package dto + +// RegisterRequest 用户注册请求参数 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` // 用户名,3-50 字符 + Email string `json:"email" binding:"required,email"` // 邮箱地址,必须符合邮箱格式 + Password string `json:"password" binding:"required,min=6,max=50"` // 密码,6-50 字符 + Nickname string `json:"nickname" binding:"max=50"` // 昵称,可选,最多 50 字符 +} + +// LoginRequest 用户登录请求参数 +type LoginRequest struct { + Account string `json:"account" binding:"required"` // 登录账号,支持用户名或邮箱 + Password string `json:"password" binding:"required"` // 登录密码 +} + +// LoginResponse 登录成功响应数据 +type LoginResponse struct { + Token string `json:"token"` // Access Token,用于接口认证 + RefreshToken string `json:"refresh_token"` // Refresh Token,用于刷新 Access Token + ExpiresIn int64 `json:"expires_in"` // Access Token 有效期(秒) + User UserInfo `json:"user"` // 当前登录用户基本信息 +} + +// UserInfo 用户基本信息(用于响应返回,不含敏感字段) +type UserInfo struct { + ID int64 `json:"id"` // 用户 ID + Username string `json:"username"` // 用户名 + Email string `json:"email"` // 邮箱 + Nickname string `json:"nickname"` // 昵称 + Avatar string `json:"avatar"` // 头像 URL + Gender int `json:"gender"` // 性别:0=未知, 1=男, 2=女 + Roles []string `json:"roles"` // 角色代码列表 +} + +// RefreshTokenRequest 刷新 Token 请求参数 +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` // 原 Refresh Token +} + +// UpdateProfileRequest 更新个人资料请求参数 +type UpdateProfileRequest struct { + Nickname string `json:"nickname" binding:"max=50"` // 新昵称 + Avatar string `json:"avatar" binding:"max=500"` // 新头像 URL + Gender *int `json:"gender" binding:"omitempty,oneof=0 1 2"` // 新性别,指针类型区分「未传」和「传 0」 + Phone string `json:"phone" binding:"max=20"` // 新手机号 +} + +// ChangePasswordRequest 修改密码请求参数 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` // 旧密码 + NewPassword string `json:"new_password" binding:"required,min=6,max=50"` // 新密码,6-50 字符 +} diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go index 53fcefc..4116689 100644 --- a/backend/go-service/app/provider/provider.go +++ b/backend/go-service/app/provider/provider.go @@ -3,6 +3,8 @@ package provider import ( + "github.com/echochat/backend/app/auth/controller" + "github.com/echochat/backend/app/auth/service" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" "github.com/google/wire" @@ -10,19 +12,32 @@ import ( "gorm.io/gorm" ) -// App 应用根容器,持有所有基础设施组件 +// App 应用根容器,持有基础设施组件和各模块的 Controller/Service type App struct { - Config *config.Config - DB *gorm.DB - Redis *redis.Client + Config *config.Config + DB *gorm.DB + Redis *redis.Client + AuthService *service.AuthService // Auth 认证服务 + AuthController *controller.AuthController // 前台认证控制器 + AdminAuthController *controller.AdminAuthController // 后台认证控制器 } // NewApp 创建应用实例 -func NewApp(cfg *config.Config, gormDB *gorm.DB, redisClient *redis.Client) *App { +func NewApp( + cfg *config.Config, + gormDB *gorm.DB, + redisClient *redis.Client, + authService *service.AuthService, + authCtrl *controller.AuthController, + adminAuthCtrl *controller.AdminAuthController, +) *App { return &App{ - Config: cfg, - DB: gormDB, - Redis: redisClient, + Config: cfg, + DB: gormDB, + Redis: redisClient, + AuthService: authService, + AuthController: authCtrl, + AdminAuthController: adminAuthCtrl, } } @@ -36,10 +51,16 @@ func provideRedisConfig(cfg *config.Config) *config.RedisConfig { return &cfg.Redis } +// provideJWTConfig 从全局 Config 中提取 JWTConfig +func provideJWTConfig(cfg *config.Config) *config.JWTConfig { + return &cfg.JWT +} + // InfraSet 基础设施层 Provider Set var InfraSet = wire.NewSet( provideDBConfig, provideRedisConfig, + provideJWTConfig, db.NewPostgres, db.NewRedis, NewApp, diff --git a/backend/go-service/app/provider/wire.go b/backend/go-service/app/provider/wire.go index 55175aa..055ec6a 100644 --- a/backend/go-service/app/provider/wire.go +++ b/backend/go-service/app/provider/wire.go @@ -4,6 +4,7 @@ package provider import ( + "github.com/echochat/backend/app/auth" "github.com/echochat/backend/config" "github.com/google/wire" ) @@ -12,6 +13,7 @@ import ( func InitializeApp(cfg *config.Config) (*App, error) { wire.Build( InfraSet, + auth.AuthSet, ) return nil, nil } diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index e46c0f1..d60bf45 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -7,6 +7,9 @@ package provider import ( + "github.com/echochat/backend/app/auth/controller" + "github.com/echochat/backend/app/auth/dao" + "github.com/echochat/backend/app/auth/service" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" ) @@ -25,6 +28,12 @@ func InitializeApp(cfg *config.Config) (*App, error) { if err != nil { return nil, err } - app := NewApp(cfg, gormDB, client) + userDAO := dao.NewUserDAO(gormDB) + roleDAO := dao.NewRoleDAO(gormDB) + jwtConfig := provideJWTConfig(cfg) + authService := service.NewAuthService(userDAO, roleDAO, jwtConfig) + authController := controller.NewAuthController(authService) + adminAuthController := controller.NewAdminAuthController(authService) + app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController) return app, nil } diff --git a/backend/go-service/cmd/server/main.go b/backend/go-service/cmd/server/main.go index ec990ed..babca53 100644 --- a/backend/go-service/cmd/server/main.go +++ b/backend/go-service/cmd/server/main.go @@ -13,8 +13,8 @@ import ( "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/middleware" - "github.com/echochat/backend/pkg/utils" "github.com/echochat/backend/app/provider" + "github.com/echochat/backend/router" "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -45,7 +45,6 @@ func main() { if err != nil { logs.Fatal(ctx, "main", "初始化应用失败", zap.Error(err)) } - _ = app // 4. 创建 Gin Engine if cfg.Server.Mode == "release" { @@ -61,14 +60,8 @@ func main() { middleware.Recovery(), ) - // 6. 注册路由 - engine.GET("/health", func(c *gin.Context) { - utils.ResponseOK(c, gin.H{ - "status": "ok", - "service": "echochat", - "time": time.Now().Format("2006-01-02 15:04:05"), - }) - }) + // 6. 注册路由(由 router.Setup 统一汇总各模块路由) + router.Setup(engine, app) // 7. 启动 HTTP 服务(优雅关闭) addr := fmt.Sprintf(":%d", cfg.Server.Port) diff --git a/backend/go-service/go.mod b/backend/go-service/go.mod index 848a855..caa7609 100644 --- a/backend/go-service/go.mod +++ b/backend/go-service/go.mod @@ -4,11 +4,13 @@ go 1.23.12 require ( github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/redis/go-redis/v9 v9.18.0 github.com/spf13/viper v1.21.0 go.uber.org/zap v1.27.1 + golang.org/x/crypto v0.40.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -57,7 +59,6 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/backend/go-service/go.sum b/backend/go-service/go.sum index c3c0f66..92b8207 100644 --- a/backend/go-service/go.sum +++ b/backend/go-service/go.sum @@ -39,6 +39,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.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/go-service/pkg/middleware/auth.go b/backend/go-service/pkg/middleware/auth.go new file mode 100644 index 0000000..1caa208 --- /dev/null +++ b/backend/go-service/pkg/middleware/auth.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "strings" + + "github.com/echochat/backend/config" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +const ( + ContextKeyUserID = "user_id" // Context 中存储当前用户 ID 的 Key + ContextKeyUsername = "username" // Context 中存储当前用户名的 Key + ContextKeyRoles = "roles" // Context 中存储当前用户角色列表的 Key +) + +// JWTAuth JWT 认证中间件 +// 从 Authorization Header 中提取 Bearer Token,验证后将用户信息注入 Gin Context +func JWTAuth(jwtCfg *config.JWTConfig) gin.HandlerFunc { + return func(c *gin.Context) { + funcName := "middleware.JWTAuth" + ctx := c.Request.Context() + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + utils.ResponseUnauthorized(c, "缺少认证信息") + c.Abort() + return + } + + // 提取 Bearer Token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + utils.ResponseUnauthorized(c, "认证格式错误,应为 Bearer {token}") + c.Abort() + return + } + + tokenStr := parts[1] + claims, err := utils.ParseToken(jwtCfg, tokenStr) + if err != nil { + logs.Warn(ctx, funcName, "Token 验证失败", + zap.Error(err), + zap.String("ip", c.ClientIP()), + ) + utils.ResponseUnauthorized(c, "认证已过期或无效,请重新登录") + c.Abort() + return + } + + // 验证 Token 类型(只允许 access token 访问接口) + if claims.Subject != "access" { + utils.ResponseUnauthorized(c, "无效的 Token 类型") + c.Abort() + return + } + + // 将用户信息注入 Gin Context,供后续 Controller/Service 使用 + c.Set(ContextKeyUserID, claims.UserID) + c.Set(ContextKeyUsername, claims.Username) + c.Set(ContextKeyRoles, claims.Roles) + + c.Next() + } +} + +// RequireRole 角色权限检查中间件 +// 检查当前用户是否拥有指定角色之一(OR 逻辑),需在 JWTAuth 之后使用 +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + funcName := "middleware.RequireRole" + ctx := c.Request.Context() + + userRoles, exists := c.Get(ContextKeyRoles) + if !exists { + utils.ResponseForbidden(c, "无权限访问") + c.Abort() + return + } + + roleList, ok := userRoles.([]string) + if !ok { + utils.ResponseForbidden(c, "无权限访问") + c.Abort() + return + } + + for _, required := range roles { + for _, userRole := range roleList { + if userRole == required { + c.Next() + return + } + } + } + + userID, _ := c.Get(ContextKeyUserID) + logs.Warn(ctx, funcName, "角色权限不足", + zap.Any("user_id", userID), + zap.Strings("required", roles), + zap.Strings("actual", roleList), + ) + utils.ResponseForbidden(c, "权限不足,需要角色: "+strings.Join(roles, " 或 ")) + c.Abort() + } +} + +// GetCurrentUserID 从 Gin Context 获取当前登录用户 ID +// 供 Controller 层调用的便捷方法 +func GetCurrentUserID(c *gin.Context) (int64, bool) { + val, exists := c.Get(ContextKeyUserID) + if !exists { + return 0, false + } + userID, ok := val.(int64) + return userID, ok +} + +// GetCurrentUsername 从 Gin Context 获取当前登录用户名 +func GetCurrentUsername(c *gin.Context) (string, bool) { + val, exists := c.Get(ContextKeyUsername) + if !exists { + return "", false + } + username, ok := val.(string) + return username, ok +} diff --git a/backend/go-service/pkg/utils/jwt.go b/backend/go-service/pkg/utils/jwt.go new file mode 100644 index 0000000..fd790ff --- /dev/null +++ b/backend/go-service/pkg/utils/jwt.go @@ -0,0 +1,86 @@ +package utils + +import ( + "errors" + "time" + + "github.com/echochat/backend/config" + "github.com/golang-jwt/jwt/v5" +) + +// Claims 自定义 JWT Claims,在标准 Claims 基础上扩展业务字段 +type Claims struct { + UserID int64 `json:"user_id"` // 用户 ID + Username string `json:"username"` // 用户名 + Roles []string `json:"roles"` // 用户角色代码列表 + jwt.RegisteredClaims +} + +var ( + ErrTokenExpired = errors.New("token 已过期") + ErrTokenInvalid = errors.New("token 无效") +) + +// GenerateToken 生成 Access Token +// 包含 UserID、Username、Roles,有效期由配置中的 access_expire_min 决定 +func GenerateToken(cfg *config.JWTConfig, userID int64, username string, roles []string) (string, error) { + expireTime := time.Now().Add(time.Duration(cfg.AccessExpireMin) * time.Minute) + + claims := &Claims{ + UserID: userID, + Username: username, + Roles: roles, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expireTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: cfg.Issuer, + Subject: "access", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(cfg.Secret)) +} + +// GenerateRefreshToken 生成 Refresh Token +// 仅包含 UserID,有效期由配置中的 refresh_expire_day 决定 +func GenerateRefreshToken(cfg *config.JWTConfig, userID int64) (string, error) { + expireTime := time.Now().Add(time.Duration(cfg.RefreshExpireDay) * 24 * time.Hour) + + claims := &Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expireTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: cfg.Issuer, + Subject: "refresh", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(cfg.Secret)) +} + +// ParseToken 解析并验证 JWT Token +// 验证签名、过期时间、签发者,返回解析后的 Claims +func ParseToken(cfg *config.JWTConfig, tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrTokenInvalid + } + return []byte(cfg.Secret), nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrTokenExpired + } + return nil, ErrTokenInvalid + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrTokenInvalid + } + + return claims, nil +} diff --git a/backend/go-service/pkg/utils/password.go b/backend/go-service/pkg/utils/password.go new file mode 100644 index 0000000..41da7a5 --- /dev/null +++ b/backend/go-service/pkg/utils/password.go @@ -0,0 +1,17 @@ +package utils + +import "golang.org/x/crypto/bcrypt" + +// HashPassword 使用 bcrypt 对明文密码进行加密 +// cost 使用默认值 10,在安全性和性能之间取得平衡 +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword 校验明文密码是否与 bcrypt 哈希匹配 +// 返回 true 表示密码正确,false 表示密码错误或哈希无效 +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/backend/go-service/router/router.go b/backend/go-service/router/router.go new file mode 100644 index 0000000..b95850c --- /dev/null +++ b/backend/go-service/router/router.go @@ -0,0 +1,39 @@ +// Package router 主路由汇总入口 +// 不含任何具体路由定义,仅调用各模块的 RegisterRoutes 函数 +// 新增模块时在 Setup 函数中添加一行调用即可 +package router + +import ( + "time" + + "github.com/echochat/backend/app/auth" + "github.com/echochat/backend/app/provider" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" +) + +// Setup 注册所有路由 +// 包含健康检查和各业务模块的路由注册 +func Setup(engine *gin.Engine, app *provider.App) { + // 健康检查(不经过业务中间件) + engine.GET("/health", func(c *gin.Context) { + utils.ResponseOK(c, gin.H{ + "status": "ok", + "service": "echochat", + "time": time.Now().Format("2006-01-02 15:04:05"), + }) + }) + + // JWT 认证中间件实例 + jwtAuth := middleware.JWTAuth(&app.Config.JWT) + + // --- 各模块路由注册 --- + auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth) + + // [未来] im.RegisterRoutes(engine, app.ImController, jwtAuth) + // [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth) + // [未来] contact.RegisterRoutes(engine, app.ContactController, jwtAuth) + // [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth) + // [未来] admin.RegisterRoutes(engine, app.AdminController, jwtAuth) +}