跳转至

短信验证码接入设计方案

背景

当前用户登录注册接口仍是 mock 逻辑:

  • POST /v1/users/send-code 固定返回 123456
  • POST /v1/users/login 固定校验 123456

生产环境需要接入阿里云短信发送服务,并使用 Redis 保存验证码和有效期。为了控制短信成本、降低接口被刷风险,发送接口需要支持“有效期内复用”:同一手机号在验证码未过期时再次请求,不重新生成验证码,也不再次调用阿里云短信。

目标

  1. 接入阿里云短信服务,支持真实发送登录验证码。
  2. 验证码写入 Redis,默认 5 分钟有效。
  3. 5 分钟有效期内重复请求同一手机号时复用 Redis 中的验证码,不重发短信。
  4. 登录或注册时从 Redis 校验验证码。
  5. 验证成功后删除验证码,避免同一个验证码被重复使用。
  6. 保留本地开发 mock 模式,避免开发和测试环境依赖真实短信供应商。
  7. 配置、错误码、服务封装为后续注册、找回密码、绑定手机号等场景扩展留出空间。

非目标

  1. 第一阶段不做多短信供应商自动切换。
  2. 第一阶段不做图形验证码、滑块验证等人机校验。
  3. 第一阶段不引入数据库表保存验证码历史。
  4. 第一阶段不改造完整用户体系,只替换现有短信验证码发送与登录校验逻辑。

关键产品规则

验证码有效期

默认有效期为 5 分钟:

SMS_CODE_TTL_SECONDS=300

5 分钟是较常见的短信验证码有效期,能兼顾安全性和用户输入体验。短信偶发延迟、用户切换应用、复制粘贴验证码等场景通常可以覆盖。

有效期内复用

同一手机号、同一业务场景下,只要 Redis 中已有未过期验证码:

  • 不重新生成验证码。
  • 不调用阿里云短信接口。
  • 直接返回成功,并告诉前端剩余有效期。

这意味着用户如果在 5 分钟内多次点击“发送验证码”,系统不会发多条短信,前端应提示用户查看上一条短信。

验证成功后删除

用户提交验证码并校验成功后,立即删除 Redis 中的验证码。这样即使 TTL 还没到,同一个验证码也不能再次使用。

阿里云发送失败不写 Redis

如果阿里云短信发送失败,不能把验证码写入 Redis。否则用户没有收到短信,却会被“5 分钟内不重发”的规则挡住。

主流程

发送验证码

flowchart TD
  A["客户端请求发送验证码"] --> B["校验手机号格式"]
  B --> C["读取 Redis: sms:code:login:{phone}"]
  C --> D{"验证码是否存在且未过期"}
  D -->|是| E["返回 success<br/>reused=true<br/>ttl_seconds=剩余秒数<br/>不调用阿里云"]
  D -->|否| F["生成 6 位验证码"]
  F --> G["调用阿里云 SendSms"]
  G --> H{"发送是否成功"}
  H -->|是| I["写入 Redis 并设置 TTL=300 秒"]
  I --> J["返回 success<br/>reused=false<br/>ttl_seconds=300"]
  H -->|否| K["返回短信发送失败<br/>不写 Redis"]

登录或自动注册

flowchart TD
  A["客户端提交手机号和验证码"] --> B["校验手机号格式"]
  B --> C["读取 Redis: sms:code:login:{phone}"]
  C --> D{"验证码是否存在"}
  D -->|否| E["返回验证码过期或未发送"]
  D -->|是| F["对比用户输入验证码"]
  F --> G{"是否一致"}
  G -->|否| H["返回验证码错误"]
  G -->|是| I["删除 Redis 验证码"]
  I --> J["查找用户"]
  J --> K{"用户是否存在"}
  K -->|否| L["自动注册用户"]
  K -->|是| M["检查用户状态"]
  L --> N["签发 JWT"]
  M --> N

Redis 设计

Key 命名

建议按业务场景区分:

sms:code:{scene}:{phone}

