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.

821 lines
37 KiB

<?php
namespace Tests\Feature;
use App\Models\AdminUser;
use App\Models\Application;
use App\Models\ApplicationFile;
use App\Models\Competition;
use App\Models\CompetitionTrack;
use App\Models\SignupChannel;
use App\Models\User;
use App\Support\ChannelEntryEncryption;
use App\Support\ChannelEntrySignature;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
use Tests\CreatesApplication;
class ChannelSignupIntegrationTest extends TestCase
{
use CreatesApplication;
private string $logPath = '/tmp/cxcyds-channel-signup-test.log';
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => '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_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('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);
$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);
$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, ['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('CHANNEL-B', $application->signup_channel_code);
$this->assertSame('state-B', $application->signup_channel_state);
$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->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<string, mixed> $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<string, mixed> $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<string, string> $overrides
* @return array<string, string>
*/
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<string, string> $parameters
*/
private function assertChannelEntryError(array $parameters, string $errorCode): void
{
$this->get('/channel-entry?'.http_build_query($parameters))
->assertOk()
->assertSee("错误码:{$errorCode}");
}
/**
* @return array<string, mixed>
*/
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<string, string> $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,
]));
}
}