lh 19 часов назад
Родитель
Сommit
af5e9ffe8d

+ 3 - 0
app/Cache/CacheKeys.php

@@ -52,5 +52,8 @@ class CacheKeys
             'sms_code'  => 'dy_sms_%s_%s',        // channelId phone
             'with_draw' => 'dy_withdraw_cash:%s', // date('Ymd')
         ],
+        'captcha'   => [
+            'code' => 'captcha_code_%s', // %s为验证码key
+        ],
     ];
 }

+ 46 - 0
app/Cache/CaptchaCache.php

@@ -0,0 +1,46 @@
+<?php
+
+
+namespace App\Cache;
+
+
+use App\Libs\Utils;
+use Illuminate\Support\Facades\Redis;
+
+class CaptchaCache
+{
+    /**
+     * 写入验证码答案
+     *
+     * @param string $key
+     * @param string $code
+     * @param int    $ttl 有效期(秒)
+     * @return void
+     */
+    public static function set(string $key, string $code, int $ttl)
+    {
+        $cacheKey = Utils::getCacheKey('captcha.code', [$key]);
+        Redis::setex($cacheKey, $ttl, strtolower($code));
+    }
+
+    /**
+     * 取出并立即删除验证码答案(一次性消费,防止同一答案被重复使用)
+     *
+     * @param string $key
+     * @return string 不存在或已过期时返回空字符串
+     */
+    public static function pull(string $key): string
+    {
+        if ($key === '') {
+            return '';
+        }
+
+        $cacheKey = Utils::getCacheKey('captcha.code', [$key]);
+        $code     = (string)Redis::get($cacheKey);
+        if ($code !== '') {
+            Redis::del($cacheKey);
+        }
+
+        return $code;
+    }
+}

+ 13 - 1
app/Http/Controllers/Account/AccountController.php

@@ -8,6 +8,7 @@ use App\Exceptions\ApiException;
 use App\Libs\ApiResponse;
 use App\Libs\Utils;
 use App\Services\Account\AccountService;
+use App\Services\Account\CaptchaService;
 use App\Transformer\Account\AccountTransformer;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
@@ -18,11 +19,14 @@ class AccountController extends BaseController
 {
     use ApiResponse;
     protected $accountService;
+    protected $captchaService;
 
     public function __construct(
-        AccountService $accountService
+        AccountService $accountService,
+        CaptchaService $captchaService
     ) {
         $this->accountService = $accountService;
+        $this->captchaService = $captchaService;
     }
 
     public function index(Request $request)
@@ -69,6 +73,14 @@ class AccountController extends BaseController
             Utils::throwError(ErrorConst::PARAM_ERROR_CODE);
         }
 
+        // 图片验证码校验(config('captcha.enabled') 开启时生效)
+        if (config('captcha.enabled')) {
+            $this->captchaService->verify(
+                (string)getProp($all, 'captcha_key'),
+                (string)getProp($all, 'captcha_code')
+            );
+        }
+
         // 登录
         $user = $this->accountService->login($account, $passwd);
         return $this->success($user);

+ 34 - 0
app/Http/Controllers/Account/CaptchaController.php

@@ -0,0 +1,34 @@
+<?php
+
+
+namespace App\Http\Controllers\Account;
+
+
+use App\Libs\ApiResponse;
+use App\Services\Account\CaptchaService;
+use Illuminate\Routing\Controller as BaseController;
+
+class CaptchaController extends BaseController
+{
+    use ApiResponse;
+
+    protected $captchaService;
+
+    public function __construct(
+        CaptchaService $captchaService
+    ) {
+        $this->captchaService = $captchaService;
+    }
+
+    /**
+     * 获取登录图片验证码(免登录接口)
+     *
+     * 返回 { key, image },image 为 data:image/png;base64,... 可直接用于 img src
+     *
+     * @return mixed
+     */
+    public function getCaptcha()
+    {
+        return $this->success($this->captchaService->generate());
+    }
+}

+ 5 - 0
app/Providers/RouteServiceProvider.php