当前登录注册一体场景:

sms:code:login:13800138000

后续可以扩展:

sms:code:register:{phone}
sms:code:reset_password:{phone}
sms:code:bind_phone:{phone}

Value 结构

建议 Redis value 使用 JSON,而不是只存验证码字符串:

{
  "code": "839204",
  "phone": "13800138000",
  "scene": "login",
  "created_at": "2026-05-20T12:00:00+08:00"
}

原因:

  • 方便排查问题。
  • 方便后续增加发送渠道、模板编号、请求来源等字段。
  • 避免未来扩展时改动 key 结构。

TTL

写入 Redis 时使用原子过期写入:

SETEX sms:code:login:{phone} 300 {json_payload}

查询剩余有效期时使用:

TTL sms:code:login:{phone}

并发注意事项

如果两个发送请求几乎同时到达,可能都读到 Redis 为空,然后都调用阿里云。为避免并发重复发送,建议使用短锁:

sms:lock:send:{scene}:{phone}

规则:

  • 获取锁成功的请求负责生成验证码和发短信。
  • 获取锁失败的请求短暂等待后重新读 Redis。
  • 锁 TTL 建议 5 到 10 秒,避免发送进程异常时死锁。

第一阶段如果用户量较小,可以先不加锁;但生产环境建议加。

接口设计

发送验证码

沿用当前接口:

POST /v1/users/send-code

请求:

{
  "phone": "13800138000"
}

生产响应:

{
  "code": 0,
  "msg": "操作成功",
  "data": {
    "reused": false,
    "ttl_seconds": 300
  }
}

有效期内重复请求:

{
  "code": 0,
  "msg": "操作成功",
  "data": {
    "reused": true,
    "ttl_seconds": 246
  }
}

开发环境如开启调试返回验证码:

{
  "code": 0,
  "msg": "操作成功",
  "data": {
    "reused": false,
    "ttl_seconds": 300,
    "debug_code": "839204"
  }
}

生产环境不能返回 debug_code

验证码登录或注册

沿用当前接口:

POST /v1/users/login

请求:

{
  "phone": "13800138000",
  "code": "839204"
}

响应仍保持现有结构:

{
  "code": 0,
  "msg": "操作成功",
  "data": {
    "access_token": "..."
  }
}

服务分层

建议新增短信相关服务目录:

app/services/sms/
  __init__.py
  aliyun_sms_service.py
  verification_code_service.py

AliyunSmsService

职责:

  • 读取阿里云短信配置。
  • 调用阿里云 SendSms
  • 处理阿里云 SDK 异常。
  • 将供应商错误转换成业务侧可理解的结果。

建议对外方法:

async def send_verification_code(phone: str, code: str) -> None:
    ...

VerificationCodeService

职责:

  • 手机号规范化和基础校验。
  • 生成安全随机验证码。
  • 查询 Redis 中是否已有未过期验证码。
  • 控制 5 分钟内复用。
  • 调用短信供应商发送。
  • 写入 Redis 和设置 TTL。
  • 登录时校验验证码。
  • 校验成功后删除 Redis key。

建议对外方法:

async def send_login_code(phone: str) -> SendCodeResult:
    ...

async def verify_login_code(phone: str, code: str) -> None:
    ...

路由层只负责请求响应转换,不直接操作 Redis 或阿里云 SDK。

配置设计

建议新增配置项:

# 短信供应商:mock / aliyun
SMS_PROVIDER=mock

# 验证码规则
SMS_CODE_TTL_SECONDS=300
SMS_CODE_LENGTH=6
SMS_DEBUG_RETURN_CODE=false

# 阿里云短信
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=

说明:

  • 本地开发默认 SMS_PROVIDER=mock,避免真实短信费用。
  • 生产环境使用 SMS_PROVIDER=aliyun
  • SMS_DEBUG_RETURN_CODE 只能在开发环境打开。
  • ALIYUN_SMS_TEMPLATE_CODE 对应阿里云控制台审核通过的短信模板。
  • 模板变量建议统一使用 code,即阿里云模板参数为 {"code":"839204"}

