修复开发机共享连接不跟随团队服务器迁移 #90
75
app/Console/Commands/ConnectTeamSystemDatabase.php
Normal file
75
app/Console/Commands/ConnectTeamSystemDatabase.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use App\Utilities\TeamSystemDatabaseProfile;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ConnectTeamSystemDatabase extends Command
|
||||
{
|
||||
protected $signature = 'system-database:connect-team {--password-stdin : 从标准输入读取首次接入密码,不使用命令行明文参数}';
|
||||
|
||||
protected $description = '本地开发环境使用已有密码跟随最新团队连接资料,不迁移或写入共享数据库';
|
||||
|
||||
public function handle(SystemDatabaseConfiguration $configuration, TeamSystemDatabaseProfile $profile): int
|
||||
{
|
||||
if (! app()->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']);
|
||||
}
|
||||
}
|
||||
@ -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_<SOURCE>_* 连接参数。
|
||||
'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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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。显式复用同一权限初始化逻辑,
|
||||
|
||||
@ -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。只有探索尚未接入的新表时,才需要使用对应上游的只读连接;不要为了让已有资料页出数据,在每个开发者的引导库重复登记生产来源。
|
||||
|
||||
94
tests/Feature/Installation/ConnectTeamSystemDatabaseTest.php
Normal file
94
tests/Feature/Installation/ConnectTeamSystemDatabaseTest.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Installation;
|
||||
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use App\Utilities\TeamSystemDatabaseProfile;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ConnectTeamSystemDatabaseTest extends TestCase
|
||||
{
|
||||
private function settings(): array
|
||||
{
|
||||
return ['driver'=>'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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user