公开渠道迭代

master
weizong song 4 months ago
parent 43a04c2918
commit 15818964b7

@ -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',

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\PublicSourceChannel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
class PublicSourceChannelController extends Controller
{
public function index(Request $request): JsonResponse
{
$channels = PublicSourceChannel::query()
->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<string, mixed>
*/
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);
}
}

@ -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()

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PublicSourceChannel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PublicSourceChannelController extends Controller
{
public function resolve(Request $request): JsonResponse
{
$sourceCode = trim((string) $request->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,
]);
}
}

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PublicSourceChannel extends Model
{
use HasFactory;
public const STATUS_ENABLED = 'enabled';
public const STATUS_DISABLED = 'disabled';
public const STATUSES = [
self::STATUS_ENABLED,
self::STATUS_DISABLED,
];
protected $fillable = [
'source_code',
'source_name',
'status',
];
public function users(): HasMany
{
return $this->hasMany(User::class, 'public_source_channel_id');
}
public function isEnabled(): bool
{
return $this->status === self::STATUS_ENABLED;
}
}

@ -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',
];
}

@ -0,0 +1,5 @@
<?php
return [
'frontend_url' => env('PUBLIC_FRONTEND_URL'),
];

@ -0,0 +1,25 @@
<?php
use App\Models\PublicSourceChannel;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
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();
});
}
public function down(): void
{
Schema::dropIfExists('public_source_channels');
}
};

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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',
]);
});
}
};

@ -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();

@ -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();
});

Loading…
Cancel
Save