修复开发机共享连接不跟随团队服务器迁移 #90

Merged
WuYongJie merged 1 commits from fix/WuYongJie-local-team-connection into main 2026-08-31 19:43:03 +08:00
8 changed files with 235 additions and 3 deletions

View 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']);
}
}

View File

@ -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',

View File

@ -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,

View File

@ -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。显式复用同一权限初始化逻辑,

View File

@ -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。只有探索尚未接入的新表时,才需要使用对应上游的只读连接;不要为了让已有资料页出数据,在每个开发者的引导库重复登记生产来源。

View 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();
}
}

View File

@ -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
{

View File

@ -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]);