- 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属 - 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签 - 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示 - 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片 - 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查
338 lines
11 KiB
Go
338 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"hfb_sys/backend/internal/sitecode"
|
|
"hfb_sys/backend/pkg/response"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type InvitationBinder interface {
|
|
BindInvitation(ctx context.Context, inviteeID uint64, code string, siteID uint64, siteCode ...string) error
|
|
}
|
|
|
|
type Handler struct {
|
|
service *Service
|
|
invitationBinder InvitationBinder
|
|
}
|
|
|
|
func (h *Handler) SetInvitationBinder(binder InvitationBinder) {
|
|
h.invitationBinder = binder
|
|
}
|
|
|
|
type SendSMSRequest struct {
|
|
Phone string `json:"phone" binding:"required"`
|
|
CaptchaID string `json:"captcha_id" binding:"required"`
|
|
CaptchaCode string `json:"captcha_code" binding:"required"`
|
|
SiteCode string `json:"site_code"`
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Phone string `json:"phone" binding:"required"`
|
|
Code string `json:"code" binding:"required,len=6"`
|
|
TrackCode string `json:"track_code"`
|
|
InviteCode string `json:"invite_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"`
|
|
InviteCode string `json:"invite_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
|
|
}
|
|
|
|
siteCode := sitecode.FromRequest(c)
|
|
if siteCode == "" {
|
|
siteCode = strings.TrimSpace(req.SiteCode)
|
|
}
|
|
domain := strings.TrimSpace(firstNonEmpty(
|
|
c.GetHeader("X-Distribution-Domain"),
|
|
c.Query("distribution_domain"),
|
|
c.Query("domain"),
|
|
))
|
|
if siteCode == "" && domain == "" {
|
|
host := sitecode.ExtractHost(c.Request.Host)
|
|
if host != "" && !sitecode.IsLocalHost(host) {
|
|
domain = host
|
|
}
|
|
}
|
|
|
|
if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), siteCode, domain); err != nil {
|
|
writeAuthError(c, err)
|
|
return
|
|
}
|
|
response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300})
|
|
}
|
|
|
|
func firstNonEmpty(items ...string) string {
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item) != "" {
|
|
return item
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
siteCode := sitecode.FromRequest(c)
|
|
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode), siteCode)
|
|
if err != nil {
|
|
writeAuthError(c, err)
|
|
return
|
|
}
|
|
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
|
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
|
|
}
|
|
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
|
|
}
|
|
siteCode := sitecode.FromRequest(c)
|
|
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode), siteCode)
|
|
if err != nil {
|
|
writeAuthError(c, err)
|
|
return
|
|
}
|
|
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
|
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
|
|
}
|
|
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")
|
|
}
|