'sqlite', 'database.connections.sqlite' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => false, ], 'logging.default' => 'single', 'logging.channels.single.path' => $this->logPath, 'app.url' => 'https://localhost', ]); DB::purge(); DB::reconnect(); Log::forgetChannel(); @unlink($this->logPath); $this->createSchema(); } public function test_admin_signup_channel_contract(): void { Sanctum::actingAs(AdminUser::query()->create([ 'username' => 'admin', 'password_hash' => 'unused', 'name' => '管理员', 'status' => 'active', ])); $competition = $this->competition('main-event', ['name' => '主赛事']); $create = $this->postJson("/api/v1/admin/competitions/{$competition->id}/signup-channels", [ 'channel_name' => '小程序渠道', 'shared_secret' => 'secret-A', 'success_callback_url' => 'https://mini.example.com/result', 'remark' => '测试', ])->assertCreated(); $code = (string) $create->json('channel_code'); $this->assertNotSame('', $code); $this->assertSame('小程序渠道', $create->json('channel_name')); $this->assertSame(SignupChannel::CALLBACK_TYPE_WEB, $create->json('success_callback_type')); $this->assertSame(ChannelEntryEncryption::ALGORITHM, $create->json('encryption_algorithm')); $this->assertFalse($create->json('entry_encryption_enabled')); $this->assertStringContainsString('BEGIN PUBLIC KEY', (string) $create->json('encryption_public_key')); $create->assertJsonMissingPath('encryption_private_key'); $rawPrivateKey = (string) DB::table('signup_channels') ->where('id', $create->json('id')) ->value('encryption_private_key'); $this->assertNotSame('', $rawPrivateKey); $this->assertStringNotContainsString('BEGIN PRIVATE KEY', $rawPrivateKey); $this->getJson("/api/v1/admin/competitions/{$competition->id}/signup-channels") ->assertOk() ->assertJsonPath('data.0.competition_name', '主赛事') ->assertJsonMissingPath('data.0.shared_secret'); $this->getJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}") ->assertOk() ->assertJsonPath('shared_secret', 'secret-A'); $this->postJson("/api/v1/admin/competitions/{$competition->id}/signup-channels", [ 'channel_code' => 'client-code', 'channel_name' => '非法渠道', 'shared_secret' => 'secret-B', 'success_callback_url' => 'https://mini.example.com/result', ])->assertUnprocessable(); $withoutCallback = $this->postJson("/api/v1/admin/competitions/{$competition->id}/signup-channels", [ 'channel_name' => '无回调渠道', 'shared_secret' => 'secret-C', ])->assertCreated(); $this->assertSame('', $withoutCallback->json('success_callback_url')); $this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [ 'success_callback_url' => '', 'entry_encryption_enabled' => true, 'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM, 'mini_program_callback_path' => '/pages/signup/result', 'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO, ])->assertOk() ->assertJsonPath('success_callback_url', '') ->assertJsonPath('entry_encryption_enabled', true) ->assertJsonPath('success_callback_type', SignupChannel::CALLBACK_TYPE_MINI_PROGRAM) ->assertJsonPath('mini_program_callback_path', '/pages/signup/result'); $this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [ 'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM, 'mini_program_callback_path' => 'https://invalid.example.com/page', ])->assertUnprocessable(); $this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [ 'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM, 'mini_program_callback_path' => '', ])->assertUnprocessable(); $this->putJson("/api/v1/admin/competitions/{$competition->id}/signup-channels/{$create->json('id')}", [ 'success_callback_type' => SignupChannel::CALLBACK_TYPE_WEB, 'success_callback_url' => 'ftp://mini.example.com/result', ])->assertUnprocessable(); } public function test_draft_save_allows_required_fields_missing_but_validates_filled_formats(): void { $this->competition('main-event'); Sanctum::actingAs(User::query()->create([ 'mobile' => '13800138000', ])); $this->putJson('/api/applications/current?competition_slug=main-event', [ 'player_name' => '张三', 'contact_email' => 'bad-email', ])->assertUnprocessable() ->assertJsonValidationErrors(['contact_email']); $this->putJson('/api/applications/current?competition_slug=main-event', [ 'player_name' => '张三', 'contact_email' => 'draft@example.com', 'commitment_accepted' => true, 'promise_signature' => 'data:image/png;base64,AAAA', ])->assertOk() ->assertJsonPath('status', 'draft') ->assertJsonPath('player_name', '张三') ->assertJsonPath('contact_email', 'draft@example.com') ->assertJsonPath('entry_group', null) ->assertJsonPath('company_name', null) ->assertJsonMissingPath('promise_signature'); } public function test_application_save_and_submit_do_not_fail_when_contact_email_is_used_by_another_user(): void { $competition = $this->competition('main-event'); $track = CompetitionTrack::query()->create([ 'competition_id' => $competition->id, 'track_code' => 'consumer', 'title' => '消费赛道', 'sort' => 1, 'is_enabled' => true, ]); User::query()->create([ 'mobile' => '13800138009', 'email' => 'shared@example.com', ]); $user = User::query()->create([ 'mobile' => '13800138010', ]); Sanctum::actingAs($user); $this->putJson('/api/applications/current?competition_slug=main-event', [ 'player_name' => '李四', 'contact_email' => 'shared@example.com', 'commitment_accepted' => true, 'promise_signature' => 'data:image/png;base64,AAAA', ])->assertOk() ->assertJsonPath('status', 'draft') ->assertJsonPath('contact_email', 'shared@example.com'); $application = Application::query() ->where('user_id', $user->id) ->where('competition_id', $competition->id) ->firstOrFail(); ApplicationFile::query()->create([ 'application_id' => $application->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/shared.pdf', 'original_name' => 'shared.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); $this->postJson( '/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'shared@example.com', '13800138010') )->assertOk() ->assertJsonPath('status', 'submitted') ->assertJsonPath('contact_email', 'shared@example.com'); $this->assertNull($user->refresh()->email); } public function test_channel_entry_success_errors_context_and_safe_logs(): void { $competition = $this->competition('main-event'); $channelA = $this->channel($competition, 'CHANNEL-A', ['shared_secret' => 'secret-A']); $channelB = $this->channel($competition, 'CHANNEL-B', ['shared_secret' => 'secret-B']); $response = $this ->withHeader('X-Forwarded-Proto', 'https') ->withHeader('X-Forwarded-Port', '443') ->get('/channel-entry?'.http_build_query($this->signed($channelA))); $response->assertOk() ->assertHeader('Referrer-Policy', 'no-referrer') ->assertSee("localStorage.setItem('cxxfds_token'", false) ->assertSee('/admin/c/main-event/apply', false) ->assertSee('href="https://', false) ->assertDontSee('href="http://', false) ->assertSee("const redirectUrl = 'https:\\/\\/", false) ->assertDontSee("const redirectUrl = 'http:\\/\\/", false); $cacheControl = (string) $response->headers->get('Cache-Control'); foreach (['private', 'no-store', 'no-cache', 'must-revalidate'] as $directive) { $this->assertStringContainsString($directive, $cacheControl); } $user = User::query()->where('mobile', '13800138000')->firstOrFail(); $application = Application::query() ->where('user_id', $user->id) ->where('competition_id', $competition->id) ->firstOrFail(); $this->assertSame('CHANNEL-A', $user->first_channel_code); $this->assertSame('CHANNEL-A', $user->last_channel_code); $this->assertSame('张三', $user->name); $this->assertSame('测试企业', $user->company); $this->assertSame('CHANNEL-A', $application->signup_channel_code); $this->assertSame('draft', $application->status); $this->assertSame(1, DB::table('personal_access_tokens')->where('tokenable_id', $user->id)->count()); $badHash = $this->signed($channelA); $badHash['hash'] = str_repeat('0', 64); $this->assertChannelEntryError($badHash, 'HASH_INVALID'); $this->assertChannelEntryError($this->signed($channelA, ['timestamp' => (string) (time() - 301)]), 'TIMESTAMP_EXPIRED'); $this->assertChannelEntryError($this->signed($channelA, ['mobile' => '']), 'MOBILE_INVALID'); $plaintext = [ 'channel_code' => $channelA->channel_code, 'timestamp' => (string) time(), 'name' => '张三', 'mobile' => '13800138000', 'company_name' => '测试企业', 'state' => 'plaintext', ]; $plaintext['hash'] = ChannelEntrySignature::make($plaintext, $channelA->shared_secret); $this->assertChannelEntryError($plaintext, 'ENCRYPTION_INVALID'); $encryptedEmptyName = $this->signed($channelA); $encryptedEmptyName['name'] = ChannelEntryEncryption::encrypt('', (string) $channelA->encryption_public_key); $encryptedEmptyName['hash'] = ChannelEntrySignature::make($encryptedEmptyName, $channelA->shared_secret); $this->assertChannelEntryError($encryptedEmptyName, 'ENCRYPTION_INVALID'); $this->assertChannelEntryError($this->signed($channelA, ['state' => 'a&b=c']), 'HASH_INVALID'); $legacyChannel = $this->channel($competition, 'CHANNEL-LEGACY', ['entry_encryption_enabled' => false]); $legacyParameters = [ 'channel_code' => $legacyChannel->channel_code, 'timestamp' => (string) time(), 'name' => '旧渠道用户', 'mobile' => '13600136000', 'company_name' => '旧渠道企业', 'state' => 'legacy', ]; $legacyParameters['hash'] = ChannelEntrySignature::make($legacyParameters, $legacyChannel->shared_secret); $this->get('/channel-entry?'.http_build_query($legacyParameters)) ->assertOk() ->assertSee('/admin/c/main-event/apply', false); $this->assertSame('旧渠道用户', User::query()->where('mobile', '13600136000')->value('name')); $this->assertSame('旧渠道企业', User::query()->where('mobile', '13600136000')->value('company')); $legacyParameters['company_name'] = '旧渠道&企业'; $legacyParameters['hash'] = ChannelEntrySignature::make($legacyParameters, $legacyChannel->shared_secret); $this->assertChannelEntryError($legacyParameters, 'HASH_INVALID'); $disabled = $this->channel($competition, 'CHANNEL-DISABLED', ['status' => SignupChannel::STATUS_DISABLED]); $this->assertChannelEntryError($this->signed($disabled), 'CHANNEL_DISABLED'); $emptyOptional = $this->signed($channelA, [ 'mobile' => '13900139000', 'company_name' => '', 'state' => '', ]); $this->get('/channel-entry?'.http_build_query($emptyOptional)) ->assertOk() ->assertSee('/admin/c/main-event/apply', false); $this->assertNull(User::query()->where('mobile', '13900139000')->value('company')); $forbiddenControls = $this->signed($channelA, ['mobile' => '13700137000']); $forbiddenControls['competition_id'] = '999999'; $forbiddenControls['competition_slug'] = 'other-event'; $forbiddenControls['callback_url'] = 'https://evil.example.com/takeover'; $this->get('/channel-entry?'.http_build_query($forbiddenControls)) ->assertOk() ->assertSee('/admin/c/main-event/apply', false) ->assertDontSee('evil.example.com', false); $this->assertChannelEntryError( $this->signed($this->channel($this->competition('unpublished', ['published' => false]), 'UNPUBLISHED')), 'COMPETITION_INVALID' ); $notStarted = $this->competition('not-started', ['signup_open_at' => now()->addHour()]); $this->assertChannelEntryError($this->signed($this->channel($notStarted, 'NOT-STARTED')), 'SIGNUP_NOT_STARTED'); $this->assertChannelEntryError( $this->signed($this->channel($this->competition('closed-time', ['signup_close_at' => now()->subMinute()]), 'CLOSED-TIME')), 'SIGNUP_CLOSED' ); $this->assertChannelEntryError( $this->signed($this->channel($this->competition('reviewing', ['status' => 'reviewing']), 'REVIEWING')), 'SIGNUP_CLOSED' ); $this->assertChannelEntryError( $this->signed($this->channel($this->competition('ended', ['status' => 'ended']), 'ENDED')), 'SIGNUP_CLOSED' ); $application->fill([ 'player_name' => '保留姓名', 'company_name' => '保留企业', 'project_name' => '保留项目', 'intro' => '保留简介', ])->save(); $this->get('/channel-entry?'.http_build_query($this->signed($channelB, [ 'name' => '李四', 'company_name' => '新渠道企业', 'state' => 'state-B', ]))) ->assertOk(); $user->refresh(); $application->refresh(); $this->assertSame('CHANNEL-A', $user->first_channel_code); $this->assertSame('CHANNEL-B', $user->last_channel_code); $this->assertSame('李四', $user->name); $this->assertSame('新渠道企业', $user->company); $this->assertSame('CHANNEL-B', $application->signup_channel_code); $this->assertSame('state-B', $application->signup_channel_state); $this->assertSame('保留姓名', $application->player_name); $this->assertSame('保留企业', $application->company_name); $this->assertSame('保留项目', $application->project_name); $logs = file_get_contents($this->logPath) ?: ''; $this->assertStringContainsString('138****8000', $logs); $this->assertStringNotContainsString('13800138000', $logs); $this->assertStringNotContainsString('secret-A', $logs); $this->assertDoesNotMatchRegularExpression('/\b\d+\|[A-Za-z0-9]{40}\b/', $logs); } public function test_artisan_generates_channel_entry_urls_from_database(): void { $competition = $this->competition('main-event'); $channel = $this->channel($competition, 'CHANNEL-CLI', ['shared_secret' => 'cli-secret']); $exitCode = Artisan::call('channel:entry-urls', [ '--competition' => 'main-event', '--channel' => 'CHANNEL-CLI', '--mobile' => '13800138009', '--name' => '命令测试', '--company-name' => '测试公司', '--state' => 'cli-state', '--timestamp' => (string) time(), '--base-url' => 'http://localhost', '--require-encryption' => true, '--plain' => true, ]); $this->assertSame(0, $exitCode); $output = trim(Artisan::output()); $this->assertStringStartsWith('http://localhost/channel-entry?', $output); $this->assertStringNotContainsString('cli-secret', $output); $this->assertStringNotContainsString('13800138009', $output); $this->assertStringNotContainsString('命令测试', $output); $this->assertStringNotContainsString('测试公司', $output); $query = parse_url($output, PHP_URL_QUERY); $this->assertIsString($query); parse_str($query, $parameters); $this->assertSame('CHANNEL-CLI', $parameters['channel_code'] ?? null); $this->assertSame( ChannelEntrySignature::make($parameters, $channel->shared_secret), $parameters['hash'] ?? null ); $this->get('/channel-entry?'.$query) ->assertOk() ->assertSee('/admin/c/main-event/apply', false); } public function test_submit_channel_callback_success_fallback_and_logs(): void { $competition = $this->competition('main-event', [ 'settings' => [ 'success_notice' => [ 'enabled' => true, 'message' => '请关注后续通知,并保持手机畅通。', ], ], ]); $track = CompetitionTrack::query()->create([ 'competition_id' => $competition->id, 'track_code' => 'consumer', 'title' => '消费赛道', 'sort' => 1, 'is_enabled' => true, ]); $channel = $this->channel($competition, 'CHANNEL-CALLBACK', [ 'shared_secret' => 'callback-secret', 'success_callback_url' => 'https://mini.example.com/result?keep=1&status=old#done', ]); $user = User::query()->create(['mobile' => '13800138001']); $application = Application::query()->create([ 'user_id' => $user->id, 'competition_id' => $competition->id, 'signup_channel_id' => $channel->id, 'signup_channel_code' => $channel->channel_code, 'signup_channel_state' => 'trace id=1&x', 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $application->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/a.pdf', 'original_name' => 'a.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($user); $response = $this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code)) ->assertOk() ->assertJsonPath('status', 'submitted') ->assertJsonPath('success_notice.enabled', true) ->assertJsonPath('success_notice.message', '请关注后续通知,并保持手机畅通。') ->assertJsonPath('channel_callback.type', SignupChannel::CALLBACK_TYPE_WEB) ->assertJsonStructure(['channel_callback' => ['redirect_url']]); $redirectUrl = (string) $response->json('channel_callback.redirect_url'); $parts = parse_url($redirectUrl); parse_str((string) ($parts['query'] ?? ''), $query); $this->assertSame('https', $parts['scheme'] ?? null); $this->assertSame('mini.example.com', $parts['host'] ?? null); $this->assertSame('done', $parts['fragment'] ?? null); $this->assertSame('1', $query['keep'] ?? null); $this->assertSame('submitted', $query['status'] ?? null); $this->assertSame('trace id=1&x', $query['state'] ?? null); $this->assertSame('CHANNEL-CALLBACK', $query['channel_code'] ?? null); $this->assertSame((string) $application->id, $query['application_id'] ?? null); $this->assertSame($this->callbackHash($query, 'callback-secret'), $query['hash'] ?? null); $miniProgramUser = User::query()->create(['mobile' => '13800138005']); $miniProgramChannel = $this->channel($competition, 'CHANNEL-MINI-PROGRAM', [ 'shared_secret' => 'mini-program-secret', 'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM, 'mini_program_callback_path' => '/pages/signup/result?keep=1&status=old', 'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_RE_LAUNCH, ]); $miniProgramApplication = Application::query()->create([ 'user_id' => $miniProgramUser->id, 'competition_id' => $competition->id, 'signup_channel_id' => $miniProgramChannel->id, 'signup_channel_code' => $miniProgramChannel->channel_code, 'signup_channel_state' => 'mini-state', 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $miniProgramApplication->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/mini.pdf', 'original_name' => 'mini.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($miniProgramUser); $miniProgramResponse = $this->postJson( '/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'mini@example.com', '13800138005') )->assertOk() ->assertJsonPath('channel_callback.type', SignupChannel::CALLBACK_TYPE_MINI_PROGRAM) ->assertJsonPath('channel_callback.method', SignupChannel::MINI_PROGRAM_METHOD_RE_LAUNCH) ->assertJsonMissingPath('channel_callback.redirect_url'); $miniProgramPath = (string) $miniProgramResponse->json('channel_callback.path'); [$miniBasePath, $miniQueryString] = array_pad(explode('?', $miniProgramPath, 2), 2, ''); parse_str($miniQueryString, $miniQuery); $this->assertSame('/pages/signup/result', $miniBasePath); $this->assertSame('1', $miniQuery['keep'] ?? null); $this->assertSame('submitted', $miniQuery['status'] ?? null); $this->assertSame('mini-state', $miniQuery['state'] ?? null); $this->assertSame((string) $miniProgramApplication->id, $miniQuery['application_id'] ?? null); $this->assertSame($this->callbackHash($miniQuery, 'mini-program-secret'), $miniQuery['hash'] ?? null); $switchTabUser = User::query()->create(['mobile' => '13800138015']); $switchTabChannel = $this->channel($competition, 'CHANNEL-SWITCH-TAB', [ 'shared_secret' => 'switch-tab-secret', 'success_callback_type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM, 'mini_program_callback_path' => '/pages/shop/index2?keep=1', 'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_SWITCH_TAB, ]); $switchTabApplication = Application::query()->create([ 'user_id' => $switchTabUser->id, 'competition_id' => $competition->id, 'signup_channel_id' => $switchTabChannel->id, 'signup_channel_code' => $switchTabChannel->channel_code, 'signup_channel_state' => 'switch-state', 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $switchTabApplication->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/switch-tab.pdf', 'original_name' => 'switch-tab.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($switchTabUser); $this->postJson( '/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'switch-tab@example.com', '13800138015') )->assertOk() ->assertJsonPath('channel_callback.type', SignupChannel::CALLBACK_TYPE_MINI_PROGRAM) ->assertJsonPath('channel_callback.method', SignupChannel::MINI_PROGRAM_METHOD_SWITCH_TAB) ->assertJsonPath('channel_callback.path', '/pages/shop/index2'); $noneUser = User::query()->create(['mobile' => '13800138006']); $noneChannel = $this->channel($competition, 'CHANNEL-NONE', [ 'success_callback_type' => SignupChannel::CALLBACK_TYPE_NONE, ]); $noneApplication = Application::query()->create([ 'user_id' => $noneUser->id, 'competition_id' => $competition->id, 'signup_channel_id' => $noneChannel->id, 'signup_channel_code' => $noneChannel->channel_code, 'signup_channel_state' => 'none-state', 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $noneApplication->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/none.pdf', 'original_name' => 'none.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($noneUser); $this->postJson( '/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'none@example.com', '13800138006') )->assertOk() ->assertJsonMissingPath('channel_callback'); $ordinaryUser = User::query()->create(['mobile' => '13800138002']); $ordinaryApplication = Application::query()->create([ 'user_id' => $ordinaryUser->id, 'competition_id' => $competition->id, 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $ordinaryApplication->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/b.pdf', 'original_name' => 'b.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($ordinaryUser); $this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'ordinary@example.com', '13800138002')) ->assertOk() ->assertJsonMissingPath('channel_callback'); $channel->update(['success_callback_url' => 'ftp://mini.example.com/result']); $fallbackUser = User::query()->create(['mobile' => '13800138003']); $fallbackApplication = Application::query()->create([ 'user_id' => $fallbackUser->id, 'competition_id' => $competition->id, 'signup_channel_id' => $channel->id, 'signup_channel_code' => $channel->channel_code, 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $fallbackApplication->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/c.pdf', 'original_name' => 'c.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($fallbackUser); $this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'fallback@example.com', '13800138003')) ->assertOk() ->assertJsonPath('status', 'submitted') ->assertJsonMissingPath('channel_callback'); $this->assertSame('submitted', $fallbackApplication->refresh()->status); $channel->update(['success_callback_url' => 'https://mini.example.com/code-only']); $codeOnlyUser = User::query()->create(['mobile' => '13800138004']); $codeOnlyApplication = Application::query()->create([ 'user_id' => $codeOnlyUser->id, 'competition_id' => $competition->id, 'signup_channel_id' => null, 'signup_channel_code' => $channel->channel_code, 'status' => 'draft', ]); ApplicationFile::query()->create([ 'application_id' => $codeOnlyApplication->id, 'kind' => 'plan', 'disk' => 'public', 'path' => 'plans/d.pdf', 'original_name' => 'd.pdf', 'size' => 100, 'mime' => 'application/pdf', ]); Sanctum::actingAs($codeOnlyUser); $this->postJson('/api/applications/current/submit?competition_slug=main-event', $this->submitPayload($track->track_code, 'code-only@example.com', '13800138004')) ->assertOk() ->assertJsonStructure(['channel_callback' => ['redirect_url']]); $logs = file_get_contents($this->logPath) ?: ''; $this->assertStringContainsString('"callback_result":"success"', $logs); $this->assertStringContainsString('"callback_result":"failed"', $logs); $this->assertStringNotContainsString('13800138001', $logs); $this->assertStringNotContainsString('callback-secret', $logs); } private function createSchema(): void { Schema::create('admin_users', function (Blueprint $table) { $table->id(); $table->string('mobile')->nullable(); $table->string('username')->nullable()->unique(); $table->string('password_hash')->nullable(); $table->string('name')->default(''); $table->string('status')->default('active'); $table->timestamp('last_login_at')->nullable(); $table->timestamps(); }); Schema::create('competitions', function (Blueprint $table) { $table->id(); $table->string('slug')->unique(); $table->string('name'); $table->text('description')->nullable(); $table->text('pledge_content_html')->nullable(); $table->string('status')->default('draft'); $table->boolean('published')->default(false); $table->dateTime('signup_open_at')->nullable(); $table->dateTime('signup_close_at')->nullable(); $table->foreignId('form_schema_id')->nullable(); $table->foreignId('review_form_schema_id')->nullable(); $table->json('branding_json')->nullable(); $table->json('settings')->nullable(); $table->json('scoring_rules_json')->nullable(); $table->timestamps(); }); Schema::create('form_schema_definitions', function (Blueprint $table) { $table->id(); $table->foreignId('competition_id'); $table->string('purpose'); $table->string('name'); $table->unsignedInteger('version')->default(1); $table->json('schema_json')->nullable(); $table->boolean('is_published')->default(true); $table->timestamps(); }); Schema::create('competition_tracks', function (Blueprint $table) { $table->id(); $table->foreignId('competition_id'); $table->string('track_code'); $table->string('title'); $table->text('description')->nullable(); $table->integer('sort')->default(0); $table->boolean('is_enabled')->default(true); $table->timestamps(); }); Schema::create('signup_channels', function (Blueprint $table) { $table->id(); $table->foreignId('competition_id')->nullable(); $table->string('channel_code')->unique(); $table->string('channel_name'); $table->string('status')->default(SignupChannel::STATUS_ENABLED); $table->string('shared_secret'); $table->string('encryption_algorithm')->nullable(); $table->boolean('entry_encryption_enabled')->default(false); $table->text('encryption_public_key')->nullable(); $table->text('encryption_private_key')->nullable(); $table->string('success_callback_url', 2048); $table->string('success_callback_type')->default(SignupChannel::CALLBACK_TYPE_WEB); $table->string('mini_program_callback_path')->nullable(); $table->string('mini_program_callback_method')->default(SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO); $table->text('remark')->nullable(); $table->timestamps(); }); Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('mobile')->unique(); $table->string('name')->nullable(); $table->string('email')->nullable()->unique(); $table->string('company')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->rememberToken(); $table->foreignId('first_channel_id')->nullable(); $table->string('first_channel_code')->nullable(); $table->timestamp('first_channel_entered_at')->nullable(); $table->foreignId('last_channel_id')->nullable(); $table->string('last_channel_code')->nullable(); $table->timestamp('last_channel_entered_at')->nullable(); $table->timestamps(); }); Schema::create('applications', function (Blueprint $table) { $table->id(); $table->foreignId('user_id'); $table->foreignId('competition_id'); $table->foreignId('signup_channel_id')->nullable(); $table->string('signup_channel_code')->nullable(); $table->string('signup_channel_state', 2048)->nullable(); $table->timestamp('signup_channel_entered_at')->nullable(); $table->string('status')->default('draft'); $table->string('player_name')->nullable(); $table->string('school')->nullable(); $table->string('degree')->nullable(); $table->string('contact_email')->nullable(); $table->string('contact_mobile')->nullable(); $table->string('entry_group')->nullable(); $table->string('company_name')->nullable(); $table->string('project_name')->nullable(); $table->string('track')->nullable(); $table->string('location_country')->nullable(); $table->string('location_province')->nullable(); $table->string('location_city')->nullable(); $table->string('oversea_country')->nullable(); $table->text('intro')->nullable(); $table->timestamp('promise_signed_at')->nullable(); $table->mediumText('promise_signature')->nullable(); $table->timestamp('submitted_at')->nullable(); $table->timestamps(); $table->unique(['user_id', 'competition_id']); }); Schema::create('application_files', function (Blueprint $table) { $table->id(); $table->foreignId('application_id'); $table->string('kind'); $table->string('disk')->default('public'); $table->string('path'); $table->string('original_name'); $table->unsignedBigInteger('size')->default(0); $table->string('mime')->nullable(); $table->timestamps(); }); Schema::create('application_review_records', function (Blueprint $table) { $table->id(); $table->foreignId('application_id'); $table->timestamps(); }); Schema::create('application_review_scores', function (Blueprint $table) { $table->id(); $table->foreignId('application_id'); $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(); }); } /** * @param array $overrides */ private function competition(string $slug, array $overrides = []): Competition { return Competition::query()->create(array_merge([ 'slug' => $slug, 'name' => $slug, 'status' => 'signup_open', 'published' => true, 'signup_open_at' => now()->subHour(), 'signup_close_at' => now()->addHour(), ], $overrides)); } /** * @param array $overrides */ private function channel(Competition $competition, string $code, array $overrides = []): SignupChannel { $channel = new SignupChannel(array_merge([ 'channel_name' => $code, 'status' => SignupChannel::STATUS_ENABLED, 'shared_secret' => "secret-{$code}", 'entry_encryption_enabled' => true, 'success_callback_url' => 'https://callback.example.com/result', 'success_callback_type' => SignupChannel::CALLBACK_TYPE_WEB, 'mini_program_callback_path' => null, 'mini_program_callback_method' => SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO, ], $overrides)); $channel->channel_code = $code; $competition->signupChannels()->save($channel); return $channel; } /** * @param array $overrides * @return array */ private function signed(SignupChannel $channel, array $overrides = []): array { $plaintext = array_merge([ 'channel_code' => $channel->channel_code, 'timestamp' => (string) time(), 'name' => '张三', 'mobile' => '13800138000', 'company_name' => '测试企业', 'state' => 'state-A', ], $overrides); $parameters = $plaintext; $parameters['name'] = $plaintext['name'] === '' ? '' : ChannelEntryEncryption::encrypt($plaintext['name'], (string) $channel->encryption_public_key); $parameters['mobile'] = $plaintext['mobile'] === '' ? '' : ChannelEntryEncryption::encrypt($plaintext['mobile'], (string) $channel->encryption_public_key); $parameters['company_name'] = $plaintext['company_name'] === '' ? '' : ChannelEntryEncryption::encrypt($plaintext['company_name'], (string) $channel->encryption_public_key); $parameters['hash'] = ChannelEntrySignature::make($parameters, $channel->shared_secret); return $parameters; } /** * @param array $parameters */ private function assertChannelEntryError(array $parameters, string $errorCode): void { $this->get('/channel-entry?'.http_build_query($parameters)) ->assertOk() ->assertSee("错误码:{$errorCode}"); } /** * @return array */ private function submitPayload( string $trackCode, string $email = 'test@example.com', string $mobile = '13800138001' ): array { return [ 'player_name' => '张三', 'school' => '测试大学', 'degree' => '本科', 'contact_email' => $email, 'contact_mobile' => $mobile, 'entry_group' => '创新组', 'company_name' => null, 'project_name' => '测试项目', 'track' => $trackCode, 'location_country' => '中国', 'location_province' => '江苏省', 'location_city' => '南京市', 'intro' => '项目简介', 'commitment_accepted' => true, 'promise_signature' => 'data:image/png;base64,AAAA', ]; } /** * @param array $query */ private function callbackHash(array $query, string $secret): string { return hash('sha256', implode('&', [ 'state='.trim((string) ($query['state'] ?? '')), 'channel_code='.trim((string) ($query['channel_code'] ?? '')), 'application_id='.trim((string) ($query['application_id'] ?? '')), 'status='.trim((string) ($query['status'] ?? '')), 'submitted_at='.trim((string) ($query['submitted_at'] ?? '')), 'secret='.$secret, ])); } }