topfans/docs/superpowers/plans/2026-05-26-sms-register-implementation.md
2026-05-26 13:23:04 +08:00

33 KiB
Raw Blame History

注册短信验证码功能实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在用户注册流程中加入阿里云短信验证码验证,确保手机号真实有效,防止恶意注册。

Architecture:

  • 采用方案一userService直接集成前端 → 网关 → userService → 阿里云SMS/Redis
  • 注册验证码存储在RedisHash结构60秒TTL
  • 验证成功后生成verify_token存入Redis300秒TTL注册时校验
  • 限流策略60秒内不能重复发送每小时最多10次验证失败3次强制删除验证码

Tech Stack: Go (Dubbo-go), Redis, 阿里云SMS SDK, Vue/uni-app


文件结构

后端改动

新增文件:

  • backend/services/userService/config/sms_config.go - SMS配置结构体
  • backend/services/userService/service/sms_service.go - 短信发送服务
  • backend/services/userService/pkg/redis/sms_redis.go - Redis操作验证码存储、限流
  • backend/pkg/proto/user/auth.pb.go - Proto生成文件新增SendCode/VerifyCode方法

修改文件:

  • backend/services/userService/main.go - 初始化SMS配置和依赖
  • backend/services/userService/service/auth_service.go - 添加verify_token校验逻辑
  • backend/services/userService/service/auth_service.go:62-96 - Register方法需增加verify_token验证
  • backend/services/userService/provider/auth_provider.go - 实现SendCode/VerifyCode的RPC处理
  • backend/services/userService/provider/unified_provider.go - 注册新方法到Dubbo

网关改动:

  • backend/gateway/controller/auth_controller.go - 添加send-code/verify-code HTTP端点
  • backend/gateway/dto/auth_dto.go - 添加请求/响应DTO
  • backend/gateway/router/ - 路由配置

数据库:

  • backend/migrations/ - 新增 sms_send_log

前端改动

修改文件:

  • frontend/pages/register/register.vue - 添加短信验证码交互流程
  • frontend/utils/api.js - 添加send-code/verify-code API

阶段一:后端基础设施

Task 1: 添加阿里云SMS SDK依赖

Files:

  • Modify: backend/services/userService/go.mod

  • Step 1: 添加阿里云SMS SDK依赖

cd /Users/liulujian/Documents/code/TopFansByGithub/backend/services/userService
go get github.com/alibabacloud-go/dysmsapi-20180501/v2/client
go get github.com/alibabacloud-go/darabonba-openapi/v2/client
go get github.com/alibabacloud-go/tea/tea
go get github.com/alibabacloud-go/tea-utils/v2/service
  • Step 2: 验证依赖安装
cd /Users/liulujian/Documents/code/TopFansByGithub/backend/services/userService
go mod tidy
go build -o userService .

Task 2: 创建SMS配置

Files:

  • Create: backend/services/userService/config/sms_config.go

  • Step 1: 编写SMSConfig配置结构体

package config

// SMSConfig 短信配置
type SMSConfig struct {
    AccessKeyID     string
    AccessKeySecret string
    SignName        string        // 短信签名
    TemplateCode    string        // 短信模板CODE
    Region          string        // 区域(默认 cn-hangzhou
}

// GetSMSConfig 获取SMS配置从环境变量
func GetSMSConfig() *SMSConfig {
    return &SMSConfig{
        AccessKeyID:     getEnv("SMS_ACCESS_KEY_ID", ""),
        AccessKeySecret: getEnv("SMS_ACCESS_KEY_SECRET", ""),
        SignName:        getEnv("SMS_SIGN_NAME", "TopFans"),
        TemplateCode:    getEnv("SMS_TEMPLATE_CODE", ""),
        Region:          getEnv("SMS_REGION", "cn-hangzhou"),
    }
}

func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}
  • Step 2: Commit
git add backend/services/userService/config/sms_config.go
git commit -m "feat: add SMS config structure"

Task 3: 创建Redis操作层验证码存储+限流)

Files:

  • Create: backend/services/userService/pkg/redis/sms_redis.go

  • Step 1: 编写Redis操作函数

package redis

import (
    "context"
    "fmt"
    "math/rand"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
    "github.com/topfans/backend/pkg/logger"
)

