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