1140 lines
33 KiB
Markdown
1140 lines
33 KiB
Markdown
# 注册短信验证码功能实现计划
|
||
|
||
> **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
|
||
<!-- 验证码输入框区域 -->
|
||
<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分钟 |