topfans/backend/gateway/controller/auth_controller.go
zerosaturation 8a767fb400 fix(backend): service stability — batch 3 accumulated (bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential / ErrTooManyLoginAttempts;
  user-not-found 跑 dummy bcrypt 抹平 ~100ms 时序差; mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级)。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, noop EventProducer.Publish; Init 不再装配 streams;
  11 处硬编码 'gallery'/'default' 抽常量到 pkg/queue/consts (值不变, 消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value, 50-goroutine race_test 零告警;
  MustInit 调用点 gateway main + auth_provider + loadgen 同步更新。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误 记 WARN 不静默;
  Dify err 映射稳定用户文案。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop worker。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go);
  铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。
- .env.example JWT_SECRET 改为 ≥32 字节 base64 示例(原为空, 被 MustInit 立即拒)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 14:03:21 +08:00

552 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
response.InternalError(c, "刷新令牌失败")
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 {
response.BadRequest(c, "请求参数错误")
return
}
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.ValidateToken(ctx, &req)
if err != nil {
logger.Logger.Error("ValidateToken failed", zap.Error(err))
response.InternalError(c, "令牌校验失败")
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,
})
}