短信驗證碼註冊的核心流程:
-- 短信驗證碼記錄表(可選,Redis 為主,資料庫為備份/審計)
CREATE TABLE `sms_verification_codes` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`mobile` CHAR(11) NOT NULL COMMENT '手機號',
`code` CHAR(6) NOT NULL COMMENT '驗證碼',
`type` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=註冊 2=登入 3=重設密碼',
`ip` VARCHAR(45) NOT NULL COMMENT '請求 IP',
`status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=待驗證 1=已驗證 2=已過期',
`expired_at` DATETIME NOT NULL COMMENT '過期時間',
`verified_at` DATETIME NULL COMMENT '驗證時間',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_mobile_type` (`mobile`, `type`),
INDEX `idx_expired_at` (`expired_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信驗證碼記錄';
// config/sms.php
return [
'yunpian' => [
'api_key' => env('YUNPIAN_API_KEY', ''),
'api_url' => 'https://sms.yunpian.com/v2/sms/single_send.json',
'sign_name' => '【您的品牌】', // 簽名需在雲片後台審核通過
],
'code' => [
'length' => 4,
'expire_minutes' => 5,
'max_requests_per_hour' => 10, // 同一手機號/小時限制
],
'image_captcha' => [
'length' => 4,
'expire_seconds' => 300,
],
];
<?php
namespace App\Service;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Request;
class SmsService {
private string $apiKey;
private string $apiUrl;
private string $signName;
private int $codeLength;
private int $expireSeconds;
private int $maxPerHour;
public function __construct() {
$this->apiKey = Config::get('sms.yunpian.api_key');
$this->apiUrl = Config::get('sms.yunpian.api_url');
$this->signName = Config::get('sms.yunpian.sign_name');
$this->codeLength = Config::get('sms.code.length', 4);
$this->expireSeconds = Config::get('sms.code.expire_minutes', 5) * 60;
$this->maxPerHour = Config::get('sms.code.max_requests_per_hour', 10);
}
/**
* 發送註冊驗證碼
*/
public function sendRegisterCode(string $mobile, string $captchaId, string $captchaCode): array {
// 1. 驗證圖形驗證碼
if (!$this->verifyImageCaptcha($captchaId, $captchaCode)) {
return ['code' => 1001, 'msg' => '圖形驗證碼錯誤'];
}
// 2. 頻率限制(Redis 計數器)
$rateKey = "sms_rate:{$mobile}";
$count = Cache::get($rateKey, 0);
if ($count >= $this->maxPerHour) {
return ['code' => 1002, 'msg' => '請求過於頻繁,請稍後再試'];
}
// 3. 生成驗證碼
$code = $this->generateCode();
// 4. 存入 Redis(Key: sms_code:{mobile}:{type},值: code,TTL: 過期時間)
$cacheKey = "sms_code:{$mobile}:register";
Cache::set($cacheKey, $code, $this->expireSeconds);
// 5. 呼叫雲片發送
$result = $this->sendSms($mobile, $code);
if ($result['code'] !== 0) {
// 發送失敗,刪除快取
Cache::delete($cacheKey);
return $result;
}
// 6. 更新頻率計數器(1 小時過期)
Cache::inc($rateKey);
if ($count === 0) {
Cache::expire($rateKey, 3600);
}
// 7. 記錄資料庫(異步隊列更佳)
$this->logToDatabase($mobile, $code, 1, Request::ip());
return ['code' => 0, 'msg' => '發送成功'];
}
/**
* 驗證驗證碼
*/
public function verifyCode(string $mobile, string $code, int $type = 1): array {
$cacheKey = "sms_code:{$mobile}:" . ($type === 1 ? 'register' : ($type === 2 ? 'login' : 'reset'));
$storedCode = Cache::get($cacheKey);
if ($storedCode === null) {
return ['code' => 2001, 'msg' => '驗證碼已過期或不存在'];
}
if ($storedCode !== $code) {
return ['code' => 2002, 'msg' => '驗證碼錯誤'];
}
// 驗證成功,刪除快取防止重複使用
Cache::delete($cacheKey);
// 更新資料庫狀態
$this->updateDatabaseStatus($mobile, $code, 1);
return ['code' => 0, 'msg' => '驗證通過'];
}
/**
* 生成隨機數字驗證碼
*/
private function generateCode(): string {
$min = 10 ** ($this->codeLength - 1);
$max = (10 ** $this->codeLength) - 1;
return (string)mt_rand($min, $max);
}
/**
* 呼叫雲片 SMS API
*/
private function sendSms(string $mobile, string $code): array {
$text = "{$this->signName}您的驗證碼是{$code},{$this->expireSeconds / 60}分鐘內有效,請勿洩露給他人。";
$data = [
'apikey' => $this->apiKey,
'mobile' => $mobile,
'text' => $text,
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->apiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$response = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) {
return ['code' => 500, 'msg' => '短信服務暫時不可用'];
}
$result = json_decode($response, true);
if (($result['code'] ?? -1) !== 0) {
return ['code' => 501, 'msg' => '短信發送失敗:' . ($result['msg'] ?? '未知錯誤')];
}
return ['code' => 0, 'msg' => '發送成功', 'sid' => $result['data']['sid'] ?? ''];
}
/**
* 驗證圖形驗證碼
*/
private function verifyImageCaptcha(string $id, string $code): bool {
// ThinkPHP 內建驗證碼類
$captcha = new \think\captcha\Captcha();
return $captcha->check($code, $id);
}
/**
* 記錄資料庫(建議用隊列異步)
*/
private function logToDatabase(string $mobile, string $code, int $type, string $ip): void {
try {
\think\facade\Db::table('sms_verification_codes')->insert([
'mobile' => $mobile,
'code' => $code,
'type' => $type,
'ip' => $ip,
'expired_at' => date('Y-m-d H:i:s', time() + $this->expireSeconds),
'created_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// 僅記錄日誌,不影響主流程
\think\facade\Log::error("短信記錄入庫失敗:{$e->getMessage()}");
}
}
private function updateDatabaseStatus(string $mobile, string $code, int $status): void {
\think\facade\Db::table('sms_verification_codes')
->where('mobile', $mobile)
->where('code', $code)
->where('status', 0)
->update(['status' => $status, 'verified_at' => date('Y-m-d H:i:s')]);
}
}
?>
<?php
namespace App\Controller;
use App\Service\SmsService;
use think\facade\Validate;
class SmsController {
private SmsService $smsService;
public function __construct(SmsService $smsService) {
$this->smsService = $smsService;
}
// 獲取圖形驗證碼
public function getImageCaptcha() {
$config = [
'fontSize' => 30,
'length' => 4,
'useNoise' => false,
'expire' => 300,
];
$captcha = new \think\captcha\Captcha($config);
return $captcha->entry('register'); // 'register' 為驗證碼標識
}
// 發送短信驗證碼
public function sendCode() {
$data = request()->post();
$validate = Validate::rule([
'mobile' => 'require|mobile',
'captcha_id' => 'require',
'captcha_code' => 'require',
])->message([
'mobile.require' => '請輸入手機號',
'mobile.mobile' => '手機號格式不正確',
'captcha_id.require' => '缺少驗證碼標識',
'captcha_code.require' => '請輸入圖形驗證碼',
]);
if (!$validate->check($data)) {
return json(['code' => 400, 'msg' => $validate->getError()]);
}
$result = $this->smsService->sendRegisterCode(
$data['mobile'],
$data['captcha_id'],
$data['captcha_code']
);
return json($result);
}
// 驗證短信驗證碼
public function verifyCode() {
$data = request()->post();
$validate = Validate::rule([
'mobile' => 'require|mobile',
'code' => 'require|length:4',
]);
if (!$validate->check($data)) {
return json(['code' => 400, 'msg' => $validate->getError()]);
}
$result = $this->smsService->verifyCode($data['mobile'], $data['code'], 1);
return json($result);
}
}
?>
<!-- Register.vue -->
<template>
<div class="register-container">
<h2>手機號註冊</h2>
<div class="form-group">
<label>手機號</label>
<input v-model="form.mobile" type="tel" placeholder="請輸入手機號" maxlength="11">
</div>
<div class="form-group captcha-row">
<label>圖形驗證碼</label>
<input v-model="form.captchaCode" type="text" placeholder="請輸入右側驗證碼" maxlength="4">
<img :src="captchaSrc" @click="refreshCaptcha" class="captcha-img" title="點擊刷新">
</div>
<div class="form-group">
<label>短信驗證碼</label>
<input v-model="form.smsCode" type="text" placeholder="請輸入短信驗證碼" maxlength="6">
<button
:class="['btn-sms', { disabled: smsBtnDisabled }]"
@click="sendSmsCode"
:disabled="smsBtnDisabled"
>
{{ smsBtnText }}
</button>
</div>
<div class="form-group">
<label>設定密碼</label>
<input v-model="form.password" type="password" placeholder="6-20 位字元">
</div>
<button class="btn-submit" @click="submitRegister" :disabled="submitting">
{{ submitting ? '註冊中...' : '立即註冊' }}
</button>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const apiBase = '/api/sms'
const form = reactive({
mobile: '',
captchaId: 'register',
captchaCode: '',
smsCode: '',
password: '',
})
const captchaSrc = ref('')
const smsBtnText = ref('獲取驗證碼')
const smsBtnDisabled = ref(false)
const submitting = ref(false)
// 刷新圖形驗證碼
const refreshCaptcha = () => {
captchaSrc.value = `${apiBase}/getImageCaptcha?t=${Date.now()}`
}
// 發送短信驗證碼
const sendSmsCode = async () => {
if (!form.mobile) return alert('請先輸入手機號')
if (!form.captchaCode) return alert('請輸入圖形驗證碼')
if (smsBtnDisabled.value) return
try {
const res = await fetch(`${apiBase}/sendCode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mobile: form.mobile,
captcha_id: form.captchaId,
captcha_code: form.captchaCode,
}),
})
const data = await res.json()
if (data.code === 0) {
startCountdown()
refreshCaptcha() // 發送成功後刷新圖形驗證碼
} else {
alert(data.msg)
refreshCaptcha()
}
} catch (e) {
alert('網路錯誤,請重試')
}
}
// 60 秒倒數計時
const startCountdown = () => {
let seconds = 60
smsBtnDisabled.value = true
const timer = setInterval(() => {
seconds--
smsBtnText.value = `${seconds}s 後重試`
if (seconds <= 0) {
clearInterval(timer)
smsBtnDisabled.value = false
smsBtnText.value = '重新獲取'
}
}, 1000)
}
// 提交註冊
const submitRegister = async () => {
if (!form.mobile || !form.smsCode || !form.password) {
return alert('請填寫完整資訊')
}
submitting.value = true
try {
// 1. 驗證短信碼
const verifyRes = await fetch(`${apiBase}/verifyCode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile: form.mobile, code: form.smsCode }),
})
const verifyData = await verifyRes.json()
if (verifyData.code !== 0) {
return alert(verifyData.msg)
}
// 2. 呼叫註冊 API(略)
// const regRes = await fetch('/api/auth/register', { ... })
alert('註冊成功!')
router.push('/login')
} catch (e) {
alert('註冊失敗,請重試')
} finally {
submitting.value = false
}
}
onMounted(refreshCaptcha)
</script>
<style scoped>
.captcha-row { display: flex; align-items: center; gap: 10px; }
.captcha-img { height: 40px; cursor: pointer; border: 1px solid #ddd; border-radius: 4px; }
.btn-sms { padding: 8px 16px; background: #0d6efd; color: white; border: none; border-radius: 4px; }
.btn-sms.disabled { background: #ccc; cursor: not-allowed; }
.register-container { max-width: 400px; margin: 40px auto; padding: 20px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
.btn-submit { width: 100%; padding: 12px; background: #198754; color: white; border: none; border-radius: 4px; font-size: 16px; }
</style>
| 風險點 | 解決方案 |
|---|---|
| 短信轟炸 | 圖形驗證碼 + IP/手機號頻率限制(Redis 計數器 + 滑動窗口) |
| 驗證碼被暴力破解 | 限制驗證次數(如 5 次失敗鎖定 15 分鐘)、驗證後即時刪除快取 |
| API 金鑰洩露 | 環境變數儲存、伺服器端代理呼叫、定期輪換 API Key |
| 並發競態條件 | Redis Lua 腳本原子操作、或使用 SETNX 分散式鎖 |
| 重放攻擊 | 驗證成功即刪除 Key、單次使用、綁定業務類型 |
| 高併發效能 | 異步隊列發送短信、Redis Pipeline 批次操作、連線池複用 |
// 發送驗證碼的原子操作:檢查頻率 + 設定驗證碼 + 增加計數 $luaScript = <<= maxPerHour then return {0, 'rate_limited'} end redis.call('SET', codeKey, code, 'EX', expireSeconds) redis.call('INCR', rateKey) if current == false then redis.call('EXPIRE', rateKey, 3600) end return {1, 'ok'} LUA; $result = Cache::handler()->eval($luaScript, 2, $rateKey, $cacheKey, $maxPerHour, $expireSeconds, $code);
這套方案涵蓋了短信驗證碼註冊的完整閉環:圖形驗證碼防刷 → Redis 高效快取 → 雲片 SMS 可靠送達 → 原子驗證邏輯 → 前端友善交互。關鍵優勢:
生產環境建議再加入:短信內容模板審核、發送失敗重試機制、多通道降級(阿里雲/騰訊雲備用)、監控告警(發送成功率、延遲、餘額)。