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.
336 lines
13 KiB
336 lines
13 KiB
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Exceptions\ChannelEntryException;
|
|
use App\Models\Application;
|
|
use App\Models\Competition;
|
|
use App\Models\SignupChannel;
|
|
use App\Models\User;
|
|
use App\Support\ChannelEntryEncryption;
|
|
use App\Support\ChannelEntrySignature;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
class ChannelEntryController extends Controller
|
|
{
|
|
public function __invoke(Request $request): Response
|
|
{
|
|
$parameters = $this->parameters($request);
|
|
$context = $this->logContext($request, $parameters);
|
|
|
|
try {
|
|
$this->assertRequiredParameters($parameters);
|
|
$channel = $this->resolveChannel($parameters['channel_code']);
|
|
$context['channel_code'] = $channel->channel_code;
|
|
$context['competition_id'] = $channel->competition_id;
|
|
|
|
$competition = $this->resolveCompetition($channel);
|
|
$this->assertUnambiguousParameters($parameters, $channel);
|
|
$this->assertTimestamp($parameters['timestamp']);
|
|
$this->assertHash($parameters, $channel);
|
|
if ($channel->entry_encryption_enabled) {
|
|
$parameters = $this->decryptSensitiveParameters($parameters, $channel);
|
|
$context['mobile_masked'] = $this->maskMobile($parameters['mobile']);
|
|
}
|
|
$this->assertMobile($parameters['mobile']);
|
|
|
|
[$user, $application, $token] = DB::transaction(function () use ($channel, $competition, $parameters): array {
|
|
$enteredAt = now();
|
|
$user = User::query()->firstOrCreate(
|
|
['mobile' => $parameters['mobile']],
|
|
['name' => null, 'email' => null, 'password' => null]
|
|
);
|
|
|
|
if ($user->wasRecentlyCreated) {
|
|
$user->fill([
|
|
'first_channel_id' => $channel->id,
|
|
'first_channel_code' => $channel->channel_code,
|
|
'first_channel_entered_at' => $enteredAt,
|
|
]);
|
|
}
|
|
|
|
$user->fill([
|
|
'last_channel_id' => $channel->id,
|
|
'last_channel_code' => $channel->channel_code,
|
|
'last_channel_entered_at' => $enteredAt,
|
|
])->save();
|
|
|
|
$application = Application::query()->firstOrCreate(
|
|
[
|
|
'user_id' => $user->id,
|
|
'competition_id' => $competition->id,
|
|
],
|
|
['status' => 'draft']
|
|
);
|
|
$application->fill([
|
|
'signup_channel_id' => $channel->id,
|
|
'signup_channel_code' => $channel->channel_code,
|
|
'signup_channel_state' => $parameters['state'],
|
|
'signup_channel_entered_at' => $enteredAt,
|
|
])->save();
|
|
|
|
$user->tokens()->delete();
|
|
$token = $user->createToken('web')->plainTextToken;
|
|
|
|
return [$user, $application, $token];
|
|
});
|
|
|
|
$context['user_id'] = $user->id;
|
|
$context['application_id'] = $application->id;
|
|
$this->logEntry('success', $context);
|
|
|
|
return $this->secureView('channel.entry-handoff', [
|
|
'token' => $token,
|
|
'user' => $this->handoffUser($user),
|
|
'competitionSlug' => $competition->slug,
|
|
'redirectUrl' => url('/admin/c/'.rawurlencode($competition->slug).'/apply'),
|
|
]);
|
|
} catch (ChannelEntryException $exception) {
|
|
$context['error_code'] = $exception->errorCode;
|
|
$this->logEntry('failed', $context);
|
|
|
|
return $this->secureView('channel.entry-error', [
|
|
'errorCode' => $exception->errorCode,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
} catch (Throwable $exception) {
|
|
$context['error_code'] = 'CHANNEL_ENTRY_FAILED';
|
|
$this->logEntry('failed', $context);
|
|
Log::error('channel.entry exception', [
|
|
'exception' => $exception::class,
|
|
]);
|
|
|
|
return $this->secureView('channel.entry-error', [
|
|
'errorCode' => 'CHANNEL_ENTRY_FAILED',
|
|
'message' => '暂时无法进入报名系统,请从小程序重新进入。',
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string}
|
|
*/
|
|
private function parameters(Request $request): array
|
|
{
|
|
return [
|
|
'channel_code' => $this->trimmed($request->query('channel_code')),
|
|
'timestamp' => $this->trimmed($request->query('timestamp')),
|
|
'name' => $this->trimmed($request->query('name')),
|
|
'mobile' => $this->trimmed($request->query('mobile')),
|
|
'company_name' => $this->trimmed($request->query('company_name')),
|
|
'state' => $this->trimmed($request->query('state')),
|
|
'hash' => $this->trimmed($request->query('hash')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters
|
|
*/
|
|
private function assertRequiredParameters(array $parameters): void
|
|
{
|
|
if ($parameters['channel_code'] === '') {
|
|
throw new ChannelEntryException('CHANNEL_MISSING', '渠道参数无效。');
|
|
}
|
|
|
|
foreach (['timestamp', 'name', 'hash'] as $parameter) {
|
|
if ($parameters[$parameter] === '') {
|
|
throw new ChannelEntryException('PARAM_MISSING', '渠道参数无效。');
|
|
}
|
|
}
|
|
|
|
if ($parameters['mobile'] === '') {
|
|
throw new ChannelEntryException('MOBILE_INVALID', '手机号信息异常。');
|
|
}
|
|
}
|
|
|
|
private function resolveChannel(string $channelCode): SignupChannel
|
|
{
|
|
$channel = SignupChannel::query()
|
|
->with('competition')
|
|
->where('channel_code', $channelCode)
|
|
->first();
|
|
|
|
if ($channel === null) {
|
|
throw new ChannelEntryException('CHANNEL_NOT_FOUND', '渠道不可用。');
|
|
}
|
|
|
|
if ($channel->status !== SignupChannel::STATUS_ENABLED) {
|
|
throw new ChannelEntryException('CHANNEL_DISABLED', '渠道不可用。');
|
|
}
|
|
|
|
return $channel;
|
|
}
|
|
|
|
private function resolveCompetition(SignupChannel $channel): Competition
|
|
{
|
|
$competition = $channel->competition;
|
|
if ($competition === null) {
|
|
throw new ChannelEntryException('COMPETITION_INVALID', '赛事不可报名。');
|
|
}
|
|
|
|
if (in_array($competition->status, ['signup_closed', 'reviewing', 'ended'], true)) {
|
|
throw new ChannelEntryException('SIGNUP_CLOSED', '报名已截止。');
|
|
}
|
|
|
|
if (! $competition->published
|
|
|| ! in_array($competition->status, ['published', 'signup_open'], true)
|
|
|| trim((string) $competition->slug) === '') {
|
|
throw new ChannelEntryException('COMPETITION_INVALID', '赛事不可报名。');
|
|
}
|
|
|
|
if ($competition->signup_open_at !== null && now()->isBefore($competition->signup_open_at)) {
|
|
throw new ChannelEntryException('SIGNUP_NOT_STARTED', '报名尚未开始。');
|
|
}
|
|
|
|
if ($competition->signup_close_at !== null && now()->isAfter($competition->signup_close_at)) {
|
|
throw new ChannelEntryException('SIGNUP_CLOSED', '报名已截止。');
|
|
}
|
|
|
|
return $competition;
|
|
}
|
|
|
|
private function assertTimestamp(string $timestamp): void
|
|
{
|
|
if (! ctype_digit($timestamp)) {
|
|
throw new ChannelEntryException('TIMESTAMP_EXPIRED', '链接已失效,请从小程序重新进入。');
|
|
}
|
|
|
|
$window = max(0, (int) config('channel_entry.timestamp_window_seconds', 300));
|
|
if (abs(time() - (int) $timestamp) > $window) {
|
|
throw new ChannelEntryException('TIMESTAMP_EXPIRED', '链接已失效,请从小程序重新进入。');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters
|
|
*/
|
|
private function assertUnambiguousParameters(array $parameters, SignupChannel $channel): void
|
|
{
|
|
$fields = ['state'];
|
|
if (! $channel->entry_encryption_enabled) {
|
|
$fields[] = 'name';
|
|
$fields[] = 'company_name';
|
|
}
|
|
|
|
foreach ($fields as $field) {
|
|
if (str_contains($parameters[$field], '&') || str_contains($parameters[$field], '=')) {
|
|
throw new ChannelEntryException('HASH_INVALID', '渠道参数无效。');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters
|
|
*/
|
|
private function assertHash(array $parameters, SignupChannel $channel): void
|
|
{
|
|
if (! preg_match('/^[a-f0-9]{64}$/', $parameters['hash'])
|
|
|| ! hash_equals(ChannelEntrySignature::make($parameters, $channel->shared_secret), $parameters['hash'])) {
|
|
throw new ChannelEntryException('HASH_INVALID', '渠道参数无效。');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters
|
|
* @return array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string}
|
|
*/
|
|
private function decryptSensitiveParameters(array $parameters, SignupChannel $channel): array
|
|
{
|
|
try {
|
|
$privateKey = trim((string) $channel->encryption_private_key);
|
|
if ($privateKey === '') {
|
|
throw new \RuntimeException('Channel encryption private key is missing.');
|
|
}
|
|
|
|
$parameters['name'] = ChannelEntryEncryption::decrypt($parameters['name'], $privateKey);
|
|
$parameters['mobile'] = ChannelEntryEncryption::decrypt($parameters['mobile'], $privateKey);
|
|
$parameters['company_name'] = $parameters['company_name'] === ''
|
|
? ''
|
|
: ChannelEntryEncryption::decrypt($parameters['company_name'], $privateKey);
|
|
if ($parameters['name'] === '') {
|
|
throw new \RuntimeException('Channel entry name is missing.');
|
|
}
|
|
} catch (Throwable) {
|
|
throw new ChannelEntryException('ENCRYPTION_INVALID', '渠道参数无效。');
|
|
}
|
|
|
|
return $parameters;
|
|
}
|
|
|
|
private function assertMobile(string $mobile): void
|
|
{
|
|
if (! preg_match('/^1[3-9]\d{9}$/', $mobile)) {
|
|
throw new ChannelEntryException('MOBILE_INVALID', '手机号信息异常。');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function logContext(Request $request, array $parameters): array
|
|
{
|
|
return [
|
|
'channel_code' => $parameters['channel_code'] !== '' ? $parameters['channel_code'] : null,
|
|
'competition_id' => null,
|
|
'mobile_masked' => $this->maskMobile($parameters['mobile']),
|
|
'state' => $parameters['state'] !== '' ? $parameters['state'] : null,
|
|
'user_id' => null,
|
|
'application_id' => null,
|
|
'error_code' => null,
|
|
'client_ip' => $request->ip(),
|
|
'callback_result' => null,
|
|
'created_at' => now()->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function logEntry(string $result, array $context): void
|
|
{
|
|
Log::info('channel.entry', array_merge($context, ['result' => $result]));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private function secureView(string $view, array $data): Response
|
|
{
|
|
return response()
|
|
->view($view, $data)
|
|
->header('Cache-Control', 'private, no-store, no-cache, must-revalidate')
|
|
->header('Pragma', 'no-cache')
|
|
->header('Referrer-Policy', 'no-referrer');
|
|
}
|
|
|
|
/**
|
|
* @return array{id: int, mobile: string, name: ?string, email: ?string}
|
|
*/
|
|
private function handoffUser(User $user): array
|
|
{
|
|
return [
|
|
'id' => $user->id,
|
|
'mobile' => $user->mobile,
|
|
'name' => $user->name,
|
|
'email' => $user->email,
|
|
];
|
|
}
|
|
|
|
private function maskMobile(string $mobile): string
|
|
{
|
|
return preg_match('/^\\d{11}$/', $mobile)
|
|
? substr($mobile, 0, 3).'****'.substr($mobile, -4)
|
|
: '***';
|
|
}
|
|
|
|
private function trimmed(mixed $value): string
|
|
{
|
|
return is_scalar($value) ? trim((string) $value) : '';
|
|
}
|
|
}
|