首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

ThinkPHP 短信驗證碼註冊完整實作:雲片 SMS、圖形驗證碼防刷、Redis 快取與前端 Vue 整合

     編輯:PHP 綜合知識

業務流程設計

短信驗證碼註冊的核心流程:

  1. 用戶輸入手機號 → 點擊「獲取驗證碼」
  2. 前端驗證圖形驗證碼(防機器人刷接口)
  3. 後端生成 4-6 位隨機碼 → 存入 Redis(設定過期時間)
  4. 呼叫雲片 SMS API 發送短信
  5. 用戶輸入收到的驗證碼 → 後端驗證正確性與時效
  6. 驗證通過 → 完成註冊/登入,清除驗證碼

資料庫設計

-- 短信驗證碼記錄表(可選,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='短信驗證碼記錄';

後端核心實作

1. 配置檔

// 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,
    ],
];

2. 服務類

<?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')]);
    }
}
?>

3. 控制器

<?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);
    }
}
?>

前端整合

Vue 3 Composition API 範例

<!-- 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 批次操作、連線池複用

Redis 原子操作優化(Lua 腳本)

// 發送驗證碼的原子操作:檢查頻率 + 設定驗證碼 + 增加計數
$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 可靠送達 → 原子驗證邏輯 → 前端友善交互。關鍵優勢:

  • 關鍵路徑全程 Redis,毫秒級回應
  • Lua 腳本保證並發安全
  • 分層架構(Service/Controller/View)易維護擴展
  • 完整的錯誤碼體系便於前端差異化提示
  • 審計日誌滿足合規需求

生產環境建議再加入:短信內容模板審核、發送失敗重試機制、多通道降級(阿里雲/騰訊雲備用)、監控告警(發送成功率、延遲、餘額)。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved