# 注册短信验证码功能实现计划 > **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 - 注册验证码存储在Redis(Hash结构,60秒TTL) - 验证成功后生成verify_token存入Redis(300秒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依赖** ```bash 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: 验证依赖安装** ```bash 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配置结构体** ```go 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** ```bash 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操作函数** ```go 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:" // 验证Token:verify: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客户端实现** ```bash grep -r "go-redis" /Users/liulujian/Documents/code/TopFansByGithub/backend/services/userService/ ``` 如果没有,需要添加依赖并创建Redis客户端初始化代码。 - [ ] **Step 3: Commit** ```bash 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服务** ```go 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. 存储验证码到Redis(60秒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_token(300秒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** ```bash 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` 方法的参数验证之后、手机号重复检查之前,添加: ```go // 在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** ```bash 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文件** ```bash 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方法定义** ```protobuf // 发送验证码请求 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处理方法** ```go // 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** ```bash 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定义** ```go // 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中添加: ```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中注册新路由: ```go auth := r.Group("/api/v1/auth") { auth.POST("/send-code", SendCode) auth.POST("/verify-code", VerifyCode) } ``` - [ ] **Step 4: Commit** ```bash 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: 添加验证码相关的响应式数据** ```javascript 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: 添加发送验证码方法** ```javascript // 发送验证码 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** 在手机号和密码输入框之间添加: ```vue 发送验证码 {{countdown}}秒 ✓ 已验证 {{codeError}} ``` - [ ] **Step 4: 修改handleRegister方法** ```javascript 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** ```bash 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** ```javascript // 发送验证码 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** ```bash 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** ```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** ```bash 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: 确认环境变量配置** ```bash # 阿里云短信配置(直接使用 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连接配置: ```bash 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**: 配置检查 --- ## 验证步骤 ### 后端验证 ```bash # 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}' ``` ### 前端验证 ```bash # 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分钟