// SMS Redis Key 前缀
const (
    SMSCodeKeyPrefix     = "sms:register:"    // 验证码sms:register:{mobile}
    SMSVerifyTokenPrefix = "verify:register:"  // 验证Tokenverify:register:{mobile}
    SMSLimitMobilePrefix  = "sms:limit:mobile:" // 手机号频率sms:limit:mobile:register:{mobile}
    SMSLimitIPPrefix      = "sms:limit:ip:send:" // IP频率sms:limit:ip:send:{ip}
    SMSBlacklistIPPrefix  = "sms:blacklist:ip:"  // IP黑名单sms:blacklist:ip:{ip}
)

// SMSCodeData 验证码数据结构
type SMSCodeData struct {
    Code       string `json:"code"`
    CreatedAt  int64  `json:"created_at"`
    Attempts   int    `json:"attempts"`
    Used       bool   `json:"used"`
}

// GenerateCode 生成6位数字验证码
func GenerateCode() string {
    rand.Seed(time.Now().UnixNano())
    code := rand.Intn(900000) + 100000 // 100000-999999
    return strconv.Itoa(code)
}

// SaveSMSCode 保存验证码到Redis
func SaveSMSCode(ctx context.Context, mobile, code string, ttl time.Duration) error {
    key := SMSCodeKeyPrefix + mobile
    data := &SMSCodeData{
        Code:      code,
        CreatedAt: time.Now().Unix(),
        Attempts:  0,
        Used:      false,
    }
    // 使用Hash存储
    err := GetRedisClient().HSet(ctx, key, map[string]interface{}{
        "code":       data.Code,
        "created_at": data.CreatedAt,
        "attempts":   data.Attempts,
        "used":       data.Used,
    }).Err()
    if err != nil {
        return err
    }
    return GetRedisClient().Expire(ctx, key, ttl).Err()
}

// GetSMSCode 获取验证码数据
func GetSMSCode(ctx context.Context, mobile string) (*SMSCodeData, error) {
    key := SMSCodeKeyPrefix + mobile
    result, err := GetRedisClient().HGetAll(ctx, key).Result()
    if err != nil {
        return nil, err
    }
    if len(result) == 0 {
        return nil, fmt.Errorf("验证码不存在或已过期")
    }
    attempts, _ := strconv.Atoi(result["attempts"])
    return &SMSCodeData{
        Code:      result["code"],
        CreatedAt: parseInt64(result["created_at"]),
        Attempts:  attempts,
        Used:      result["used"] == "true",
    }, nil
}

// DeleteSMSCode 删除验证码
func DeleteSMSCode(ctx context.Context, mobile string) error {
    key := SMSCodeKeyPrefix + mobile
    return GetRedisClient().Del(ctx, key).Err()
}

// IncrementAttempts 递增验证失败次数
func IncrementAttempts(ctx context.Context, mobile string) (int, error) {
    key := SMSCodeKeyPrefix + mobile
    count, err := GetRedisClient().HIncrBy(ctx, key, "attempts", 1).Result()
    return int(count), err
}

// MarkCodeUsed 标记验证码已使用
func MarkCodeUsed(ctx context.Context, mobile string) error {
    key := SMSCodeKeyPrefix + mobile
    return GetRedisClient().HSet(ctx, key, "used", "true").Err()
}

// SaveVerifyToken 保存验证Token
func SaveVerifyToken(ctx context.Context, mobile, token string, ttl time.Duration) error {
    key := SMSVerifyTokenPrefix + mobile
    return GetRedisClient().Set(ctx, key, token, ttl).Err()
}

// GetVerifyToken 获取验证Token
func GetVerifyToken(ctx context.Context, mobile string) (string, error) {
    key := SMSVerifyTokenPrefix + mobile
    return GetRedisClient().Get(ctx, key).Result()
}

// DeleteVerifyToken 删除验证Token
func DeleteVerifyToken(ctx context.Context, mobile string) error {
    key := SMSVerifyTokenPrefix + mobile
    return GetRedisClient().Del(ctx, key).Err()
}

// CheckRateLimitMobile 检查手机号发送频率限制
func CheckRateLimitMobile(ctx context.Context, mobile string) (bool, int, error) {
    key := SMSLimitMobilePrefix + mobile
    count, err := GetRedisClient().Get(ctx, key).Result()
    if err == redis.Nil {
        return true, 0, nil // 没有限制记录
    }
    if err != nil {
        return false, 0, err
    }
    limitCount, _ := strconv.Atoi(count)
    return limitCount < 10, limitCount, nil // 每小时最多10次
}

