diff --git a/app/Console/Commands/ConnectTeamSystemDatabase.php b/app/Console/Commands/ConnectTeamSystemDatabase.php new file mode 100644 index 00000000..576183d0 --- /dev/null +++ b/app/Console/Commands/ConnectTeamSystemDatabase.php @@ -0,0 +1,75 @@ +environment(['local', 'development', 'testing'])) { + $this->error('此命令仅用于本地开发环境,不修改生产服务器连接。'); + return self::FAILURE; + } + + $current = $configuration->current(); + $password = $this->option('password-stdin') + ? rtrim((string) fgets(STDIN, 1002), "\r\n") + : (string) ($current['password'] ?? ''); + + try { + $settings = $profile->settings($password); + } catch (Throwable) { + $this->warn('本机尚未保存有效的团队密码。请在系统数据库页面输入一次密码;已有配置未修改,无需另配上游账号。'); + return VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED; + } + + try { + // 只探测最新端点,不先连接可能已停用的旧服务器。 + $result = $configuration->probe($settings); + $settings['prefix'] = $result['prefix']; + if ($this->matches($current, $settings)) { + $this->info('本机已连接最新团队系统数据库,业务镜像共用此连接。'); + return self::SUCCESS; + } + } catch (Throwable) { + $this->error('最新团队数据库接入未通过验证,原配置已保留。请检查网络、TLS和共享库访问权限;不要改填上游账号。'); + return self::FAILURE; + } + + try { + $configuration->persist($settings); + } catch (Throwable) { + $this->error('本机配置保存或复核失败。请核对私有配置备份和系统连接状态;不要仅修改运行模式或绕过验证。'); + return self::FAILURE; + } + $this->info('已备份并更新本机系统库、业务镜像库连接。请重新登录;未迁移或同步业务数据。'); + return self::SUCCESS; + } + + private function matches(array $current, array $settings): bool + { + foreach (['host', 'port', 'database', 'username', 'password', 'prefix', 'tls_required', 'tls_verify_server_cert', 'tls_ca'] as $key) { + if (($current[$key] ?? null) != ($settings[$key] ?? null)) return false; + } + $mirror = (array) config('database.connections.business_mirror'); + return ($current['mode'] ?? '') === 'shared' + && config('upstreams.stored_configuration.enabled', false) + && ! config('upstreams.local_configuration.enabled', false) + && data_get($mirror, 'read.host.0') === $settings['host'] + && data_get($mirror, 'write.host.0') === $settings['host'] + && (int) ($mirror['port'] ?? 0) === (int) $settings['port'] + && ($mirror['database'] ?? '') === $settings['mirror_database'] + && ($mirror['username'] ?? '') === $settings['username'] + && ($mirror['password'] ?? '') === $settings['password'] + && empty($mirror['unix_socket']); + } +} diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index f48b2e60..e2b437f9 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -175,7 +175,7 @@ class SystemDatabaseConfiguration : $this->current()['prefix']; $this->assertValidPrefix($prefix); - $updated = $this->replaceEnvironmentValues($current, [ + $values = [ 'DB_CONNECTION' => 'mysql', 'DB_HOST' => (string) $settings['host'], // Laravel prioritizes split read/write hosts, URL and socket over @@ -202,7 +202,22 @@ class SystemDatabaseConfiguration // 另外维护完整的 DB__* 连接参数。 'UPSTREAM_STORED_CONFIG_ENABLED' => 'true', 'UPSTREAM_LOCAL_CONFIG_ENABLED' => 'false', - ]); + ]; + if (isset($settings['mirror_database'])) { + // 团队接入统一两个库的端点,避免旧 BUSINESS_MIRROR_DB_* 覆盖新系统连接。 + $values += [ + 'APP_INSTALLED' => 'true', + 'BUSINESS_MIRROR_DB_HOST' => (string) $settings['host'], + 'BUSINESS_MIRROR_DB_HOST_READ' => (string) $settings['host'], + 'BUSINESS_MIRROR_DB_HOST_WRITE' => (string) $settings['host'], + 'BUSINESS_MIRROR_DB_PORT' => (string) $settings['port'], + 'BUSINESS_MIRROR_DB_DATABASE' => (string) $settings['mirror_database'], + 'BUSINESS_MIRROR_DB_USERNAME' => (string) $settings['username'], + 'BUSINESS_MIRROR_DB_PASSWORD' => (string) $settings['password'], + 'BUSINESS_MIRROR_DB_SOCKET' => '', + ]; + } + $updated = $this->replaceEnvironmentValues($current, $values); try { if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) { @@ -511,6 +526,14 @@ class SystemDatabaseConfiguration 'DB_PASSWORD', 'DB_PREFIX', 'DB_SOCKET', + 'BUSINESS_MIRROR_DB_HOST', + 'BUSINESS_MIRROR_DB_HOST_READ', + 'BUSINESS_MIRROR_DB_HOST_WRITE', + 'BUSINESS_MIRROR_DB_PORT', + 'BUSINESS_MIRROR_DB_DATABASE', + 'BUSINESS_MIRROR_DB_USERNAME', + 'BUSINESS_MIRROR_DB_PASSWORD', + 'BUSINESS_MIRROR_DB_SOCKET', 'MYSQL_ATTR_SSL_CIPHER', 'MYSQL_ATTR_SSL_CA', 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', diff --git a/app/Utilities/TeamSystemDatabaseProfile.php b/app/Utilities/TeamSystemDatabaseProfile.php index a4616ccd..1443a3f0 100644 --- a/app/Utilities/TeamSystemDatabaseProfile.php +++ b/app/Utilities/TeamSystemDatabaseProfile.php @@ -48,6 +48,7 @@ class TeamSystemDatabaseProfile 'host' => $this->requiredString($profile, 'host'), 'port' => $this->requiredPort($profile), 'database' => $this->requiredString($profile, 'database'), + 'mirror_database' => 'caiwu_business_mirror', 'username' => $this->requiredString($profile, 'username'), 'password' => $password, 'tls_required' => true, diff --git a/deploy/local-init.sh b/deploy/local-init.sh index e2a32135..dc1bc7b3 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -316,6 +316,23 @@ if [ "$db_host" = "mysql" ]; then fi +# 已保存的团队密码可解锁最新端点;服务器迁移不应要求每台开发机手改 .env。 +# 无有效团队密码的首次引导保持原状,仍通过网页完成一次接入。 +echo "==> 自动对齐本机团队共享连接..." +if docker compose -f "$compose_file" run --rm --no-deps -T --entrypoint sh app -c \ + 'jiangling-entrypoint true && exec runuser -u www-data -- php artisan system-database:connect-team --no-interaction --no-ansi'; then + team_connect_status=0 +else + team_connect_status=$? +fi +if [ "$team_connect_status" -ne 0 ] && [ "$team_connect_status" -ne 13 ]; then + echo "错误:本机未完成自动接入;请先按上方提示核对连接和私有配置备份。" >&2 + exit "$team_connect_status" +fi + +db_host="$(read_env_value DB_HOST)" +app_installed="$(read_env_value APP_INSTALLED | tr '[:upper:]' '[:lower:]')" + docker compose -f "$compose_file" up -d --no-build app # 容器可能已经存在而没有重新执行 entrypoint。显式复用同一权限初始化逻辑, diff --git a/docs/通用上游数据接入.md b/docs/通用上游数据接入.md index 3f16c069..619c63a3 100644 --- a/docs/通用上游数据接入.md +++ b/docs/通用上游数据接入.md @@ -30,6 +30,10 @@ php artisan business-mirror:register database_17 public example_orders \ ### 已有公司、店铺、商标页在新人电脑没有数据 +`deploy/local-init.sh` 会先使用本机已经保存的团队密码,按当前代码的加密团队资料验证最新端点;验证成功后自动备份并对齐系统库和业务镜像库的私有配置。这样服务器迁移后不必每台开发机手动修改主机、端口或上游开关。当前连接已正确时不会重复改写配置;验证失败保留原配置。此自动修复只在本地开发运行,不修改生产端点,不执行迁移或业务同步。 + +已有本地容器可由 Codex 直接执行 `docker compose -f docker-compose.local.yml exec -T -u www-data app php artisan system-database:connect-team`。从未保存团队密码的机器返回“首次接入”状态,仍只需在网页输入一次系统数据库密码;自动化接入可用 `--password-stdin` 安全传入,不提供明文密码命令行参数。不能把普通测试管理员的网页登录密码当成系统数据库密码。 + 这些页面优先读取 `caiwu_business_mirror`。本机 Docker 正常、临时引导库有用户,并不代表已经连接团队共享库。Codex 应先在最新主线执行 `deploy/local-init.sh`,通过系统数据库入口完成一次共享接入和重新登录,再运行 `system-database:verify` 并检查相应 `business-mirror:status`。镜像连接默认复用系统数据库主机、端口、账号和 TLS,不要求新人另填 SQL Server 密码,也不要把 `furniture3d_prod` 填成 `operations_replica`。 已有镜像可读时,上游开关关闭或网络暂不可达不应阻断页面。读取失败不能解释为真实记录数为 0。只有探索尚未接入的新表时,才需要使用对应上游的只读连接;不要为了让已有资料页出数据,在每个开发者的引导库重复登记生产来源。 diff --git a/tests/Feature/Installation/ConnectTeamSystemDatabaseTest.php b/tests/Feature/Installation/ConnectTeamSystemDatabaseTest.php new file mode 100644 index 00000000..2a6cacc4 --- /dev/null +++ b/tests/Feature/Installation/ConnectTeamSystemDatabaseTest.php @@ -0,0 +1,94 @@ +'mysql','host'=>'new-team.test','port'=>8444,'database'=>'shared_system', + 'mirror_database'=>'caiwu_business_mirror','username'=>'team_user','password'=>'fixture-secret', + 'prefix'=>'ak_','tls_required'=>true,'tls_verify_server_cert'=>false,'tls_ca'=>'']; + } + + private function configureProfile(): void + { + $this->app->instance(TeamSystemDatabaseProfile::class, new TeamSystemDatabaseProfile( + TeamSystemDatabaseProfile::seal($this->settings(), 'fixture-secret') + )); + } + + public function test_existing_password_repairs_old_endpoint_without_connecting_to_old_database(): void + { + $this->configureProfile(); + $configuration=Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn(array_replace($this->settings(), ['host'=>'retired.test','mode'=>'shared'])); + $configuration->shouldReceive('probe')->once()->with(Mockery::on(fn($s)=>$s['host']==='new-team.test'))->andReturn(['prefix'=>'ak_']); + $configuration->shouldReceive('persist')->once()->with($this->settings())->andReturn('private-backup'); + $this->app->instance(SystemDatabaseConfiguration::class,$configuration); + $this->artisan('system-database:connect-team')->expectsOutputToContain('已备份并更新本机')->assertSuccessful(); + } + + public function test_first_bootstrap_without_team_password_does_not_write_or_probe(): void + { + $this->configureProfile(); + $configuration=Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn(['password'=>'bootstrap-only','mode'=>'bootstrap']); + $configuration->shouldNotReceive('probe'); + $configuration->shouldNotReceive('persist'); + $this->app->instance(SystemDatabaseConfiguration::class,$configuration); + $this->artisan('system-database:connect-team')->expectsOutputToContain('输入一次密码')->assertExitCode(13); + } + + public function test_current_team_connection_is_not_rewritten_on_every_initialization(): void + { + $this->configureProfile(); + $s=$this->settings(); + config(['upstreams.stored_configuration.enabled'=>true,'upstreams.local_configuration.enabled'=>false, + 'database.connections.business_mirror'=>['read'=>['host'=>[$s['host']]],'write'=>['host'=>[$s['host']]],'port'=>$s['port'], + 'database'=>$s['mirror_database'],'username'=>$s['username'],'password'=>$s['password'],'unix_socket'=>'']]); + $configuration=Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn($s+['mode'=>'shared']); + $configuration->shouldReceive('probe')->once()->andReturn(['prefix'=>'ak_']); + $configuration->shouldNotReceive('persist'); + $this->app->instance(SystemDatabaseConfiguration::class,$configuration); + $this->artisan('system-database:connect-team')->expectsOutputToContain('已连接最新团队')->assertSuccessful(); + } + + public function test_failed_new_endpoint_probe_keeps_original_configuration(): void + { + $this->configureProfile(); + $configuration=Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn($this->settings()+['mode'=>'shared']); + $configuration->shouldReceive('probe')->once()->andThrow(new RuntimeException('private connection details')); + $configuration->shouldNotReceive('persist'); + $this->app->instance(SystemDatabaseConfiguration::class,$configuration); + $this->artisan('system-database:connect-team')->expectsOutputToContain('原配置已保留')->doesntExpectOutputToContain('private connection details')->assertFailed(); + } + + public function test_production_never_rewrites_its_internal_endpoint(): void + { + $this->app->detectEnvironment(fn()=>'production'); + $configuration=Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldNotReceive('current'); + $this->app->instance(SystemDatabaseConfiguration::class,$configuration); + $this->artisan('system-database:connect-team')->expectsOutputToContain('不修改生产服务器连接')->assertFailed(); + } + + public function test_persistence_failure_does_not_claim_connection_success(): void + { + $this->configureProfile(); + $configuration=Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn(array_replace($this->settings(), ['host'=>'retired.test','mode'=>'shared'])); + $configuration->shouldReceive('probe')->once()->andReturn(['prefix'=>'ak_']); + $configuration->shouldReceive('persist')->once()->andThrow(new RuntimeException('private details')); + $this->app->instance(SystemDatabaseConfiguration::class,$configuration); + $this->artisan('system-database:connect-team')->expectsOutputToContain('请核对私有配置备份')->doesntExpectOutputToContain('已备份并更新')->assertFailed(); + } +} diff --git a/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php index 13c6b4a5..63489af3 100644 --- a/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php +++ b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php @@ -7,6 +7,15 @@ use Symfony\Component\Process\Process; class LocalSharedDatabaseStatusTest extends TestCase { + public function test_team_repair_runs_before_application_start_and_ready_check(): void + { + $script=file_get_contents(dirname(__DIR__,3).'/deploy/local-init.sh'); + $repair=strpos($script,'php artisan system-database:connect-team'); + $this->assertNotFalse($repair); + $this->assertLessThan(strpos($script,'up -d --no-build app'),$repair); + $this->assertStringContainsString('run --rm --no-deps -T --entrypoint sh app', $script); + } + /** @dataProvider verificationStates */ public function test_initializer_reports_the_verified_state(int $verificationExit, int $expectedExit, string $message): void { diff --git a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php index 7d90e8a6..e4460468 100644 --- a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php +++ b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php @@ -14,7 +14,8 @@ class SystemDatabasePersistenceTest extends TestCase $environmentPath = storage_path('framework/testing-legacy-routes.env'); $original = "DB_HOST=mysql\nDB_HOST_READ=old-read\nDB_HOST_WRITE=old-write\n" . "DATABASE_URL=mysql://fixture:fixture@old-url/old_database\nDB_SOCKET=/tmp/old.sock\n" - . "DB_HOST=old-duplicate\nSYSTEM_DATABASE_MODE=bootstrap\nUNRELATED_OPTION=keep\n"; + . "DB_HOST=old-duplicate\nSYSTEM_DATABASE_MODE=bootstrap\nUNRELATED_OPTION=keep\n" + . "BUSINESS_MIRROR_DB_HOST=old-mirror\nBUSINESS_MIRROR_DB_HOST_READ=old-mirror-read\nBUSINESS_MIRROR_DB_HOST_WRITE=old-mirror-write\nBUSINESS_MIRROR_DB_PORT=13306\nBUSINESS_MIRROR_DB_SOCKET=/tmp/mirror.sock\n"; file_put_contents($environmentPath, $original); $configuration = new class($environmentPath) extends SystemDatabaseConfiguration { public array $verified = []; @@ -29,8 +30,16 @@ class SystemDatabasePersistenceTest extends TestCase 'host' => 'shared.example.test', 'port' => 8443, 'database' => 'shared_system', 'username' => 'fixture_user', 'password' => 'fixture_password', 'prefix' => '', 'tls_required' => true, 'tls_verify_server_cert' => false, + 'mirror_database' => 'caiwu_business_mirror', ]); $saved = \Dotenv\Dotenv::parse(file_get_contents($environmentPath)); + $this->assertSame('shared.example.test', $saved['BUSINESS_MIRROR_DB_HOST']); + $this->assertSame('shared.example.test', $saved['BUSINESS_MIRROR_DB_HOST_READ']); + $this->assertSame('shared.example.test', $saved['BUSINESS_MIRROR_DB_HOST_WRITE']); + $this->assertSame('8443', $saved['BUSINESS_MIRROR_DB_PORT']); + $this->assertSame('', $saved['BUSINESS_MIRROR_DB_SOCKET']); + $this->assertSame('caiwu_business_mirror', $saved['BUSINESS_MIRROR_DB_DATABASE']); + $this->assertSame('fixture_password', $configuration->verified['BUSINESS_MIRROR_DB_PASSWORD']); foreach (['DB_HOST', 'DB_HOST_READ', 'DB_HOST_WRITE'] as $key) { $this->assertSame('shared.example.test', $saved[$key]); $this->assertSame('shared.example.test', $configuration->verified[$key]);