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.

208 lines
7.4 KiB

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\Competition;
use App\Models\CompetitionPortalLocale;
use App\Models\PublicSourceChannel;
use App\Models\SmsVerification;
use App\Models\User;
use App\Services\Sms\ParticipantSmsVerificationService;
use App\Services\Sms\SmsSender;
use App\Support\PortalLocale;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
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, ParticipantSmsVerificationService $service, SmsSender $sender): JsonResponse
{
$data = $request->validate([
'mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'],
'portal' => ['sometimes', 'nullable', 'in:participant,audience'],
]);
$mobile = $data['mobile'];
$scene = SmsVerification::loginSceneFromPortal(
isset($data['portal']) && is_string($data['portal']) ? $data['portal'] : null
);
Log::info('sms.send hit', [
'mobile_mask' => $this->maskMobile($mobile),
'scene' => $scene,
'ip' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 200),
]);
$result = $service->sendLoginCode($mobile, $sender, $scene);
return response()->json($result->toHttpPayload(), $result->success ? 200 : 503);
}
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'],
'lang' => ['sometimes', 'nullable', 'string', 'max:16'],
]);
$data['public_source_code'] = is_string($request->input('public_source_code'))
? (string) $request->input('public_source_code')
: '';
$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' => ['验证码无效或已过期'],
]);
}
$signupLocale = PortalLocale::ZH;
$signupLocaleLocked = false;
$user = DB::transaction(function () use ($data, $verification, &$signupLocale, &$signupLocaleLocked) {
$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]
);
if ($user->wasRecentlyCreated) {
$this->attributePublicSource($user, (string) ($data['public_source_code'] ?? ''));
}
$requested = PortalLocale::fromRequest(isset($data['lang']) ? (string) $data['lang'] : null);
$application = Application::firstOrCreateDraft($user->id, $competition->id);
$locked = $application->isSubmitted();
$seed = $requested;
if ($locked
&& \Illuminate\Support\Facades\Schema::hasColumn($application->getTable(), 'signup_locale')
&& PortalLocale::isValid((string) $application->signup_locale)) {
$seed = (string) $application->signup_locale;
}
$signupLocale = CompetitionPortalLocale::resolveForLogin(
(int) $user->id,
(int) $competition->id,
PortalLocale::PORTAL_PARTICIPANT,
$seed,
$locked
);
$signupLocaleLocked = $locked;
if (! $locked
&& \Illuminate\Support\Facades\Schema::hasColumn($application->getTable(), 'signup_locale')) {
$application->forceFill(['signup_locale' => $signupLocale])->save();
}
$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',
'signup_locale' => $signupLocale,
'signup_locale_locked' => $signupLocaleLocked,
'user' => [
'id' => $user->id,
'mobile' => $user->mobile,
'name' => $user->name,
'email' => $user->email,
'company' => $user->company,
],
]);
}
private function attributePublicSource(User $user, string $sourceCode): void
{
$sourceCode = trim($sourceCode);
if (! preg_match('/^[A-Za-z0-9_-]{2,64}$/', $sourceCode)) {
return;
}
$channel = PublicSourceChannel::query()
->where('source_code', $sourceCode)
->where('status', PublicSourceChannel::STATUS_ENABLED)
->first();
if ($channel === null) {
return;
}
$user->forceFill([
'public_source_channel_id' => $channel->id,
'public_source_code' => $channel->source_code,
'public_source_attributed_at' => now(),
])->save();
}
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]);
}
}