// IncrMobileCount 增加手机号发送计数
func IncrMobileCount(ctx context.Context, mobile string) error {
    key := SMSLimitMobilePrefix + mobile
    pipe := GetRedisClient().Pipeline()
    pipe.Incr(ctx, key)
    pipe.Expire(ctx, key, time.Hour) // 1小时后过期
    _, err := pipe.Exec(ctx)
    return err
}

// CheckRateLimitIP 检查IP发送频率限制
func CheckRateLimitIP(ctx context.Context, ip string) (bool, int, error) {
    key := SMSLimitIPPrefix + ip
    count, err := GetRedisClient().Get(ctx, key).Result()
    if err == redis.Nil {
        return true, 0, nil
    }
    if err != nil {
        return false, 0, err
    }
    limitCount, _ := strconv.Atoi(count)
    return limitCount < 30, limitCount, nil // 每小时最多30次
}

// IncrIPCount 增加IP发送计数
func IncrIPCount(ctx context.Context, ip string) error {
    key := SMSLimitIPPrefix + ip
    pipe := GetRedisClient().Pipeline()
    pipe.Incr(ctx, key)
    pipe.Expire(ctx, key, time.Hour)
    _, err := pipe.Exec(ctx)
    return err
}

// CheckIPBlacklist 检查IP是否在黑名单
func CheckIPBlacklist(ctx context.Context, ip string) (bool, error) {
    key := SMSBlacklistIPPrefix + ip
    exists, err := GetRedisClient().Exists(ctx, key).Result()
    return exists > 0, err
}

func parseInt64(s string) int64 {
    v, _ := strconv.ParseInt(s, 10, 64)
    return v
}
  • Step 2: 检查是否已有Redis客户端实现
grep -r "go-redis" /Users/liulujian/Documents/code/TopFansByGithub/backend/services/userService/

如果没有需要添加依赖并创建Redis客户端初始化代码。

  • Step 3: Commit
git add backend/services/userService/pkg/redis/sms_redis.go
git commit -m "feat: add SMS Redis operations for code storage and rate limiting"

Task 4: 创建SMS服务

Files:

  • Create: backend/services/userService/service/sms_service.go

  • Step 1: 编写SMS服务

package service

import (
    "context"
    "fmt"
    "os"
    "strings"
    "time"

    dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20180501/v2/client"
    util "github.com/alibabacloud-go/tea-utils/v2/service"
    "github.com/alibabacloud-go/darabonba-openapi/v2/client"
    "github.com/alibabacloud-go/tea/tea"
    "github.com/topfans/backend/pkg/logger"
    "github.com/topfans/backend/services/userService/config"
    "github.com/topfans/backend/services/userService/pkg/redis"
    "go.uber.org/zap"
)

var smsClient *dysmsapi20170525.Client

// InitSMSClient 初始化SMS客户端单例模式
func InitSMSClient() error {
    cfg := config.GetSMSConfig()
    if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
        logger.Logger.Warn("SMS credentials not configured, SMS service disabled")
        return nil
    }

    openapiConfig := &client.Config{
        AccessKeyId:     tea.String(cfg.AccessKeyID),
        AccessKeySecret: tea.String(cfg.AccessKeySecret),
    }
    openapiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com")

    var err error
    smsClient, err = dysmsapi20170525.NewClient(openapiConfig)
    if err != nil {
        return fmt.Errorf("failed to create SMS client: %w", err)
    }

    logger.Logger.Info("SMS client initialized successfully")
    return nil
}

