where('phone', $phone)->where('created_at', '>', date('Y-m-d H:i:s', time() - $interval))->exists()) { throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => $interval]); } $perIp = (int) config('mini_api.sms_per_ip_per_hour', 30); if ($perIp > 0 && MiniSmsCode::query()->where('request_ip', $ip) ->where('created_at', '>', date('Y-m-d H:i:s', time() - 3600))->count() >= $perIp) { throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => 3600]); } $perPhone = (int) config('mini_api.sms_daily_per_phone', 10); if ($perPhone > 0 && MiniSmsCode::query()->where('phone', $phone) ->where('created_at', '>=', date('Y-m-d 00:00:00'))->count() >= $perPhone) { throw new ApiException(42901, '今日验证码发送次数已达上限,请明日再试', 429, ['retry_after' => 86400]); } } /** Returns the plaintext code for delivery. The caller sends it and never persists it. */ public static function issue(string $phone, string $ip): string { $code = (string) random_int(100000, 999999); (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $ip, $code) { self::assertSendAllowed($phone, $ip); $now = date('Y-m-d H:i:s'); MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE) ->whereNull('consumed_at')->update(['consumed_at' => $now]); MiniSmsCode::query()->insert([ 'phone' => $phone, 'purpose' => self::PURPOSE, 'code_hash' => self::hash($phone, $code), 'attempts' => 0, 'expires_at' => date('Y-m-d H:i:s', time() + self::ttl()), 'request_ip' => $ip, 'created_at' => $now, ]); }); self::purge(); return $code; } /** * Consumes the newest live code for the phone in a transaction so two concurrent requests * cannot both redeem it. The outcome is decided inside the transaction but the rejection is * raised only after it commits, otherwise a failed guess would roll back the very attempt * counter that is meant to stop the guessing. */ public static function consume(string $phone, string $code): void { $max = (int) config('mini_api.sms_max_attempts', 5); $outcome = (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $code, $max) { // Never fall back to an older code after the latest code was used or expired. $row = MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE) ->orderByDesc('id')->lockForUpdate()->first(); $now = time(); if (!$row || $row->consumed_at !== null || strtotime((string) $row->expires_at) <= $now || strtotime((string) $row->created_at) + self::ttl() <= $now) { return ['expired', false]; } if (!hash_equals((string) $row->code_hash, self::hash($phone, $code))) { $attempts = (int) $row->attempts + 1; $locked = $attempts >= $max; MiniSmsCode::query()->where('id', $row->id)->update([ 'attempts' => $attempts, // Burning the row on the last miss stops guessing without a separate lock table. 'consumed_at' => $locked ? date('Y-m-d H:i:s') : null, ]); return ['mismatch', $locked]; } MiniSmsCode::query()->where('id', $row->id)->update([ 'attempts' => (int) $row->attempts + 1, 'consumed_at' => date('Y-m-d H:i:s'), ]); return ['ok', false]; }); match ($outcome[0]) { 'ok' => null, 'mismatch' => throw new ApiException(40102, $outcome[1] ? '验证码错误次数过多,请重新获取' : '验证码错误', 401), default => throw new ApiException(40102, '验证码已失效,请重新获取', 401), }; } /** Removes a code that was never delivered, so it cannot be guessed or redeemed later. */ public static function discard(string $phone, string $code): void { MiniSmsCode::query()->where('phone', $phone)->where('code_hash', self::hash($phone, $code))->delete(); } private static function hash(string $phone, string $code): string { // Keyed so an offline attacker holding the table still cannot search a 6-digit space. $key = (string) config('mini_api.sms_code_pepper', '') ?: (string) config('mini_api.sms_password', ''); return hash_hmac('sha256', $code, $key . '|' . $phone . '|' . self::PURPOSE); } /** Opportunistic cleanup keeps the table bounded without needing a scheduled job. */ private static function purge(): void { MiniSmsCode::query()->where('created_at', '<', date('Y-m-d H:i:s', time() - 86400))->delete(); } }