diff --git a/app/Http/Controllers/Api/Admin/AdminMenuController.php b/app/Http/Controllers/Api/Admin/AdminMenuController.php index ef62282..ac7201a 100644 --- a/app/Http/Controllers/Api/Admin/AdminMenuController.php +++ b/app/Http/Controllers/Api/Admin/AdminMenuController.php @@ -23,6 +23,12 @@ class AdminMenuController extends Controller 'title' => '赛事列表', 'permissionCode' => 'competition.read', ], + [ + 'name' => 'admin-public-source-channels', + 'path' => 'public-source-channels', + 'title' => '公开来源渠道', + 'permissionCode' => 'competition.read', + ], ['section' => '评审管理'], [ 'name' => 'admin-reviewers-list', diff --git a/app/Http/Controllers/Api/Admin/PublicSourceChannelController.php b/app/Http/Controllers/Api/Admin/PublicSourceChannelController.php new file mode 100644 index 0000000..7b86fa2 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/PublicSourceChannelController.php @@ -0,0 +1,116 @@ +orderByDesc('id') + ->get() + ->map(fn (PublicSourceChannel $channel) => $this->toRow($channel, $request)) + ->values(); + + return response()->json(['data' => $channels]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'source_code' => [ + 'required', + 'string', + 'min:2', + 'max:64', + 'regex:/^[A-Za-z0-9_-]{2,64}$/', + Rule::unique('public_source_channels', 'source_code'), + ], + 'source_name' => ['required', 'string', 'min:1', 'max:100'], + 'status' => ['sometimes', Rule::in(PublicSourceChannel::STATUSES)], + ]); + + $channel = PublicSourceChannel::query()->create([ + 'source_code' => trim($data['source_code']), + 'source_name' => trim($data['source_name']), + 'status' => $data['status'] ?? PublicSourceChannel::STATUS_ENABLED, + ]); + + return response()->json($this->toRow($channel, $request), 201); + } + + public function show(Request $request, PublicSourceChannel $publicSourceChannel): JsonResponse + { + return response()->json($this->toRow($publicSourceChannel, $request)); + } + + public function update(Request $request, PublicSourceChannel $publicSourceChannel): JsonResponse + { + $data = $request->validate([ + 'source_code' => [ + 'sometimes', + 'string', + 'min:2', + 'max:64', + 'regex:/^[A-Za-z0-9_-]{2,64}$/', + Rule::unique('public_source_channels', 'source_code')->ignore($publicSourceChannel->id), + ], + 'source_name' => ['sometimes', 'string', 'min:1', 'max:100'], + 'status' => ['sometimes', Rule::in(PublicSourceChannel::STATUSES)], + ]); + + if (array_key_exists('source_code', $data)) { + $publicSourceChannel->source_code = trim($data['source_code']); + } + if (array_key_exists('source_name', $data)) { + $publicSourceChannel->source_name = trim($data['source_name']); + } + if (array_key_exists('status', $data)) { + $publicSourceChannel->status = $data['status']; + } + $publicSourceChannel->save(); + + return response()->json($this->toRow($publicSourceChannel, $request)); + } + + public function destroy(PublicSourceChannel $publicSourceChannel): Response + { + $publicSourceChannel->status = PublicSourceChannel::STATUS_DISABLED; + $publicSourceChannel->save(); + + return response()->noContent(); + } + + /** + * @return array + */ + private function toRow(PublicSourceChannel $channel, Request $request): array + { + return [ + 'id' => $channel->id, + 'source_code' => $channel->source_code, + 'source_name' => $channel->source_name, + 'status' => $channel->status, + 'entry_url' => $this->entryUrl($channel, $request), + 'created_at' => $channel->created_at?->toIso8601String(), + 'updated_at' => $channel->updated_at?->toIso8601String(), + ]; + } + + private function entryUrl(PublicSourceChannel $channel, Request $request): string + { + $base = trim((string) config('public_source.frontend_url')); + if ($base === '') { + $base = $request->getSchemeAndHttpHost(); + } + + return rtrim($base, '/').'/?src='.rawurlencode($channel->source_code); + } +} diff --git a/app/Http/Controllers/Api/AuthSmsController.php b/app/Http/Controllers/Api/AuthSmsController.php index 53ae997..18d7dab 100644 --- a/app/Http/Controllers/Api/AuthSmsController.php +++ b/app/Http/Controllers/Api/AuthSmsController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\Competition; +use App\Models\PublicSourceChannel; use App\Models\SmsVerification; use App\Models\User; use App\Services\Sms\ParticipantSmsVerificationService; @@ -53,6 +54,9 @@ class AuthSmsController extends Controller 'code' => ['required', 'string', 'max:64'], 'competition_slug' => ['required', 'string', 'max:64'], ]); + $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) @@ -100,6 +104,10 @@ class AuthSmsController extends Controller ['name' => null, 'email' => null, 'password' => null] ); + if ($user->wasRecentlyCreated) { + $this->attributePublicSource($user, (string) ($data['public_source_code'] ?? '')); + } + Application::query()->firstOrCreate( [ 'user_id' => $user->id, @@ -134,6 +142,29 @@ class AuthSmsController extends Controller ]); } + 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() diff --git a/app/Http/Controllers/Api/PublicSourceChannelController.php b/app/Http/Controllers/Api/PublicSourceChannelController.php new file mode 100644 index 0000000..a832e3f --- /dev/null +++ b/app/Http/Controllers/Api/PublicSourceChannelController.php @@ -0,0 +1,34 @@ +query('source_code', '')); + if (! preg_match('/^[A-Za-z0-9_-]{2,64}$/', $sourceCode)) { + return response()->json(['valid' => false]); + } + + $channel = PublicSourceChannel::query() + ->where('source_code', $sourceCode) + ->where('status', PublicSourceChannel::STATUS_ENABLED) + ->first(); + + if ($channel === null) { + return response()->json(['valid' => false]); + } + + return response()->json([ + 'valid' => true, + 'source_code' => $channel->source_code, + 'source_name' => $channel->source_name, + ]); + } +} diff --git a/app/Models/PublicSourceChannel.php b/app/Models/PublicSourceChannel.php new file mode 100644 index 0000000..42a1fb3 --- /dev/null +++ b/app/Models/PublicSourceChannel.php @@ -0,0 +1,37 @@ +hasMany(User::class, 'public_source_channel_id'); + } + + public function isEnabled(): bool + { + return $this->status === self::STATUS_ENABLED; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ffb1da6..d7ca88e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -31,6 +31,9 @@ class User extends Authenticatable 'last_channel_id', 'last_channel_code', 'last_channel_entered_at', + 'public_source_channel_id', + 'public_source_code', + 'public_source_attributed_at', ]; public function application() @@ -53,6 +56,11 @@ class User extends Authenticatable return $this->belongsTo(SignupChannel::class, 'last_channel_id'); } + public function publicSourceChannel(): BelongsTo + { + return $this->belongsTo(PublicSourceChannel::class, 'public_source_channel_id'); + } + /** * The attributes that should be hidden for serialization. * @@ -73,5 +81,6 @@ class User extends Authenticatable 'password' => 'hashed', 'first_channel_entered_at' => 'datetime', 'last_channel_entered_at' => 'datetime', + 'public_source_attributed_at' => 'datetime', ]; } diff --git a/config/public_source.php b/config/public_source.php new file mode 100644 index 0000000..42c175a --- /dev/null +++ b/config/public_source.php @@ -0,0 +1,5 @@ + env('PUBLIC_FRONTEND_URL'), +]; diff --git a/database/migrations/2026_06_07_100000_create_public_source_channels_table.php b/database/migrations/2026_06_07_100000_create_public_source_channels_table.php new file mode 100644 index 0000000..224a4d7 --- /dev/null +++ b/database/migrations/2026_06_07_100000_create_public_source_channels_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('source_code', 64)->unique(); + $table->string('source_name', 100); + $table->string('status')->default(PublicSourceChannel::STATUS_ENABLED); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('public_source_channels'); + } +}; diff --git a/database/migrations/2026_06_07_101000_add_public_source_to_users_table.php b/database/migrations/2026_06_07_101000_add_public_source_to_users_table.php new file mode 100644 index 0000000..52429a5 --- /dev/null +++ b/database/migrations/2026_06_07_101000_add_public_source_to_users_table.php @@ -0,0 +1,33 @@ +foreignId('public_source_channel_id') + ->nullable() + ->after('last_channel_entered_at') + ->constrained('public_source_channels') + ->nullOnDelete(); + $table->string('public_source_code', 64)->nullable()->after('public_source_channel_id'); + $table->timestamp('public_source_attributed_at')->nullable()->after('public_source_code'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['public_source_channel_id']); + $table->dropColumn([ + 'public_source_channel_id', + 'public_source_code', + 'public_source_attributed_at', + ]); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index a6476ff..81c296b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Admin\AuthController as AdminAuthController; use App\Http\Controllers\Api\Admin\CompetitionController as AdminCompetitionController; use App\Http\Controllers\Api\Admin\CompetitionTrackController as AdminCompetitionTrackController; use App\Http\Controllers\Api\Admin\FormSchemaDefinitionController as AdminFormSchemaDefinitionController; +use App\Http\Controllers\Api\Admin\PublicSourceChannelController as AdminPublicSourceChannelController; use App\Http\Controllers\Api\Admin\ReviewerController as AdminReviewerController; use App\Http\Controllers\Api\Admin\ReviewerScopeController as AdminReviewerScopeController; use App\Http\Controllers\Api\Admin\SignupChannelController as AdminSignupChannelController; @@ -13,6 +14,7 @@ use App\Http\Controllers\Api\ApplicationController; use App\Http\Controllers\Api\ApplicationFileController; use App\Http\Controllers\Api\AuthSmsController; use App\Http\Controllers\Api\PublicCompetitionController; +use App\Http\Controllers\Api\PublicSourceChannelController; use App\Http\Controllers\Api\ReviewApplicationController; use App\Http\Controllers\Api\ReviewAuthController; use App\Http\Controllers\Api\UserProfileController; @@ -20,6 +22,7 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1')->group(function () { Route::get('public/competitions/by-slug/{slug}', [PublicCompetitionController::class, 'showBySlug']); + Route::get('public/source-channels/resolve', [PublicSourceChannelController::class, 'resolve']); Route::prefix('review')->group(function () { Route::post('auth/login', [ReviewAuthController::class, 'login']); @@ -42,6 +45,10 @@ Route::prefix('v1')->group(function () { Route::middleware(['auth:sanctum', 'admin'])->group(function () { Route::get('me/menus', [AdminMenuController::class, 'index']); Route::get('competitions/{competition}/reviewer-scope-track-counts', [AdminReviewerScopeController::class, 'countsByTrack']); + Route::apiResource('public-source-channels', AdminPublicSourceChannelController::class) + ->parameters([ + 'public-source-channels' => 'public_source_channel', + ]); Route::apiResource('competitions', AdminCompetitionController::class); /** scoped() 无参即可;切勿写 `'track' => 'tracks'`,Laravel 会把 `tracks` 当成子表列名导致 SQL 错误 */ Route::apiResource('competitions.tracks', AdminCompetitionTrackController::class)->scoped(); diff --git a/tests/Feature/AuthSmsControllerTest.php b/tests/Feature/AuthSmsControllerTest.php index 5567533..09915ff 100644 --- a/tests/Feature/AuthSmsControllerTest.php +++ b/tests/Feature/AuthSmsControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Models\Competition; +use App\Models\PublicSourceChannel; use App\Models\SmsVerification; use App\Support\SmsConfig; use Illuminate\Database\Schema\Blueprint; @@ -92,6 +93,112 @@ class AuthSmsControllerTest extends TestCase ])->assertUnprocessable(); } + public function test_public_source_resolve_and_registration_attribution(): void + { + $this->competition('main-event'); + $sourceA = PublicSourceChannel::query()->create([ + 'source_code' => 'poster_A', + 'source_name' => '海报 A', + 'status' => PublicSourceChannel::STATUS_ENABLED, + ]); + $sourceB = PublicSourceChannel::query()->create([ + 'source_code' => 'poster_B', + 'source_name' => '海报 B', + 'status' => PublicSourceChannel::STATUS_ENABLED, + ]); + PublicSourceChannel::query()->create([ + 'source_code' => 'closed_src', + 'source_name' => '停用渠道', + 'status' => PublicSourceChannel::STATUS_DISABLED, + ]); + + $this->getJson('/api/v1/public/source-channels/resolve?source_code=poster_A') + ->assertOk() + ->assertJsonPath('valid', true) + ->assertJsonPath('source_name', '海报 A'); + + $this->getJson('/api/v1/public/source-channels/resolve?source_code=closed_src') + ->assertOk() + ->assertJsonPath('valid', false); + $this->getJson('/api/v1/public/source-channels/resolve?source_code=bad%20code') + ->assertOk() + ->assertJsonPath('valid', false); + + SmsVerification::query()->create([ + 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, + 'mobile' => '13800138100', + 'code' => '111111', + 'provider' => SmsVerification::PROVIDER_DISABLED, + 'status' => SmsVerification::STATUS_SENT, + 'expires_at' => now()->addMinutes(5), + ]); + + $this->postJson('/api/auth/sms/login', [ + 'mobile' => '13800138100', + 'code' => '111111', + 'competition_slug' => 'main-event', + 'public_source_code' => 'poster_A', + ])->assertOk(); + + $this->assertDatabaseHas('users', [ + 'mobile' => '13800138100', + 'public_source_channel_id' => $sourceA->id, + 'public_source_code' => 'poster_A', + ]); + $this->assertNotNull(DB::table('users') + ->where('mobile', '13800138100') + ->value('public_source_attributed_at')); + + SmsVerification::query()->create([ + 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, + 'mobile' => '13800138100', + 'code' => '222222', + 'provider' => SmsVerification::PROVIDER_DISABLED, + 'status' => SmsVerification::STATUS_SENT, + 'expires_at' => now()->addMinutes(5), + ]); + + $this->postJson('/api/auth/sms/login', [ + 'mobile' => '13800138100', + 'code' => '222222', + 'competition_slug' => 'main-event', + 'public_source_code' => 'poster_B', + ])->assertOk(); + + $this->assertDatabaseHas('users', [ + 'mobile' => '13800138100', + 'public_source_channel_id' => $sourceA->id, + 'public_source_code' => 'poster_A', + ]); + $this->assertDatabaseMissing('users', [ + 'mobile' => '13800138100', + 'public_source_channel_id' => $sourceB->id, + 'public_source_code' => 'poster_B', + ]); + + SmsVerification::query()->create([ + 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, + 'mobile' => '13800138101', + 'code' => '333333', + 'provider' => SmsVerification::PROVIDER_DISABLED, + 'status' => SmsVerification::STATUS_SENT, + 'expires_at' => now()->addMinutes(5), + ]); + + $this->postJson('/api/auth/sms/login', [ + 'mobile' => '13800138101', + 'code' => '333333', + 'competition_slug' => 'main-event', + 'public_source_code' => 'bad code', + ])->assertOk(); + + $this->assertDatabaseHas('users', [ + 'mobile' => '13800138101', + 'public_source_channel_id' => null, + 'public_source_code' => null, + ]); + } + public function test_enabled_sender_fails_fast_when_app_key_is_missing(): void { config([ @@ -547,6 +654,17 @@ class AuthSmsControllerTest extends TestCase $table->string('email')->nullable(); $table->string('company')->nullable(); $table->string('password')->nullable(); + $table->foreignId('public_source_channel_id')->nullable(); + $table->string('public_source_code', 64)->nullable(); + $table->timestamp('public_source_attributed_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('public_source_channels', function (Blueprint $table) { + $table->id(); + $table->string('source_code', 64)->unique(); + $table->string('source_name', 100); + $table->string('status')->default(PublicSourceChannel::STATUS_ENABLED); $table->timestamps(); });