@@ -59,5 +59,10 @@ class RouteServiceProvider extends ServiceProvider
         RateLimiter::for('api', function (Request $request) {
             return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
         });
+
+        // 登录图片验证码:免登录接口,按 IP 限流防止被刷图
+        RateLimiter::for('captcha', function (Request $request) {
+            return Limit::perMinute(20)->by($request->ip());
+        });
     }
 }

+ 174 - 0
app/Services/Account/CaptchaService.php

@@ -0,0 +1,174 @@
+<?php
+
+
+namespace App\Services\Account;
+
+
+use App\Cache\CaptchaCache;
+use App\Consts\ErrorConst;
+use App\Exceptions\ApiException;
+use App\Libs\Utils;
+
+class CaptchaService
+{
+    /**
+     * 生成验证码:返回随机 key 与 base64 图片
+     *
+     * 答案只保存在服务端(Redis),返回给前端的仅是一个随机 key。
+     *
+     * @return array
+     */
+    public function generate(): array
+    {
+        $code = $this->randomCode((int)config('captcha.length', 4));
+        $key  = md5(uniqid((string)mt_rand(), true));
+
+        CaptchaCache::set($key, $code, (int)config('captcha.ttl', 300));
+
+        return [
+            'key'   => $key,
+            'image' => 'data:image/png;base64,' . base64_encode($this->render($code)),
+        ];
+    }
+
+    /**
+     * 校验验证码
+     *
+     * 无论校验成功或失败,答案都会被消费掉(不复用),避免重放与暴力试错。
+     *
+     * @param string $key  验证码 key
+     * @param string $code 用户输入的验证码
+     * @return void
+     * @throws ApiException
+     */
+    public function verify(string $key, string $code): void
+    {
+        $key  = trim($key);
+        $code = trim($code);
+
+        if ($key === '' || $code === '') {
+            Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
+        }
+
+        // 一次性取出,不存在或已过期都返回空
+        $expected = CaptchaCache::pull($key);
+        if ($expected === '') {
+            Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
+        }
+
+        $input = config('captcha.case_insensitive', true) ? strtolower($code) : $code;
+        if (!hash_equals($expected, $input)) {
+            Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
+        }
+    }
+
+    /**
+     * 生成随机验证码
+     *
+     * @param int $length
+     * @return string
+     */
+    private function randomCode(int $length): string
+    {
+        $charset = (string)config('captcha.charset', 'ABCDEFGHJKMNPQRSTUVWXY3456789');
+        $max     = strlen($charset) - 1;
+
+        $code = '';
+        for ($i = 0; $i < $length; $i++) {
+            $code .= $charset[mt_rand(0, $max)];
+        }
+
+        return $code;
+    }
+
+    /**
+     * 渲染验证码图片,返回 PNG 二进制内容
+     *
+     * 线上 GD 未启用 FreeType,imagettftext 不可用,这里使用内置字体 imagestring;
+     * 内置字体最大仅 5 号(约 9x15),因此先按 1/2 尺寸绘制再整体放大。
+     *
+     * @param string $code
+     * @return string
+     */
+    private function render(string $code): string
+    {
+        $width  = max(60, (int)config('captcha.width', 132));
+        $height = max(24, (int)config('captcha.height', 48));
+
+        $scale = 2;
+        $fw    = (int)($width / $scale);
+        $fh    = (int)($height / $scale);
+
+        $img = imagecreatetruecolor($fw, $fh);
+        imagefill($img, 0, 0, imagecolorallocate($img, 245, 247, 250));
+
+        $font  = 5;
+        $charH = imagefontheight($font);
+        $len   = strlen($code);
+        $step  = (int)(($fw - 4) / max(1, $len));
+
+        for ($i = 0; $i < $len; $i++) {
+            $color = imagecolorallocate($img, mt_rand(20, 110), mt_rand(20, 110), mt_rand(20, 110));
+            $x     = 2 + $i * $step + mt_rand(0, 2);
+            $y     = max(0, (int)(($fh - $charH) / 2) + mt_rand(-3, 3));
+
+            imagestring($img, $font, $x, $y, $code[$i], $color);
+        }
+
+        $this->drawNoise($img, $fw, $fh);
+
+        // 放大输出(点阵放大后带锯齿感,对简单 OCR 也有一定干扰)
+        $out = imagecreatetruecolor($width, $height);
+        imagecopyresized($out, $img, 0, 0, 0, 0, $width, $height, $fw, $fh);
+        imagedestroy($img);
+
+        // 放大后再叠加干扰线,弱化点阵规律
+        for ($i = 0; $i < 3; $i++) {
+            imageline(
+                $out,
+                mt_rand(0, $width),
+                mt_rand(0, $height),
+                mt_rand(0, $width),
+                mt_rand(0, $height),
+                imagecolorallocate($out, mt_rand(150, 215), mt_rand(150, 215), mt_rand(150, 215))
+            );
+        }
+
+        ob_start();
+        imagepng($out);
+        imagedestroy($out);
+
+        return (string)ob_get_clean();
+    }
+
+    /**
+     * 绘制干扰线与噪点
+     *
+     * @param resource $img
+     * @param int      $w
+     * @param int      $h
+     * @return void
+     */
+    private function drawNoise($img, int $w, int $h): void
+    {
+        for ($i = 0; $i < 4; $i++) {
+            imageline(
+                $img,
+                mt_rand(0, $w),
+                mt_rand(0, $h),
+                mt_rand(0, $w),
+                mt_rand(0, $h),
+                imagecolorallocate($img, mt_rand(160, 220), mt_rand(160, 220), mt_rand(160, 220))
+            );
+        }
+
+        for ($i = 0; $i < 60; $i++) {
+            imagesetpixel(
+                $img,
+                mt_rand(0, max(0, $w - 1)),
+                mt_rand(0, max(0, $h - 1)),
+                imagecolorallocate($img, mt_rand(120, 200), mt_rand(120, 200), mt_rand(120, 200))
+            );
+        }
+    }
+}

