- pkg/jwt: 删 public SetSecret; 加 MustInit(secret string) 启动时强制注入, 缺/为空/等于弱默认值时返回 error(运行期不可再改); 密钥用 atomic.Value 存 []byte,所有读走 mustSecret() 原子 Load,消除 SetSecret/ParseToken 并发 data race(go test -race 零告警)。 - gateway main + auth_provider: 启动时 MustInit 读 JWT_SECRET env,失败 fatal。 - scripts/loadgen/seed/tokens: 同步 MustInit。 - .env.example: JWT_SECRET 改为 ≥32 字节 base64 示例(原为空,被 MustInit 立即拒);注释提示生产 MUST replace。 - 测试: 4 个 MustInit 行为 + 1 个 50-goroutine race 覆盖。 - 行为变更: 任何 .env 缺 JWT_SECRET 或用占位 secret 的服务,启动会 panic (这是 fail-fast 期望行为);其余 4 个 .env 文件占位由 ops 单独轮换。 Co-Authored-By: Claude <noreply@anthropic.com>
561 lines
15 KiB
Go
561 lines
15 KiB
Go
package controller
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"dubbo.apache.org/dubbo-go/v3/client"
|
||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/topfans/backend/gateway/dto"
|
||
"github.com/topfans/backend/gateway/pkg/response"
|
||
"github.com/topfans/backend/gateway/pkg/starcache"
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"google.golang.org/grpc/codes"
|
||
pb "github.com/topfans/backend/pkg/proto/user"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// AuthController 认证控制器
|
||
type AuthController struct {
|
||
userServiceClient pb.UserSocialService
|
||
starCache *starcache.Cache
|
||
}
|
||
|
||
// pbError 用于包装 proto 错误消息
|
||
type pbError struct {
|
||
message string
|
||
}
|
||
|
||
func (e *pbError) Error() string {
|
||
return e.message
|
||
}
|
||
|
||
// NewAuthController 创建认证控制器
|
||
//
|
||
// starCache 用于 Register/Login 两个公开入口的 star 解析:
|
||
// 取代原先每次都直接 RPC GetFanIdentities 拉一遍可选身份列表。
|
||
func NewAuthController(dubboClient *client.Client, starCache *starcache.Cache) (*AuthController, error) {
|
||
svc, err := pb.NewUserSocialService(dubboClient)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &AuthController{
|
||
userServiceClient: svc,
|
||
starCache: starCache,
|
||
}, nil
|
||
}
|
||
|
||
// findStar 解析 starID 对应的 *pb.Star。失败仅为 warn,不阻断主流程
|
||
// (DTO 转换对 nil star 有防御,Register/Login 仍能成功)。
|
||
func (ctrl *AuthController) findStar(ctx context.Context, starID int64) *pb.Star {
|
||
star, err := ctrl.starCache.GetStar(ctx, starID)
|
||
if err != nil {
|
||
logger.Logger.Warn("GetStar cache miss+RPC failed, continuing with nil star",
|
||
zap.Int64("star_id", starID),
|
||
zap.Error(err),
|
||
)
|
||
return nil
|
||
}
|
||
return star
|
||
}
|
||
|
||
// Register 用户注册
|
||
// @Summary 用户注册
|
||
// @Description 用户注册接口,需要提供手机号、密码、选择明星身份
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body pb.RegisterRequest true "注册请求"
|
||
// @Success 200 {object} response.Response{data=dto.RegisterResponseDTO}
|
||
// @Router /api/v1/auth/register [post]
|
||
func (ctrl *AuthController) Register(c *gin.Context) {
|
||
var req pb.RegisterRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid register request", zap.Error(err))
|
||
response.BadRequest(c, "请求参数错误")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("Register request received",
|
||
zap.String("mobile", req.Mobile),
|
||
zap.Int64("star_id", req.StarId),
|
||
)
|
||
|
||
// 调用 Dubbo 服务(无需 Attachments)
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.Register(ctx, &req)
|
||
if err != nil {
|
||
logger.Logger.Error("Register failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
star := ctrl.findStar(ctx, req.StarId)
|
||
|
||
logger.Logger.Info("Register successful",
|
||
zap.Int64("user_id", resp.User.Id),
|
||
)
|
||
|
||
// 转换为 DTO 并返回
|
||
data := dto.ToRegisterResponseDTO(
|
||
resp.AccessToken,
|
||
resp.ExpiresIn,
|
||
resp.User,
|
||
resp.FanProfile,
|
||
star,
|
||
)
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// Login 用户登录
|
||
// @Summary 用户登录
|
||
// @Description 用户登录接口,需要提供手机号和密码
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body pb.LoginRequest true "登录请求"
|
||
// @Success 200 {object} response.Response{data=dto.LoginResponseDTO}
|
||
// @Router /api/v1/auth/login [post]
|
||
func (ctrl *AuthController) Login(c *gin.Context) {
|
||
var req pb.LoginRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid login request", zap.Error(err))
|
||
response.BadRequest(c, "请求参数错误")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("Login request received",
|
||
zap.String("mobile", req.Mobile),
|
||
)
|
||
|
||
// 调用 Dubbo 服务(无需 Attachments)
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.Login(ctx, &req)
|
||
if err != nil {
|
||
logger.Logger.Error("Login failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
star := ctrl.findStar(ctx, resp.FanProfile.StarId)
|
||
|
||
logger.Logger.Info("Login successful",
|
||
zap.Int64("user_id", resp.User.Id),
|
||
)
|
||
|
||
// 转换为 DTO 并返回
|
||
data := dto.ToLoginResponseDTO(
|
||
resp.AccessToken,
|
||
resp.ExpiresIn,
|
||
"", // refresh_token 暂时为空
|
||
resp.User,
|
||
resp.FanProfile,
|
||
star,
|
||
)
|
||
response.Success(c, data)
|
||
}
|
||
|
||
// RefreshToken 刷新 Token
|
||
// @Summary 刷新访问令牌
|
||
// @Description 使用当前访问令牌刷新获取新的访问令牌
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/auth/refresh [post]
|
||
func (ctrl *AuthController) RefreshToken(c *gin.Context) {
|
||
// 从认证中间件获取用户信息
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"code": "UNAUTHORIZED",
|
||
"message": "user not authenticated",
|
||
})
|
||
return
|
||
}
|
||
|
||
starID, _ := c.Get("star_id")
|
||
|
||
logger.Logger.Info("RefreshToken request received",
|
||
zap.Any("user_id", userID),
|
||
zap.Any("star_id", starID),
|
||
)
|
||
|
||
// 创建带 Attachments 的 context
|
||
// 注意:Dubbo Attachments 的值必须是 string 或 []string
|
||
ctx := context.Background()
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
"star_id": strconv.FormatInt(starID.(int64), 10),
|
||
})
|
||
|
||
// 调用 Dubbo 服务
|
||
resp, err := ctrl.userServiceClient.RefreshToken(ctx, &pb.RefreshTokenRequest{})
|
||
if err != nil {
|
||
logger.Logger.Error("RefreshToken failed", zap.Error(err))
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"code": "INTERNAL_ERROR",
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("RefreshToken successful",
|
||
zap.Any("user_id", userID),
|
||
)
|
||
|
||
c.JSON(http.StatusOK, resp)
|
||
}
|
||
|
||
// Logout 用户登出
|
||
// @Summary 用户登出
|
||
// @Description 使用户访问令牌失效
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/auth/logout [post]
|
||
func (ctrl *AuthController) Logout(c *gin.Context) {
|
||
// 从认证中间件获取用户信息
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
response.Unauthorized(c, "请先登录")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("Logout request received",
|
||
zap.Any("user_id", userID),
|
||
)
|
||
|
||
// 创建带 Attachments 的 context
|
||
// 注意:Dubbo Attachments 的值必须是 string 或 []string
|
||
ctx := context.Background()
|
||
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": strconv.FormatInt(userID.(int64), 10),
|
||
})
|
||
|
||
// 调用 Dubbo 服务
|
||
_, err := ctrl.userServiceClient.Logout(ctx, &pb.LogoutRequest{})
|
||
if err != nil {
|
||
logger.Logger.Error("Logout failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("Logout successful",
|
||
zap.Any("user_id", userID),
|
||
)
|
||
|
||
// 返回空 data
|
||
response.Success(c, gin.H{})
|
||
}
|
||
|
||
// ValidateToken 验证 Token(用于客户端检查 Token 是否有效)
|
||
// @Summary 验证访问令牌
|
||
// @Description 验证访问令牌的有效性
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param request body pb.ValidateTokenRequest true "验证请求"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/auth/validate [post]
|
||
//
|
||
// ★ 本接口已被 AuthMiddleware 保护(鉴权边界审计 §四 P2):
|
||
// 路由从公开 /auth 组移到 authProtected(router.go:179-191),
|
||
// 调用方必须持有有效、未过期、未在黑名单的 JWT。
|
||
// 控制器内部无需再校验 Authorization 头,AuthMiddleware 已做。
|
||
func (ctrl *AuthController) ValidateToken(c *gin.Context) {
|
||
var req pb.ValidateTokenRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"code": "BAD_REQUEST",
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
// 调用 Dubbo 服务
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.ValidateToken(ctx, &req)
|
||
if err != nil {
|
||
logger.Logger.Error("ValidateToken failed", zap.Error(err))
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"code": "INTERNAL_ERROR",
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, resp)
|
||
}
|
||
|
||
// CheckNickname 检查昵称是否已被注册
|
||
// @Summary 检查昵称是否被注册
|
||
// @Description 检查指定昵称是否已被他人使用
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body pb.CheckNicknameRequest true "检查昵称请求"
|
||
// @Success 200 {object} response.Response{data=pb.CheckNicknameResponse}
|
||
// @Router /api/v1/auth/check-nickname [post]
|
||
func (ctrl *AuthController) CheckNickname(c *gin.Context) {
|
||
var req pb.CheckNicknameRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid check nickname request", zap.Error(err))
|
||
response.BadRequest(c, "请求参数错误")
|
||
return
|
||
}
|
||
|
||
// 校验 nickname 不能为空
|
||
if req.Nickname == "" {
|
||
response.BadRequest(c, "昵称不能为空")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("CheckNickname request received",
|
||
zap.String("nickname", req.Nickname),
|
||
)
|
||
|
||
// 调用 Dubbo 服务
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.CheckNickname(ctx, &req)
|
||
if err != nil {
|
||
logger.Logger.Error("CheckNickname failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("CheckNickname successful",
|
||
zap.String("nickname", req.Nickname),
|
||
zap.Bool("exists", resp.Exists),
|
||
)
|
||
|
||
response.Success(c, gin.H{
|
||
"exists": resp.Exists,
|
||
})
|
||
}
|
||
|
||
// SendCode 发送验证码
|
||
// @Summary 发送验证码
|
||
// @Description 发送手机验证码,用于注册或重置密码
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body dto.SendCodeRequest true "发送验证码请求"
|
||
// @Success 200 {object} response.Response{data=dto.SendCodeResponse}
|
||
// @Router /api/v1/auth/send-code [post]
|
||
func (ctrl *AuthController) SendCode(c *gin.Context) {
|
||
var req dto.SendCodeRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid send code request", zap.Error(err))
|
||
response.BadRequest(c, "参数错误")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("SendCode request received",
|
||
zap.String("mobile", req.Mobile),
|
||
zap.String("scene", req.Scene),
|
||
)
|
||
|
||
// 调用 Dubbo 服务
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.SendCode(ctx, &pb.SendCodeRequest{
|
||
Mobile: req.Mobile,
|
||
Scene: req.Scene,
|
||
})
|
||
if err != nil {
|
||
logger.Logger.Error("SendCode failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("SendCode successful",
|
||
zap.String("mobile", req.Mobile),
|
||
)
|
||
|
||
response.Success(c, gin.H{
|
||
"expires_in": resp.ExpiresIn,
|
||
})
|
||
}
|
||
|
||
// VerifyCode 验证验证码
|
||
// @Summary 验证验证码
|
||
// @Description 验证手机验证码,验证成功后返回 verify_token
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body dto.VerifyCodeRequest true "验证验证码请求"
|
||
// @Success 200 {object} response.Response{data=dto.VerifyCodeResponse}
|
||
// @Router /api/v1/auth/verify-code [post]
|
||
func (ctrl *AuthController) VerifyCode(c *gin.Context) {
|
||
var req dto.VerifyCodeRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid verify code request", zap.Error(err))
|
||
response.BadRequest(c, "参数错误")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("VerifyCode request received",
|
||
zap.String("mobile", req.Mobile),
|
||
zap.String("scene", req.Scene),
|
||
)
|
||
|
||
// 调用 Dubbo 服务
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.VerifyCode(ctx, &pb.VerifyCodeRequest{
|
||
Mobile: req.Mobile,
|
||
Code: req.Code,
|
||
Scene: req.Scene,
|
||
})
|
||
if err != nil {
|
||
logger.Logger.Error("VerifyCode failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("VerifyCode successful",
|
||
zap.String("mobile", req.Mobile),
|
||
)
|
||
|
||
response.Success(c, gin.H{
|
||
"verified": resp.Verified,
|
||
"verify_token": resp.VerifyToken,
|
||
"expires_in": resp.ExpiresIn,
|
||
})
|
||
}
|
||
|
||
// ResetPassword 匿名重置密码(忘记密码场景)
|
||
// @Summary 匿名重置密码
|
||
// @Description 通过手机号+短信验证码(scene=password)+新密码重置密码,无需登录态
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body dto.ResetPasswordRequest true "重置密码请求"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/auth/reset-password [post]
|
||
func (ctrl *AuthController) ResetPassword(c *gin.Context) {
|
||
var req dto.ResetPasswordRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid reset password request", zap.Error(err))
|
||
response.BadRequest(c, "参数错误")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("ResetPassword request received",
|
||
zap.String("mobile", req.Mobile),
|
||
)
|
||
|
||
// 调用 Dubbo 服务
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.ResetPassword(ctx, &pb.ResetPasswordRequest{
|
||
Mobile: req.Mobile,
|
||
NewPassword: req.NewPassword,
|
||
VerifyToken: req.VerifyToken,
|
||
})
|
||
if err != nil {
|
||
logger.Logger.Error("ResetPassword failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("ResetPassword successful",
|
||
zap.String("mobile", req.Mobile),
|
||
)
|
||
|
||
response.Success(c, gin.H{})
|
||
}
|
||
|
||
// CheckMobile 检查手机号是否已被注册
|
||
// @Summary 检查手机号是否被注册
|
||
// @Description 检查指定手机号是否已被他人使用
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body pb.CheckMobileRequest true "检查手机号请求"
|
||
// @Success 200 {object} response.Response{data=pb.CheckMobileResponse}
|
||
// @Router /api/v1/auth/check-mobile [post]
|
||
func (ctrl *AuthController) CheckMobile(c *gin.Context) {
|
||
var req pb.CheckMobileRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Logger.Warn("Invalid check mobile request", zap.Error(err))
|
||
response.BadRequest(c, "请求参数错误")
|
||
return
|
||
}
|
||
|
||
// 校验 mobile 不能为空
|
||
if req.Mobile == "" {
|
||
response.BadRequest(c, "手机号不能为空")
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("CheckMobile request received",
|
||
zap.String("mobile", req.Mobile),
|
||
)
|
||
|
||
// 调用 Dubbo 服务
|
||
ctx := context.Background()
|
||
resp, err := ctrl.userServiceClient.CheckMobile(ctx, &req)
|
||
if err != nil {
|
||
logger.Logger.Error("CheckMobile failed", zap.Error(err))
|
||
response.HandleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 检查业务错误
|
||
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
|
||
response.HandleError(c, &pbError{message: resp.Base.Message})
|
||
return
|
||
}
|
||
|
||
logger.Logger.Info("CheckMobile successful",
|
||
zap.String("mobile", req.Mobile),
|
||
zap.Bool("exists", resp.Exists),
|
||
)
|
||
|
||
response.Success(c, gin.H{
|
||
"exists": resp.Exists,
|
||
})
|
||
}
|