# 注册短信验证码功能实现计划
> **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分钟