// SendVerificationCode 发送注册验证码
func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) {
    // 1. 检查IP黑名单
    isBlacklisted, err := redis.CheckIPBlacklist(ctx, ip)
    if err != nil {
        logger.Logger.Error("Failed to check IP blacklist", zap.Error(err))
    }
    if isBlacklisted {
        return 0, fmt.Errorf("暂时无法操作,请稍后再试")
    }

    // 2. 检查手机号发送频率60秒内不能重复发送
    // 这里用Redis TTL来检查如果sms:register:{mobile}存在说明60秒内发过
    existingCode, _ := redis.GetSMSCode(ctx, mobile)
    if existingCode != nil {
        return 0, fmt.Errorf("发送过于频繁,请稍后再试")
    }

    // 3. 检查每小时发送次数限制
    allowed, count, err := redis.CheckRateLimitMobile(ctx, mobile)
    if err != nil {
        logger.Logger.Error("Failed to check mobile rate limit", zap.Error(err))
    }
    if !allowed {
        return 0, fmt.Errorf("当前手机号发送次数超限,请稍后再试")
    }

    // 4. 检查IP发送频率
    allowed, _, err = redis.CheckRateLimitIP(ctx, ip)
    if err != nil {
        logger.Logger.Error("Failed to check IP rate limit", zap.Error(err))
    }
    if !allowed {
        return 0, fmt.Errorf("请求过于频繁,请稍后再试")
    }

    // 5. 生成6位验证码
    code := redis.GenerateCode()

    // 6. 发送短信
    if smsClient != nil {
        cfg := config.GetSMSConfig()
        request := &dysmsapi20170525.SendMessageWithTemplateRequest{
            ToNumber:      tea.String("86" + mobile), // 中国区号+手机号
            FromNumber:    tea.String(cfg.SignName),
            TemplateCode:  tea.String(cfg.TemplateCode),
            TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
        }

        runtime := &util.RuntimeOptions{}
        response, err := smsClient.SendMessageWithTemplateWithOptions(request, runtime)
        if err != nil {
            logger.Logger.Error("Failed to send SMS",
                zap.String("mobile", maskMobile(mobile)),
                zap.Error(err))
            return 0, fmt.Errorf("服务暂不可用,请稍后重试")
        }

        if response.Body.ResponseCode != nil && *response.Body.ResponseCode != "OK" {
            logger.Logger.Error("SMS API returned error",
                zap.String("code", *response.Body.ResponseCode),
                zap.String("description", *response.Body.ResponseDescription))
            return 0, fmt.Errorf("发送失败,请稍后重试")
        }

        logger.Logger.Info("SMS sent successfully",
            zap.String("mobile", maskMobile(mobile)),
            zap.String("message_id", tea.StringValue(response.Body.MessageId)))
    }

    // 7. 存储验证码到Redis60秒TTL
    if err := redis.SaveSMSCode(ctx, mobile, code, 60*time.Second); err != nil {
        logger.Logger.Error("Failed to save SMS code to Redis",
            zap.String("mobile", maskMobile(mobile)),
            zap.Error(err))
        return 0, fmt.Errorf("服务暂不可用,请稍后重试")
    }

    // 8. 增加发送计数
    redis.IncrMobileCount(ctx, mobile)
    redis.IncrIPCount(ctx, ip)

    return 60, nil // 返回60秒后可以重发
}

// VerifyCode 验证验证码
func VerifyCode(ctx context.Context, mobile, code string) (string, error) {
    // 1. 获取存储的验证码
    storedData, err := redis.GetSMSCode(ctx, mobile)
    if err != nil {
        return "", fmt.Errorf("验证码已过期,请重新获取")
    }

    // 2. 检查是否已使用
    if storedData.Used {
        return "", fmt.Errorf("验证码已使用,请重新获取")
    }

    // 3. 验证code是否正确
    if storedData.Code != code {
        // 验证失败,递增失败次数
        attempts, _ := redis.IncrementAttempts(ctx, mobile)
        if attempts >= 3 {
            // 失败3次删除验证码
            redis.DeleteSMSCode(ctx, mobile)
            return "", fmt.Errorf("验证失败次数过多,请重新获取")
        }
        remaining := 3 - attempts
        return "", fmt.Errorf("验证码错误,剩余%d次", remaining)
    }

    // 4. 验证成功,删除验证码
    redis.DeleteSMSCode(ctx, mobile)

    // 5. 生成verify_token
    token := generateVerifyToken()

    // 6. 存储verify_token300秒TTL
    if err := redis.SaveVerifyToken(ctx, mobile, token, 300*time.Second); err != nil {
        logger.Logger.Error("Failed to save verify token",
            zap.String("mobile", maskMobile(mobile)),
            zap.Error(err))
        return "", fmt.Errorf("验证服务暂不可用")
    }

    logger.Logger.Info("SMS code verified successfully",
        zap.String("mobile", maskMobile(mobile)))

    return token, nil
}

