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

1140 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 注册短信验证码功能实现计划
> **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依赖**
```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:" // 验证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客户端实现**
```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. 存储验证码到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**
```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
<!-- 验证码输入框区域 -->
<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方法**
```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分钟