You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

287 lines
10 KiB

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\Competition;
use App\Models\SmsVerification;
use App\Models\User;
use App\Services\Sms\SmsSender;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class AuthSmsController extends Controller
{
/** 日志中脱敏手机号 */
private function maskMobile(string $mobile): string
{
if (strlen($mobile) < 11) {
return '***';
}
return substr($mobile, 0, 3).'****'.substr($mobile, -4);
}
public function send(Request $request, SmsSender $sender): JsonResponse
{
$data = $request->validate([
'mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'],
]);
$mobile = $data['mobile'];
Log::info('sms.send hit', [
'mobile_mask' => $this->maskMobile($mobile),
'ip' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 200),
]);
$resendSec = (int) config('sms.resend_interval_seconds', 60);
$rateKey = 'sms-send:'.hash('sha256', $mobile);
if (RateLimiter::tooManyAttempts($rateKey, 1)) {
Log::warning('sms.send rate_limited', [
'mobile_mask' => $this->maskMobile($mobile),
'rate_limiter_available_in' => RateLimiter::availableIn($rateKey),
'resend_interval_seconds' => $resendSec,
]);
throw ValidationException::withMessages([
'mobile' => ['请勿频繁发送'],
]);
}
$lastAttempt = SmsVerification::query()
->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN)
->where('mobile', $mobile)
->latest('id')
->first();
if ($lastAttempt?->created_at !== null && $lastAttempt->created_at->gt(now()->subSeconds($resendSec))) {
Log::warning('sms.send rate_limited', [
'mobile_mask' => $this->maskMobile($mobile),
'last_attempt_id' => $lastAttempt->id,
'last_attempt_status' => $lastAttempt->status,
'last_attempt_at' => $lastAttempt->created_at?->toDateTimeString(),
'resend_interval_seconds' => $resendSec,
]);
throw ValidationException::withMessages([
'mobile' => ['请勿频繁发送'],
]);
}
RateLimiter::hit($rateKey, $resendSec);
$code = (string) random_int(100000, 999999);
$smsEnabled = (bool) config('sms.enabled');
$ttlSeconds = (int) config('sms.code_ttl_seconds', 300);
$ttlMinutes = (string) (int) ceil($ttlSeconds / 60);
Log::info('sms.send branch', [
'mobile_mask' => $this->maskMobile($mobile),
'sms_enabled' => (bool) config('sms.enabled'),
]);
$verification = SmsVerification::query()->create([
'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN,
'mobile' => $mobile,
'code' => $code,
'provider' => $smsEnabled
? SmsVerification::PROVIDER_TENCENTCLOUD
: SmsVerification::PROVIDER_DISABLED,
'status' => SmsVerification::STATUS_PENDING,
'template_id' => (string) config('sms.tencentcloud.template_id'),
'sign_name' => (string) config('sms.tencentcloud.sign_name'),
'request_payload_json' => [
'phone_number' => $this->maskMobile($mobile),
'template_id' => (string) config('sms.tencentcloud.template_id'),
'sign_name' => (string) config('sms.tencentcloud.sign_name'),
'template_param_count' => (int) config('sms.tencentcloud.template_param_count', 2),
'ttl_minutes' => $ttlMinutes,
],
'expires_at' => now()->addSeconds($ttlSeconds),
]);
try {
$result = $sender->sendVerification($verification);
} catch (\Throwable $e) {
$verification->forceFill([
'status' => SmsVerification::STATUS_FAILED,
'failed_at' => now(),
'provider_code' => 'SENDER_EXCEPTION',
'provider_message' => $e->getMessage(),
'response_json' => ['error' => 'sender_exception'],
])->save();
Log::error('sms.send sender_exception', [
'mobile_mask' => $this->maskMobile($mobile),
'verification_id' => $verification->id,
'error' => $e->getMessage(),
]);
return response()->json([
'message' => '发送失败',
], 503);
}
if (! $result->success) {
$verification->forceFill([
'status' => SmsVerification::STATUS_FAILED,
'failed_at' => now(),
'provider_request_id' => $result->requestId,
'provider_code' => $result->providerCode,
'provider_message' => $result->providerMessage,
'response_json' => $result->response,
])->save();
Log::error('sms.send failed', [
'mobile_mask' => $this->maskMobile($mobile),
'verification_id' => $verification->id,
'provider' => $result->provider,
'provider_request_id' => $result->requestId,
'provider_code' => $result->providerCode,
'provider_message' => $result->providerMessage,
]);
return response()->json([
'message' => $result->providerCode === 'CONFIG_INCOMPLETE'
? '短信服务配置未完成'
: '发送失败',
], 503);
}
DB::transaction(function () use ($verification, $result, $mobile) {
$verification->forceFill([
'status' => SmsVerification::STATUS_SENT,
'sent_at' => now(),
'provider_request_id' => $result->requestId,
'provider_code' => $result->providerCode,
'provider_message' => $result->providerMessage,
'response_json' => $result->response,
])->save();
$this->expirePreviousVerifications($mobile, $verification->id);
});
Log::info('sms.send sent', [
'mobile_mask' => $this->maskMobile($mobile),
'verification_id' => $verification->id,
'provider' => $result->provider,
'provider_request_id' => $result->requestId,
'provider_code' => $result->providerCode,
]);
$payload = [
'message' => '发送成功',
];
if (! $smsEnabled && app()->environment('local', 'testing')) {
$payload['debug_code'] = $code;
}
return response()->json($payload);
}
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'],
'code' => ['required', 'string', 'max:64'],
'competition_slug' => ['required', 'string', 'max:64'],
]);
$verification = SmsVerification::query()
->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN)
->where('mobile', $data['mobile'])
->where('status', SmsVerification::STATUS_SENT)
->where('expires_at', '>', now())
->latest('id')
->first();
if ($verification === null || $verification->code !== $data['code']) {
throw ValidationException::withMessages([
'code' => ['验证码无效或已过期'],
]);
}
$user = DB::transaction(function () use ($data, $verification) {
$verification = SmsVerification::query()
->whereKey($verification->id)
->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN)
->where('mobile', $data['mobile'])
->where('status', SmsVerification::STATUS_SENT)
->where('expires_at', '>', now())
->lockForUpdate()
->first();
if ($verification === null || $verification->code !== $data['code']) {
throw ValidationException::withMessages([
'code' => ['验证码无效或已过期'],
]);
}
$competition = Competition::query()
->where('slug', $data['competition_slug'])
->where('published', true)
->first();
if ($competition === null) {
throw ValidationException::withMessages([
'competition_slug' => ['赛事不存在或未发布'],
]);
}
$user = User::query()->firstOrCreate(
['mobile' => $data['mobile']],
['name' => null, 'email' => null, 'password' => null]
);
Application::query()->firstOrCreate(
[
'user_id' => $user->id,
'competition_id' => $competition->id,
],
['status' => 'draft']
);
$verification->forceFill([
'status' => SmsVerification::STATUS_USED,
'used_at' => now(),
])->save();
$this->expirePreviousVerifications($data['mobile'], $verification->id);
return $user;
});
$user->tokens()->delete();
$token = $user->createToken('web')->plainTextToken;
return response()->json([
'token' => $token,
'token_type' => 'Bearer',
'user' => [
'id' => $user->id,
'mobile' => $user->mobile,
'name' => $user->name,
'email' => $user->email,
],
]);
}
private function expirePreviousVerifications(string $mobile, int $currentId): void
{
SmsVerification::query()
->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN)
->where('mobile', $mobile)
->where('id', '<>', $currentId)
->whereIn('status', [
SmsVerification::STATUS_PENDING,
SmsVerification::STATUS_SENT,
])
->update(['status' => SmsVerification::STATUS_EXPIRED]);
}
}