// VerifyToken 验证verify_token注册时调用
func VerifyToken(ctx context.Context, mobile, token string) error {
    storedToken, err := redis.GetVerifyToken(ctx, mobile)
    if err != nil {
        return fmt.Errorf("验证码已失效,请重新获取")
    }
    if storedToken != token {
        return fmt.Errorf("验证失败,请重新获取验证码")
    }
    // 验证成功后删除token
    redis.DeleteVerifyToken(ctx, mobile)
    return nil
}

// generateVerifyToken 生成verify_token
func generateVerifyToken() string {
    const prefix = "vtf_"
    const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    rand.Seed(time.Now().UnixNano())
    b := make([]byte, 29)
    for i := range b {
        b[i] = chars[rand.Intn(len(chars))]
    }
    return prefix + string(b)
}

// maskMobile 手机号脱敏
func maskMobile(mobile string) string {
    if len(mobile) < 11 {
        return mobile
    }
    return mobile[:3] + "****" + mobile[7:]
}
  • Step 2: Commit
git add backend/services/userService/service/sms_service.go
git commit -m "feat: add SMS service for sending and verifying codes"

Task 5: 在AuthService中集成verify_token校验

Files:

  • Modify: backend/services/userService/service/auth_service.go

  • Step 1: 在Register方法开头添加verify_token校验

Register 方法的参数验证之后、手机号重复检查之前,添加:

// 在Register方法中参数验证之后添加

// 3. 验证明ver_token新增
if req.VerifyToken != "" {
    if err := VerifyToken(ctx, req.Mobile, req.VerifyToken); err != nil {
        logger.Logger.Warn("Verify token validation failed",
            zap.String("mobile", req.Mobile),
            zap.Error(err))
        return nil, fmt.Errorf("invalid verify_token: %w", err)
    }
}

需要修改Register方法签名添加context参数。

  • Step 2: Commit
git add backend/services/userService/service/auth_service.go
git commit -m "feat: add verify_token validation in Register method"

Task 6: 添加Proto定义SendCode/VerifyCode方法

Files:

  • Modify: backend/pkg/proto/user/auth.proto(如果存在)

  • 或查看现有的proto文件结构

  • Step 1: 查看现有proto文件

ls -la /Users/liulujian/Documents/code/TopFansByGithub/backend/pkg/proto/user/
cat /Users/liulujian/Documents/code/TopFansByGithub/backend/pkg/proto/user/auth.proto
  • Step 2: 添加新的RPC方法定义
// 发送验证码请求
message SendCodeRequest {
    string mobile = 1;
    string scene = 2;  // register, password
}

// 发送验证码响应
message SendCodeResponse {
    common.BaseResponse base = 1;
    int32 expires_in = 2;  // 多少秒后可以重发
}

// 验证验证码请求
message VerifyCodeRequest {
    string mobile = 1;
    string code = 2;
    string scene = 3;
}

