- 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>
552 lines
15 KiB
Go
552 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))
|
||
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,
|
||
})
|
||
}
|