Files
hfb_sys/backend/internal/modules/auth/handler.go
T
yml2213 7ffc3822f5 Merge branch 'main' into feature/distribution-system
合入 main 的推广追踪(adtracking)模块,并解决与分销业务的 8 处冲突。

冲突解决原则:两边的字段、菜单与路由全部保留,不删任何一方。

后端:
- model/order.go:保留分销字段注释块,追加 SourceTrackCode
- model/user.go:追加 RegisterTrackCode;TokenVersion 默认值保持 1
  (与本库 users.token_version 列默认值一致)
- auth/repository.go:两处建号结构体同时写入 RegisterTrackCode 与 TokenVersion
- order/dto.go:OrderDTO 同时暴露 distribution_site_id 与 source_track_code;
  CreateRequest 同时接受 distribution_site_code 与 track_code
- order/presenter.go:同时映射 DistributionSiteID 与 SourceTrackCode

前端:
- orders/api/orders.ts:createOrder 同时携带 distribution_site_code 与 track_code
- layouts/AdminLayout.vue:分销管理菜单组与推广追踪菜单组并列保留
- router/adminRoutes.ts:分销 4 条路由与 tracking-links 路由并列保留

迁移:feature 侧 000064-000067 已在前一提交顺延为 000065-000068,
为 main 的 000064_ad_tracking 让号,合并后编号 000063→000068 连续无重复。
2026-09-10 13:06:18 +08:00

290 lines
9.4 KiB
Go

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"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"`
}
type LoginRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
TrackCode string `json:"track_code"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type PasswordLoginRequest struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password" binding:"required,min=8,max=20"`
}
type SetPasswordRequest struct {
Password string `json:"password" binding:"required,min=8,max=20"`
OldPassword string `json:"old_password"`
}
type RegisterRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
Password string `json:"password" binding:"required,min=8,max=20"`
TrackCode string `json:"track_code"`
}
type ResetPasswordRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
NewPassword string `json:"new_password" binding:"required,min=8,max=20"`
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Captcha(c *gin.Context) {
item, err := h.service.Captcha(c.Request.Context())
if err != nil {
writeAuthError(c, err)
return
}
response.OK(c, item)
}
// SendSMS 发送短信验证码
// @Summary 发送短信验证码
// @Description 发送登录验证码到指定手机号
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body SendSMSRequest true "手机号"
// @Success 200 {object} response.Body "发送成功"
// @Failure 400 {object} response.Body "请求参数错误"
// @Failure 500 {object} response.Body "服务器错误"
// @Router /auth/sms/send [post]
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), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode)); err != nil {
writeAuthError(c, err)
return
}
response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300})
}
// Login 短信验证码登录
// @Summary 短信验证码登录
// @Description 使用手机号和验证码登录
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body LoginRequest true "登录信息"
// @Success 200 {object} response.Body "登录成功,返回用户信息和 token"
// @Failure 400 {object} response.Body "请求参数错误"
// @Failure 401 {object} response.Body "验证码错误"
// @Failure 500 {object} response.Body "服务器错误"
// @Router /auth/sms/login [post]
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), strings.TrimSpace(req.TrackCode))
if err != nil {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
// Refresh 刷新访问令牌
// @Summary 刷新访问令牌
// @Description 使用 refresh_token 获取新的 access_token
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "刷新令牌"
// @Success 200 {object} response.Body "刷新成功,返回新的 token"
// @Failure 400 {object} response.Body "请求参数错误"
// @Failure 401 {object} response.Body "refresh_token 无效"
// @Router /auth/refresh [post]
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(c.Request.Context(), req.RefreshToken)
if err != nil {
if errors.Is(err, ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
return
}
response.Unauthorized(c, "刷新令牌无效或已过期")
return
}
setAccessCookie(c, tokens.AccessToken)
response.OK(c, tokens)
}
// Logout 登出
// @Summary 登出
// @Description 用户登出(客户端需清除本地 token)
// @Tags 认证
// @Accept json
// @Produce json
// @Success 200 {object} response.Body "登出成功"
// @Security BearerAuth
// @Router /auth/logout [post]
func (h *Handler) Logout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
if err := h.service.Logout(c.Request.Context(), userID); err != nil {
writeAuthError(c, err)
return
}
clearAccessCookie(c)
response.OK(c, gin.H{"logged_out": true})
}
func (h *Handler) PasswordLogin(c *gin.Context) {
var req PasswordLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号和密码不能为空,密码长度 8-20 位")
return
}
result, err := h.service.LoginWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), req.Password, c.ClientIP())
if err != nil {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
func (h *Handler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
return
}
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode))
if err != nil {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
func (h *Handler) SetPassword(c *gin.Context) {
var req SetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "密码不能为空,长度 8-20 位")
return
}
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
if err := h.service.SetPassword(c.Request.Context(), userID, req.Password, req.OldPassword); err != nil {
writeAuthError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "手机号、验证码和新密码不能为空")
return
}
if err := h.service.ResetPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.NewPassword); err != nil {
writeAuthError(c, err)
return
}
response.OK(c, gin.H{"reseted": true})
}
func writeAuthError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库或 Redis 未连接")
case errors.Is(err, ErrInvalidPhone):
response.BadRequest(c, "手机号格式不正确")
case errors.Is(err, ErrCaptchaInvalid):
response.BadRequest(c, "图形验证码错误或已过期")
case errors.Is(err, ErrCodeRateLimited):
response.Error(c, http.StatusTooManyRequests, "rate_limited", "验证码发送过于频繁")
case errors.Is(err, ErrSMSSendFailed):
response.Error(c, http.StatusBadGateway, "sms_send_failed", "验证码发送失败,请稍后重试")
case errors.Is(err, ErrCodeInvalid):
response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrUserDisabled):
response.Error(c, http.StatusForbidden, "user_disabled", "用户已被冻结")
case errors.Is(err, ErrInvalidPassword):
response.BadRequest(c, "手机号或密码错误")
case errors.Is(err, ErrInvalidOldPassword):
response.BadRequest(c, "原密码错误")
case errors.Is(err, ErrPasswordNotSet):
response.BadRequest(c, "未设置密码,请使用短信验证码登录后设置密码")
case errors.Is(err, ErrPasswordTooWeak):
response.BadRequest(c, "密码长度应为 8-20 位")
case errors.Is(err, ErrLoginLocked):
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请 15 分钟后再试")
case errors.Is(err, ErrUserAlreadyExists):
response.BadRequest(c, "该手机号已注册,请直接登录或使用短信验证码登录")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
}
}
func currentUserID(c *gin.Context) (uint64, bool) {
val, exists := c.Get("user_id")
if !exists {
return 0, false
}
userID, ok := val.(uint64)
return userID, ok
}
func setAccessCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true)
}
func clearAccessCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true)
}
func requestUsesHTTPS(c *gin.Context) bool {
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}