// 验证验证码响应
message VerifyCodeResponse {
    common.BaseResponse base = 1;
    bool verified = 2;
    string verify_token = 3;
    int32 expires_in = 4;  // token有效期}
  • Step 3: 重新生成pb.go文件

根据项目中的生成脚本生成go文件。


Task 7: 实现AuthProvider的SendCode/VerifyCode

Files:

  • Modify: backend/services/userService/provider/auth_provider.go

  • Step 1: 添加SendCode和VerifyCode处理方法

// SendCode 发送验证码
func (p *AuthProvider) SendCode(ctx context.Context, req *pb.SendCodeRequest) (*pb.SendCodeResponse, error) {
    // 获取客户端IP
    ip := getClientIP(ctx)

    expiresIn, err := service.SendVerificationCode(ctx, req.Mobile, ip)
    if err != nil {
        return &pb.SendCodeResponse{
            Base: &pbCommon.BaseResponse{
                Code:      pbCommon.StatusCode_STATUS_BAD_REQUEST,
                Message:   err.Error(),
                Timestamp: time.Now().UnixMilli(),
            },
        }, nil
    }

    return &pb.SendCodeResponse{
        Base: &pbCommon.BaseResponse{
            Code:      pbCommon.StatusCode_STATUS_OK,
            Message:   "发送成功",
            Timestamp: time.Now().UnixMilli(),
        },
        ExpiresIn: int32(expiresIn),
    }, nil
}

// VerifyCode 验证验证码
func (p *AuthProvider) VerifyCode(ctx context.Context, req *pb.VerifyCodeRequest) (*pb.VerifyCodeResponse, error) {
    token, err := service.VerifyCode(ctx, req.Mobile, req.Code)
    if err != nil {
        return &pb.VerifyCodeResponse{
            Base: &pbCommon.BaseResponse{
                Code:      pbCommon.StatusCode_STATUS_BAD_REQUEST,
                Message:   err.Error(),
                Timestamp: time.Now().UnixMilli(),
            },
            Verified: false,
        }, nil
    }

    return &pb.VerifyCodeResponse{
        Base: &pbCommon.BaseResponse{
            Code:      pbCommon.StatusCode_STATUS_OK,
            Message:   "验证成功",
            Timestamp: time.Now().UnixMilli(),
        },
        Verified:    true,
        VerifyToken: token,
        ExpiresIn:   300,
    }, nil
}
  • Step 2: Commit
git add backend/services/userService/provider/auth_provider.go
git commit -m "feat: implement SendCode and VerifyCode in AuthProvider"

Task 8: 网关层添加HTTP端点

Files:

  • Modify: backend/gateway/controller/auth_controller.go

  • Modify: backend/gateway/dto/auth_dto.go

  • Modify: backend/gateway/router/(路由配置)

  • Step 1: 添加DTO定义

// SendCodeRequest 发送验证码请求
type SendCodeRequest struct {
    Mobile string `json:"mobile" form:"mobile"`
    Scene  string `json:"scene" form:"scene"` // register, password
}

// SendCodeResponse 发送验证码响应
type SendCodeResponse struct {
    Code      int    `json:"code"`
    Message   string `json:"message"`
    ExpiresIn int    `json:"expires_in"`
}

// VerifyCodeRequest 验证验证码请求
type VerifyCodeRequest struct {
    Mobile string `json:"mobile" form:"mobile"`
    Code   string `json:"code" form:"code"`
    Scene  string `json:"scene" form:"scene"`
}

// VerifyCodeResponse 验证验证码响应
type VerifyCodeResponse struct {
    Code        int    `json:"code"`
    Message     string `json:"message"`
    Verified    bool   `json:"verified"`
    VerifyToken string `json:"verify_token"`
    ExpiresIn   int    `json:"expires_in"`
}
  • Step 2: 添加Controller处理函数

在auth_controller.go中添加

// SendCode 发送验证码
func SendCode(c *gin.Context) {
    var req dto.SendCodeRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"code": 400, "message": "参数错误"})
        return
    }

    // 调用Dubbo服务
    resp, err := dubboClient.SendCode(context.Background(), &userPB.SendCodeRequest{
        Mobile: req.Mobile,
        Scene:  req.Scene,
    })
    if err != nil {
        c.JSON(500, gin.H{"code": 500, "message": err.Error()})
        return
    }

    c.JSON(200, gin.H{
        "code":       200,
        "message":    resp.Base.Message,
        "expires_in": resp.ExpiresIn,
    })
}

// VerifyCode 验证验证码
func VerifyCode(c *gin.Context) {
    var req dto.VerifyCodeRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"code": 400, "message": "参数错误"})
        return
    }

    resp, err := dubboClient.VerifyCode(context.Background(), &userPB.VerifyCodeRequest{
        Mobile: req.Mobile,
        Code:   req.Code,
        Scene:  req.Scene,
    })
    if err != nil {
        c.JSON(500, gin.H{"code": 500, "message": err.Error()})
        return
    }

    c.JSON(200, gin.H{
        "code":        200,
        "message":     resp.Base.Message,
        "verified":    resp.Verified,
        "verify_token": resp.VerifyToken,
        "expires_in":  resp.ExpiresIn,
    })
}
  • Step 3: 添加路由

在router中注册新路由

auth := r.Group("/api/v1/auth")
{
    auth.POST("/send-code", SendCode)
    auth.POST("/verify-code", VerifyCode)
}
  • Step 4: Commit
git add backend/gateway/controller/auth_controller.go backend/gateway/dto/auth_dto.go
git commit -m "feat: add send-code and verify-code HTTP endpoints in gateway"

