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

<?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) : '';
}
}