+ 34 - 0
config/captcha.php

@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * 登录图片验证码配置
+ *
+ * 说明:当前线上 PHP 未启用 FreeType(gd_info 中无 FreeType Support),
+ *      因此字符使用 GD 内置点阵字体 imagestring 渲染,不依赖 TTF 字体文件。
+ *      若后续补上 FreeType,只需替换 CaptchaService::render() 一个方法。
+ */
+return [
+
+    /*
+    | 总开关:false 时登录接口不校验验证码。
+    | 上线顺序建议:后端先部署(保持 false) → 前端发布带验证码的版本 → 再置为 true。
+    */
+    'enabled' => (bool)env('CAPTCHA_ENABLED', false),
+
+    // 验证码字符数
+    'length' => 4,
+
+    // 字符集(已剔除 0/O/o、1/l/I 等易混淆字符)
+    'charset' => 'ABCDEFGHJKMNPQRSTUVWXY3456789',
+
+    // 答案有效期(秒)
+    'ttl' => 300,
+
+    // 输出图片尺寸(内部按 1/2 尺寸绘制内置字体后再放大)
+    'width'  => 132,
+    'height' => 48,
+
+    // 是否大小写不敏感
+    'case_insensitive' => true,
+
+];

+ 2 - 0
routes/api.php

@@ -1,6 +1,7 @@
 <?php
 
 use App\Http\Controllers\Account\AccountController;
+use App\Http\Controllers\Account\CaptchaController;
 use App\Http\Controllers\DeepSeek\DeepSeekController;
 use App\Http\Controllers\Book\BookController;
 use App\Http\Controllers\Timbre\TimbreController;
@@ -332,6 +333,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
     
 });
 
+Route::get('captcha', [CaptchaController::class, 'getCaptcha'])->middleware('throttle:captcha'); // 登录图片验证码
 Route::get('login', [AccountController::class, 'login']); // 登录
 Route::get('logout', [AccountController::class, 'logout']); // 退出