阶段二:前端改动

Task 9: 修改前端register.vue添加短信验证流程

Files:

  • Modify: frontend/pages/register/register.vue

  • Step 1: 添加验证码相关的响应式数据

const form = ref({
    phone: '',
    password: '',
    code: ''  // 新增:验证码
});

const codeStatus = ref('unsent');  // unsent, countdown, resend, verified
const countdown = ref(60);
const codeError = ref('');
const verifyToken = ref('');  // 验证成功后获得的token
const countdownTimer = ref(null);

// 响应式数据
const showCodeInput = ref(false);  // 是否显示验证码输入框
  • Step 2: 添加发送验证码方法
// 发送验证码
const handleSendCode = async () => {
    const phoneValidation = validatePhone(form.value.phone);
    if (!phoneValidation.valid) {
        errorMessage.value = phoneValidation.message;
        return;
    }

    try {
        const res = await sendCodeApi(form.value.phone, 'register');
        if (res.code === 200) {
            // 开始倒计时
            codeStatus.value = 'countdown';
            countdown.value = res.expires_in || 60;
            startCountdown();
            uni.showToast({ title: '验证码已发送', icon: 'success' });
        }
    } catch (error) {
        errorMessage.value = error.message || '发送失败,请重试';
    }
};

// 倒计时
const startCountdown = () => {
    if (countdownTimer.value) {
        clearInterval(countdownTimer.value);
    }
    countdownTimer.value = setInterval(() => {
        countdown.value--;
        if (countdown.value <= 0) {
            clearInterval(countdownTimer.value);
            codeStatus.value = 'resend';
        }
    }, 1000);
};

// 验证验证码
const handleVerifyCode = async () => {
    if (!form.value.code || form.value.code.length !== 6) {
        codeError.value = '请输入6位验证码';
        return;
    }

    try {
        const res = await verifyCodeApi(form.value.phone, form.value.code, 'register');
        if (res.code === 200 && res.data && res.data.verified) {
            verifyToken.value = res.data.verify_token;
            codeStatus.value = 'verified';
            codeError.value = '';
            uni.showToast({ title: '验证成功', icon: 'success' });
        }
    } catch (error) {
        codeError.value = error.message || '验证失败';
    }
};
  • Step 3: 修改模板添加验证码UI

在手机号和密码输入框之间添加:

<!-- 验证码输入框区域 -->
<view v-if="showCodeInput" class="input-wrapper code-wrapper">
    <input
        class="input-field"
        type="number"
        v-model="form.code"
        placeholder="请输入验证码"
        maxlength="6"
        placeholder-class="input-placeholder"
        :disabled="codeStatus === 'verified'"
    />
    <view class="send-code-btn" :class="{ 'countdown': codeStatus === 'countdown' }" @click="handleSendCode">
        <text v-if="codeStatus === 'unsent' || codeStatus === 'resend'">发送验证码</text>
        <text v-else-if="codeStatus === 'countdown'">{{countdown}}秒</text>
        <text v-else-if="codeStatus === 'verified'">✓ 已验证</text>
    </view>
</view>

<!-- 错误提示 -->
<view v-if="codeError" class="error-message">
    <text>{{codeError}}</text>
</view>
  • Step 4: 修改handleRegister方法
const handleRegister = async () => {
    // 检查是否已验证
    if (codeStatus.value !== 'verified') {
        errorMessage.value = '请先完成手机号验证';
        return;
    }

    // ... 原有验证逻辑 ...

    // 存储verify_token
    uni.setStorageSync('temp_register_verify_token', verifyToken.value);
    
    // 跳转到昵称设置页面
    uni.reLaunch({
        url: '/pages/profile/setNickname'
    });
};
  • Step 5: Commit
git add frontend/pages/register/register.vue
git commit -m "feat: add SMS verification flow to register page"

Task 10: 添加前端API方法

Files:

  • Modify: frontend/utils/api.js

  • Step 1: 添加sendCode和verifyCode API

// 发送验证码
export function sendCodeApi(mobile, scene = 'register') {
    return request({
        url: '/api/v1/auth/send-code',
        method: 'POST',
        data: { mobile, scene }
    })
}

// 验证验证码
export function verifyCodeApi(mobile, code, scene = 'register') {
    return request({
        url: '/api/v1/auth/verify-code',
        method: 'POST',
        data: { mobile, code, scene }
    })
}
  • Step 2: Commit
