'http://localhost', 'cache.default' => 'array', 'database.default' => 'sqlite', 'database.connections.sqlite' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => false, ], 'sms.driver' => 'tencentcloud', 'sms.enabled' => false, 'sms.resend_interval_seconds' => 60, 'sms.code_ttl_seconds' => 300, 'sms.tencentcloud.endpoint' => 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms', 'sms.tencentcloud.sdk_app_id' => null, 'sms.tencentcloud.app_key' => null, 'sms.tencentcloud.sign_name' => '元禾控股', 'sms.tencentcloud.template_id' => '2175650', 'sms.tencentcloud.template_param_count' => 2, ]); DB::purge(); DB::reconnect(); Cache::flush(); $this->createSchema(); } public function test_disabled_sender_creates_record_and_login_consumes_it(): void { $this->competition('main-event'); Http::fake(); $send = $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138000', ])->assertOk(); Http::assertNothingSent(); $code = (string) $send->json('debug_code'); $this->assertMatchesRegularExpression('/^\d{6}$/', $code); $record = SmsVerification::query()->firstOrFail(); $this->assertSame(SmsVerification::STATUS_SENT, $record->status); $this->assertSame(SmsVerification::PROVIDER_DISABLED, $record->provider); $this->assertSame('DISABLED_OK', $record->provider_code); $this->assertSame('138****8000', $record->request_payload_json['phone_number']); $this->assertArrayNotHasKey('code', $record->request_payload_json); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138000', 'code' => $code, 'competition_slug' => 'main-event', ])->assertOk() ->assertJsonPath('token_type', 'Bearer'); $this->assertDatabaseHas('sms_verifications', [ 'id' => $record->id, 'status' => SmsVerification::STATUS_USED, ]); $this->assertDatabaseHas('applications', [ 'competition_id' => Competition::query()->where('slug', 'main-event')->value('id'), 'status' => 'draft', ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138000', 'code' => $code, 'competition_slug' => 'main-event', ])->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([ 'sms.enabled' => true, 'sms.tencentcloud.sdk_app_id' => '1400000000', ]); Http::fake(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138001', ])->assertStatus(503) ->assertJsonPath('message', '短信服务配置未完成'); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138001', 'provider' => SmsVerification::PROVIDER_TENCENTCLOUD, 'status' => SmsVerification::STATUS_FAILED, 'provider_code' => 'CONFIG_INCOMPLETE', ]); Http::assertNothingSent(); } public function test_failed_attempt_still_counts_for_rate_limit(): void { config(['sms.enabled' => true]); Http::fake(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138008', ])->assertStatus(503); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138008', ])->assertUnprocessable() ->assertJsonValidationErrors('mobile'); $this->assertSame(1, SmsVerification::query()->where('mobile', '13800138008')->count()); } public function test_send_is_rejected_without_record_or_http_call_when_mobile_lock_is_occupied(): void { $mobile = '13800138009'; $this->enableTencentCloudConfig(); Http::fake(); $lock = Cache::lock( 'sms-send-lock:'.hash('sha256', $mobile), max(30, (int) config('sms.timeout', 5) + 10) ); $this->assertTrue($lock->get()); try { $this->postJson('/api/auth/sms/send', [ 'mobile' => $mobile, ])->assertUnprocessable() ->assertJsonPath('errors.mobile.0', '请勿频繁发送'); } finally { $lock->release(); } $this->assertSame(0, SmsVerification::query()->where('mobile', $mobile)->count()); Http::assertNothingSent(); } public function test_provider_failure_releases_lock_and_allows_send_after_rate_limit_window(): void { $mobile = '13800138010'; $startedAt = Carbon::parse('2026-06-06 10:00:00'); $this->enableTencentCloudConfig(); config(['sms.timeout' => 120]); Http::fakeSequence() ->push([ 'result' => 1014, 'errmsg' => 'template incorrect', 'sid' => 'request-failed-lock-release', ], 200) ->push([ 'result' => 0, 'errmsg' => 'OK', 'sid' => 'request-after-window', ], 200); Carbon::setTestNow($startedAt); try { $this->postJson('/api/auth/sms/send', [ 'mobile' => $mobile, ])->assertStatus(503) ->assertJsonPath('message', '发送失败'); Carbon::setTestNow($startedAt->copy()->addSeconds(61)); $this->postJson('/api/auth/sms/send', [ 'mobile' => $mobile, ])->assertOk(); } finally { Carbon::setTestNow(); } $this->assertDatabaseHas('sms_verifications', [ 'mobile' => $mobile, 'status' => SmsVerification::STATUS_FAILED, 'provider_request_id' => 'request-failed-lock-release', ]); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => $mobile, 'status' => SmsVerification::STATUS_SENT, 'provider_request_id' => 'request-after-window', ]); Http::assertSentCount(2); } public function test_disabled_sender_does_not_expose_debug_code_in_production(): void { $this->app->detectEnvironment(fn () => 'production'); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138006', ])->assertOk() ->assertJsonMissingPath('debug_code'); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138006', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, ]); } public function test_enabled_sender_posts_tencent_cloud_template_parameters_and_marks_sent(): void { $this->enableTencentCloudConfig(); Http::fake(function (Request $request) { $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138002', 'status' => SmsVerification::STATUS_PENDING, ]); return Http::response([ 'result' => 0, 'errmsg' => 'OK', 'ext' => '', 'fee' => 1, 'sid' => 'request-1', ], 200); }); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138002', ])->assertOk() ->assertJsonMissingPath('debug_code'); Http::assertSent(function (Request $request): bool { $payload = json_decode($request->body(), true); parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); $expectedSig = hash( 'sha256', 'appkey=test-app-key' .'&random='.$query['random'] .'&time='.$payload['time'] .'&mobile='.$payload['tel']['mobile'] ); return parse_url($request->url(), PHP_URL_SCHEME) === 'https' && parse_url($request->url(), PHP_URL_HOST) === 'yun.tim.qq.com' && parse_url($request->url(), PHP_URL_PATH) === '/v5/tlssmssvr/sendsms' && $query['sdkappid'] === '1400000000' && preg_match('/^\d+$/', (string) $query['random']) === 1 && ! $request->hasHeader('Authorization') && ! $request->hasHeader('X-TC-Action') && $payload['ext'] === '' && $payload['extend'] === '' && count($payload['params']) === 2 && preg_match('/^\d{6}$/', (string) $payload['params'][0]) === 1 && $payload['params'][1] === '5' && $payload['sig'] === $expectedSig && $payload['sign'] === '元禾控股' && $payload['tel'] === ['mobile' => '13800138002', 'nationcode' => '86'] && is_int($payload['time']) && $payload['tpl_id'] === 2175650; }); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138002', 'provider' => SmsVerification::PROVIDER_TENCENTCLOUD, 'status' => SmsVerification::STATUS_SENT, 'provider_request_id' => 'request-1', 'provider_code' => '0', 'provider_message' => 'OK', ]); } public function test_runtime_configuration_has_no_cam_dependency(): void { $requiredEnv = SmsConfig::realSendingRequiredEnv(); $this->assertSame('TENCENT_SMS_APP_KEY', $requiredEnv['sms.tencentcloud.app_key']); $this->assertNotContains('TENCENTCLOUD_SECRET_ID', $requiredEnv); $this->assertNotContains('TENCENTCLOUD_SECRET_KEY', $requiredEnv); $this->assertArrayNotHasKey('sms.tencentcloud.region', $requiredEnv); $this->assertArrayNotHasKey('sms.tencentcloud.secret_id', $requiredEnv); $this->assertArrayNotHasKey('sms.tencentcloud.secret_key', $requiredEnv); } public function test_http_500_with_result_zero_is_recorded_as_failed(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'result' => 0, 'errmsg' => 'upstream unavailable', 'sid' => 'http-500-sid', ], 500), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138014', ])->assertStatus(503) ->assertJsonPath('message', '发送失败'); $record = SmsVerification::query()->where('mobile', '13800138014')->firstOrFail(); $this->assertSame(SmsVerification::STATUS_FAILED, $record->status); $this->assertSame('HTTP_500', $record->provider_code); $this->assertSame('http-500-sid', $record->provider_request_id); $this->assertSame(500, $record->response_json['http_status']); $this->assertSame(0, $record->response_json['result']); $this->assertSame('upstream unavailable', $record->response_json['errmsg']); $this->assertSame(0, SmsVerification::query() ->where('mobile', '13800138014') ->where('status', SmsVerification::STATUS_SENT) ->count()); } public function test_network_error_is_recorded_with_locatable_code(): void { $this->enableTencentCloudConfig(); Http::fake(fn () => throw new \RuntimeException('connection refused')); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138011', ])->assertStatus(503); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138011', 'status' => SmsVerification::STATUS_FAILED, 'provider_code' => 'NETWORK_ERROR', ]); } public function test_invalid_json_response_is_recorded_with_locatable_code(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response('not-json', 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138012', ])->assertStatus(503); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138012', 'status' => SmsVerification::STATUS_FAILED, 'provider_code' => 'INVALID_JSON_RESPONSE', ]); } public function test_protocol_error_is_recorded_when_result_is_missing(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'errmsg' => 'missing result', 'sid' => 'protocol-error-sid', ], 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138013', ])->assertStatus(503); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138013', 'status' => SmsVerification::STATUS_FAILED, 'provider_request_id' => 'protocol-error-sid', 'provider_code' => 'PROTOCOL_ERROR', ]); } public function test_failed_new_send_keeps_previous_sent_code_usable(): void { $competition = $this->competition('main-event'); $old = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138003', 'code' => '111111', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->addMinutes(5), ]); $old->forceFill([ 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ])->save(); $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'result' => 1014, 'errmsg' => 'template incorrect', 'sid' => 'request-failed', ], 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138003', ])->assertStatus(503) ->assertJsonPath('message', '发送失败'); $this->assertDatabaseHas('sms_verifications', [ 'id' => $old->id, 'status' => SmsVerification::STATUS_SENT, ]); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138003', 'status' => SmsVerification::STATUS_FAILED, 'provider_request_id' => 'request-failed', 'provider_code' => '1014', 'provider_message' => 'template incorrect', ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138003', 'code' => '111111', 'competition_slug' => $competition->slug, ])->assertOk(); } public function test_successful_new_send_expires_previous_sent_code(): void { $this->competition('main-event'); $old = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138007', 'code' => '333333', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->addMinutes(5), ]); $old->forceFill([ 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ])->save(); $oldPending = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138007', 'code' => '444444', 'provider' => SmsVerification::PROVIDER_TENCENTCLOUD, 'status' => SmsVerification::STATUS_PENDING, 'expires_at' => now()->addMinutes(5), ]); $oldPending->forceFill([ 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ])->save(); $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'result' => 0, 'errmsg' => 'OK', 'sid' => 'request-new', ], 200), ]); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138007', ])->assertOk(); $this->assertDatabaseHas('sms_verifications', [ 'id' => $old->id, 'status' => SmsVerification::STATUS_EXPIRED, ]); $this->assertDatabaseHas('sms_verifications', [ 'id' => $oldPending->id, 'status' => SmsVerification::STATUS_EXPIRED, ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138007', 'code' => '333333', 'competition_slug' => 'main-event', ])->assertUnprocessable() ->assertJsonValidationErrors('code'); } public function test_rate_limit_is_kept_after_successful_send(): void { $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138004', ])->assertOk(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138004', ])->assertUnprocessable() ->assertJsonValidationErrors('mobile'); } public function test_expired_sent_code_cannot_login(): void { $this->competition('main-event'); SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => '13800138005', 'code' => '222222', 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->subSecond(), ]); $this->postJson('/api/auth/sms/login', [ 'mobile' => '13800138005', 'code' => '222222', 'competition_slug' => 'main-event', ])->assertUnprocessable() ->assertJsonValidationErrors('code'); } private function enableTencentCloudConfig(): void { config([ 'sms.enabled' => true, 'sms.tencentcloud.sdk_app_id' => '1400000000', 'sms.tencentcloud.app_key' => 'test-app-key', ]); } private function competition(string $slug): Competition { return Competition::query()->create([ 'slug' => $slug, 'name' => '测试赛事', 'status' => 'published', 'published' => true, ]); } private function createSchema(): void { Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('mobile')->unique(); $table->string('name')->nullable(); $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(); }); Schema::create('competitions', function (Blueprint $table) { $table->id(); $table->string('slug')->unique(); $table->string('name'); $table->string('status')->default('draft'); $table->boolean('published')->default(false); $table->timestamps(); }); Schema::create('applications', function (Blueprint $table) { $table->id(); $table->string('project_code', 32)->nullable(); $table->foreignId('user_id'); $table->foreignId('competition_id'); $table->string('status')->default('draft'); $table->softDeletes(); $table->timestamps(); $table->unique(['user_id', 'competition_id']); }); Schema::create('sms_verifications', function (Blueprint $table) { $table->id(); $table->string('scene', 64)->default('participant_login'); $table->string('mobile', 20)->index(); $table->string('code', 10); $table->string('provider', 32)->default('disabled'); $table->string('status', 16)->default('pending'); $table->string('template_id', 64)->nullable(); $table->string('sign_name', 64)->nullable(); $table->json('request_payload_json')->nullable(); $table->json('response_json')->nullable(); $table->string('provider_request_id', 128)->nullable(); $table->string('provider_code', 128)->nullable(); $table->string('provider_message', 500)->nullable(); $table->timestamp('expires_at'); $table->timestamp('sent_at')->nullable(); $table->timestamp('failed_at')->nullable(); $table->timestamp('used_at')->nullable(); $table->timestamps(); }); Schema::create('personal_access_tokens', function (Blueprint $table) { $table->id(); $table->morphs('tokenable'); $table->string('name'); $table->string('token', 64)->unique(); $table->text('abilities')->nullable(); $table->timestamp('last_used_at')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); } }