错误码建议

当前已有:

INVALID_CODE = 3001

建议新增:

SMS_CODE_EXPIRED = 3007
SMS_SEND_FAILED = 3008
SMS_NOT_CONFIGURED = 3009
SMS_TOO_FREQUENT = 3010

也可以第一阶段只复用 INVALID_CODESYSTEM_ERROR,但从产品提示和排查角度,建议区分:

场景 建议错误码 用户提示
未发送或已过期 SMS_CODE_EXPIRED 验证码已过期,请重新获取
验证码输入错误 INVALID_CODE 验证码错误
阿里云发送失败 SMS_SEND_FAILED 短信发送失败,请稍后重试
生产配置缺失 SMS_NOT_CONFIGURED 短信服务未配置
防刷限制命中 SMS_TOO_FREQUENT 请求过于频繁,请稍后再试

安全与风控

第一阶段建议至少做到:

  1. 手机号格式校验,只接受合法手机号。
  2. 验证码使用安全随机数生成。
  3. 生产环境不返回验证码明文。
  4. 验证成功后删除验证码。
  5. 阿里云失败不写 Redis。

后续增强项:

  1. 按手机号限制每天发送次数。
  2. 按 IP 限制每分钟或每天发送次数。
  3. 验证码错误次数限制,超过阈值后要求重新获取。
  4. 管理后台或日志记录短信发送失败原因。
  5. 接入图形验证码或滑块,防止批量撞手机号。

建议 Redis key:

sms:rate:phone:{phone}:{yyyyMMdd}
sms:rate:ip:{ip}:{yyyyMMddHHmm}
sms:failures:{scene}:{phone}

前端交互建议

发送成功后,前端开始 300 秒倒计时。

如果响应 reused=true

  • 不提示“已重新发送”。
  • 提示“验证码仍在有效期内,请查看上一条短信”。
  • ttl_seconds 继续倒计时。

如果响应 reused=false

  • 提示“验证码已发送”。
  • ttl_seconds 开始倒计时。

实施步骤

阶段 1:服务封装与 mock 保留

  1. 新增短信配置项。
  2. 新增 VerificationCodeService
  3. 先实现 SMS_PROVIDER=mock
  4. 登录接口改为从 Redis 校验验证码。
  5. 保持本地开发可用。

阶段 2:阿里云短信接入

  1. 引入阿里云短信 SDK。
  2. 新增 AliyunSmsService
  3. 支持生产环境 SMS_PROVIDER=aliyun
  4. 处理阿里云发送失败和配置缺失。
  5. 更新 .env.example

阶段 3:风控增强

  1. 增加手机号每日发送上限。
  2. 增加 IP 维度限流。
  3. 增加验证码错误次数限制。
  4. 必要时增加发送并发锁。

验收标准

  1. 第一次请求 send-code 时,生成验证码并写入 Redis,TTL 为 300 秒。
  2. TTL 未过期时再次请求同一手机号,不调用短信发送服务,返回 reused=true
  3. TTL 过期后再次请求,会生成新验证码并发送。
  4. 登录时验证码错误返回失败。
  5. 登录时验证码过期返回失败。
  6. 登录成功后 Redis 验证码被删除。
  7. 阿里云短信发送失败时 Redis 中没有可用验证码。
  8. 生产环境响应不包含验证码明文。
  9. mock 模式下本地开发可以不依赖阿里云完成登录流程。

待确认问题

  1. 生产环境是否坚持“5 分钟内完全不重发”,还是采用“验证码 5 分钟有效,60 秒后允许重发并刷新验证码”?
  2. 登录注册一体场景是否继续共用 login scene?
  3. 是否需要支持国际手机号,还是只支持中国大陆手机号?
  4. 阿里云短信模板变量名是否确定为 code
  5. 是否需要在管理侧记录短信发送日志,便于排查用户收不到短信的问题?