git add frontend/utils/api.js
git commit -m "feat: add send-code and verify-code API methods"

阶段三:数据库与日志

Task 11: 创建短信发送日志表

Files:

  • Create: backend/migrations/xxxx_add_sms_send_log.sql

  • Step 1: 编写迁移SQL

CREATE TABLE IF NOT EXISTS sms_send_log (
    id BIGSERIAL PRIMARY KEY,
    mobile VARCHAR(20) NOT NULL COMMENT '手机号(脱敏存储)',
    scene VARCHAR(20) NOT NULL DEFAULT 'register' COMMENT '使用场景register/password',
    template_code VARCHAR(50) NOT NULL COMMENT '短信模板CODE',
    sign_name VARCHAR(50) NOT NULL COMMENT '短信签名',
    message_id VARCHAR(64) DEFAULT '' COMMENT '阿里云返回的MessageId',
    response_code VARCHAR(20) DEFAULT '' COMMENT '阿里云返回状态码',
    response_description VARCHAR(255) DEFAULT '' COMMENT '阿里云返回描述',
    status SMALLINT NOT NULL DEFAULT 1 COMMENT '发送状态0=失败1=成功',
    send_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sms_send_log_mobile ON sms_send_log(mobile);
CREATE INDEX idx_sms_send_log_scene ON sms_send_log(scene);
CREATE INDEX idx_sms_send_log_send_time ON sms_send_log(send_time);
  • Step 2: Commit
git add backend/migrations/xxxx_add_sms_send_log.sql
git commit -m "feat: add sms_send_log table for SMS sending records"

阶段四:配置与部署

Task 12: 配置检查清单

Files:

  • Modify: deploy/envs/user.env(如果存在)

  • Step 1: 确认环境变量配置

# 阿里云短信配置(直接使用 AccessKey非 STS
SMS_ACCESS_KEY_ID=your_access_key_id
SMS_ACCESS_KEY_SECRET=your_access_key_secret
SMS_SIGN_NAME=TopFans
SMS_TEMPLATE_CODE=SMS_xxxxxxx
SMS_REGION=cn-hangzhou
  • Step 2: 添加Redis环境变量如果尚未配置

确保userService有Redis连接配置

REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=

执行顺序

  1. Task 1: 添加SMS SDK依赖
  2. Task 2: 创建SMS配置
  3. Task 3: 创建Redis操作层
  4. Task 4: 创建SMS服务
  5. Task 5: AuthService集成verify_token校验
  6. Task 6: Proto定义
  7. Task 7: AuthProvider实现
  8. Task 8: 网关HTTP端点
  9. Task 9: 前端register.vue修改
  10. Task 10: 前端API方法
  11. Task 11: 数据库表
  12. Task 12: 配置检查

验证步骤

后端验证

# 1. 启动userService
cd backend/services/userService
go run main.go

# 2. 测试发送验证码
curl -X POST http://localhost:8080/api/v1/auth/send-code \
  -H "Content-Type: application/json" \
  -d '{"mobile":"13800138000","scene":"register"}'

# 3. 测试验证验证码
curl -X POST http://localhost:8080/api/v1/auth/verify-code \
  -H "Content-Type: application/json" \
  -d '{"mobile":"13800138000","code":"123456","scene":"register"}'

# 4. 测试注册带verify_token
curl -X POST http://localhost:8080/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"mobile":"13800138000","verify_token":"vtf_xxx","password":"xxx","nickname":"xxx","star_id":1}'

前端验证

# 1. 启动前端
cd frontend
npm run dev

# 2. 访问注册页面 http://localhost:5173/pages/register/register
# 3. 输入手机号,点击"发送验证码"
# 4. 输入验证码,点击"验证"
# 5. 验证成功后输入密码,点击"注册"

注意事项

  1. 验证码日志脱敏:日志中禁止记录明文验证码,只能记录手机号和发送状态
  2. verify_token安全token有效期5分钟只能使用一次验证后立即删除
  3. 防暴力破解验证失败3次后强制删除验证码要求用户重新获取
  4. 限流保护从手机号和IP两个维度限制请求频率
  5. IP黑名单1小时内触发3次限流的IP临时拉黑30分钟