diff --git a/AGENTS.md b/AGENTS.md index 80ec75dc..bbd3da12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,7 @@ - 本地开发环境(`APP_ENV=local`)直接执行 `UpstreamQueryCache` resolver 中已经写入代码的只读查询,不等待生产刷新节点、共享快照或 `allowed_scopes`。两套连接资料仍从共享系统数据库自动解析,开发者只填写一次系统数据库密码,不再配置上游环境变量。 - 生产环境(`APP_ENV=production`)继续通过 `UpstreamQueryCache` 读取持久化在共享系统数据库 `upstream_query_cache` 中的按需查询快照;页面冷查询只登记需求,首份数据和后续刷新由 `upstream:snapshots:refresh-all` 生成。 - 两套上游的表、视图和字段结构由命令 `upstream:schema:sync` 低频读取 `INFORMATION_SCHEMA`,保存到共享系统数据库 `upstream_schema_columns`。任何能够直连的开发环境或生产刷新节点都可以生成目录;同步失败必须保留最近一次完整目录。 +- 缺少现成 Repository、页面适配器、生产 `scope` 或共享快照,不代表本地开发无法读取该来源。开发者和 Codex 在 `APP_ENV=local` 中可以直接通过 `UpstreamReadOnlyQueryFactory::forSource()` 对数据字典中选定的对象执行少量、限定字段和返回量的只读 `SELECT/WITH`,用于确认业务行、理解字段并实现功能;不需要先修改云数据库记录或等待生产刷新。禁止把用户输入拼成 SQL、输出敏感业务行或以此替代合并前的正式 Repository/测试。 - 新来源适配器必须通过 `UpstreamReadOnlyQuery` 执行固定、参数化的只读查询,禁止把原始 PDO 连接暴露给控制器或页面。即使现有数据库账号本身有写权限,应用代码也不得执行写入、高风险语句或存储过程。 - 系统数据库只保存活跃查询登记、参数、访问范围和共享结果,不执行 SQL。禁止把任意 SQL、DSN 或凭据写入任务表让后台解释执行;生产后台进程只能运行已经合并到 `main` 并在 `UpstreamSnapshotRefresher` 中登记的固定查询代码。 - 开发者需要尚未缓存的新表或字段时,必须在任务说明和 PR 中列出来源标识、业务目的、表/视图、字段、筛选与排序、最大返回量、公司/权限范围、缓存时间和失败表现;未说明边界的“把整张表接进来”不得合并。 @@ -165,6 +166,6 @@ - 本机临时引导库的新增迁移由 `deploy/local-init.sh` 和新版容器入口自动补齐;该逻辑只允许在 `SYSTEM_DATABASE_MODE=bootstrap`、MySQL 驱动且主机为固定容器服务名 `mysql` 时运行。连接共享系统数据库后必须自动跳过,普通开发者不需要也不得自行执行 `php artisan migrate`;看到本机迁移 `Pending` 时应重新运行标准初始化脚本,而不是操作共享数据库。 - Codex 排查开发机连接时,必须先确认当前主业务库仍是共享系统数据库,再确认 `UPSTREAM_STORED_CONFIG_ENABLED=true`、`UPSTREAM_LOCAL_CONFIG_ENABLED=false`,随后通过 `UpstreamSourceRegistry` 验证完整连接来自 `system_database_connections`。不得通过填写本机 `DB_JIANSUN_*`、`DB_OPERATIONS_REPLICA_*`、复制他人 `.env` 或统一 `APP_KEY` 来掩盖共享配置读取错误。 - 系统数据库统一保存应用数据、云数据库配置、上游数据字典和生产共享快照,但不是 SQL Server 网络代理。本地开发应用直接使用系统数据库中的连接资料访问两套上游;前提是该开发机网络本身能够到达数据库端点。 -- 开发验收分别验证两套来源的共享配置解析、`SELECT 1` 和至少一个真实只读查询;生产验收还需验证共享快照。所有输出不得包含地址、账号、密码或完整 DSN。页面和 Repository 仍必须通过 `UpstreamReadOnlyQuery` 执行固定、参数化的 `SELECT`,禁止写操作和任意 SQL 输入。 +- 开发验收必须在不复用负责人现有 `.env`、登录会话、容器或数据库卷的全新隔离环境中完成:只填写一次系统数据库密码,然后分别验证两套来源的共享配置解析、`SELECT 1` 和至少一个真实业务表/视图的只读查询。只在负责人已配置环境中测试不算新人接入验收。生产验收还需验证共享快照。所有输出不得包含地址、账号、密码、完整 DSN 或敏感业务行。页面和正式 Repository 仍必须通过 `UpstreamReadOnlyQuery` 执行固定、参数化的 `SELECT`,禁止写操作和任意 SQL 输入。 详细 SOP 见 `docs/开发与部署SOP.md`。 diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index bfff5d15..5d74c9ad 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -2,6 +2,7 @@ namespace App\Utilities; +use Dotenv\Dotenv; use Illuminate\Support\Facades\Artisan; use PDO; use PDOException; @@ -403,11 +404,11 @@ class SystemDatabaseConfiguration } /** - * @return array + * @return array */ protected function freshVerificationEnvironment(): array { - return array_fill_keys([ + $keys = [ 'DB_CONNECTION', 'DB_HOST', 'DB_PORT', @@ -421,7 +422,27 @@ class SystemDatabaseConfiguration 'SYSTEM_DATABASE_MODE', 'UPSTREAM_STORED_CONFIG_ENABLED', 'UPSTREAM_LOCAL_CONFIG_ENABLED', - ], false); + ]; + $environment = array_fill_keys($keys, false); + $contents = file_get_contents($this->environmentPath); + + if ($contents === false) { + throw new RuntimeException('无法读取刚保存的系统数据库配置。'); + } + + try { + $saved = Dotenv::parse($contents); + } catch (Throwable $exception) { + throw new RuntimeException('刚保存的系统数据库配置格式无效。', 0, $exception); + } + + foreach ($keys as $key) { + if (array_key_exists($key, $saved)) { + $environment[$key] = (string) $saved[$key]; + } + } + + return $environment; } private function quoteIdentifier(string $identifier): string diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index a1d130dd..6779b891 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -190,7 +190,9 @@ php artisan upstream:snapshots:refresh-all 系统数据库保存团队配置、数据字典和生产快照,不是 SQL Server 网络代理。开发环境和生产环境的读取方式不同: ```text -本地开发环境 -> 固定 Repository -> 使用共享凭据直接只读上游 +本地开发探索 -> 通用只读入口 -> 使用共享凭据直接读取选定业务表 + +本地功能实现 -> 固定 Repository -> 使用共享凭据直接只读上游 生产环境 -> 登记 scope + 参数 -> 后台固定刷新器 -> 共享查询快照 ``` @@ -202,14 +204,16 @@ php artisan upstream:schema:sync php artisan upstream:schema:sync operations_replica ``` -数据字典只解决“有哪些表和字段”,不复制业务行,也不允许在系统数据库中保存任意 SQL。开发者从目录选定对象后,仍按下面的固定查询闭环实现实际业务数据读取。 +数据字典只解决“有哪些表和字段”,不复制业务行,也不允许在系统数据库中保存任意 SQL。缺少现成 Repository、页面适配器、生产 `scope` 或共享快照时,本地开发并没有被挡住:开发者或 Codex 可以通过 `UpstreamReadOnlyQueryFactory::forSource()` 对目录中选定的对象执行少量、限定字段和返回量的只读 `SELECT/WITH`,先确认真实业务行和字段含义。该探索查询不需要修改云数据库配置或等待生产刷新,但不得输出敏感业务行,也不得接受用户输入的原始 SQL。 + +确认数据后,再按下面的固定查询闭环把它实现成可合并、可测试、可部署的正式功能。Repository 是正式业务代码的交付要求,不是本地访问数据库和开始开发的前置条件。 需要新表、视图、字段或查询条件时,按下面的固定闭环执行: 1. **先声明边界**:任务和 PR 必须写明 `source_key`、业务用途、表或视图、字段、过滤条件、排序、最大返回量、公司/权限范围、期望 TTL 和没有数据时的页面表现。禁止只写“同步整张表”; 2. **实现固定查询**:Repository/适配器通过 `UpstreamReadOnlyQuery` 执行参数化的 `SELECT/WITH`;控制器和页面只调用 Repository,不接触 PDO,不把 SQL 保存到系统数据库; 3. **登记读取契约**:为列表、汇总和选项定义稳定且可审查的 `scope`,通过 `UpstreamQueryCache` 读取,并在对应 `UpstreamSnapshotRefresher` 中登记同名范围。参数必须有限制,列表必须有稳定排序和行数上限; -4. **分支内完成自动测试**:使用脱敏 fixture 或测试替身验证正常、空数据和不可用状态;开发机能够直连时再用少量真实只读数据验证,不得在测试、日志或截图中输出凭据和敏感业务行; +4. **分支内完成自动测试**:使用脱敏 fixture 或测试替身验证正常、空数据和不可用状态;开发机能够直连时,通过通用只读入口或新 Repository 用少量真实只读数据验证,不得在测试、日志或截图中输出凭据和敏感业务行; 5. **本地直接验证**:本地页面/API 直接执行固定只读查询,不需要配置 `allowed_scopes` 或等待首份快照; 6. **生产登记范围**:代码审查和测试通过后合并到 `main`;生产刷新器仍登记精确 `allowed_scopes` 并生成共享快照; 7. **完成双实例验收**:确认首份快照有同步时间,页面响应头为 `fresh` 或允许的 `stale`;随后从第二个应用实例用相同访问范围读取,并证明 resolver 没有再次访问上游; diff --git a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php index e188904f..82ea9f3f 100644 --- a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php +++ b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php @@ -88,28 +88,38 @@ class SystemDatabasePersistenceTest extends TestCase } } - public function test_fresh_verification_does_not_inherit_stale_database_environment(): void + public function test_fresh_verification_uses_values_from_the_just_saved_environment(): void { - $configuration = new class extends SystemDatabaseConfiguration { - public function verificationEnvironment(): array - { - return $this->freshVerificationEnvironment(); - } - }; + $environmentPath = storage_path('framework/testing-fresh-verification.env'); + file_put_contents($environmentPath, implode("\n", [ + 'DB_HOST="new-shared-host.test"', + 'DB_DATABASE="new_shared_database"', + 'DB_PASSWORD="new-shared-password"', + 'SYSTEM_DATABASE_MODE="shared"', + 'UPSTREAM_STORED_CONFIG_ENABLED="true"', + 'UPSTREAM_LOCAL_CONFIG_ENABLED="false"', + '', + ])); - $environment = $configuration->verificationEnvironment(); + try { + $configuration = new class($environmentPath) extends SystemDatabaseConfiguration { + public function verificationEnvironment(): array + { + return $this->freshVerificationEnvironment(); + } + }; - foreach ([ - 'DB_HOST', - 'DB_DATABASE', - 'DB_PASSWORD', - 'DB_PREFIX', - 'SYSTEM_DATABASE_MODE', - 'UPSTREAM_STORED_CONFIG_ENABLED', - 'UPSTREAM_LOCAL_CONFIG_ENABLED', - ] as $key) { - $this->assertArrayHasKey($key, $environment); - $this->assertFalse($environment[$key]); + $environment = $configuration->verificationEnvironment(); + + $this->assertSame('new-shared-host.test', $environment['DB_HOST']); + $this->assertSame('new_shared_database', $environment['DB_DATABASE']); + $this->assertSame('new-shared-password', $environment['DB_PASSWORD']); + $this->assertSame('shared', $environment['SYSTEM_DATABASE_MODE']); + $this->assertSame('true', $environment['UPSTREAM_STORED_CONFIG_ENABLED']); + $this->assertSame('false', $environment['UPSTREAM_LOCAL_CONFIG_ENABLED']); + $this->assertFalse($environment['DB_PREFIX']); + } finally { + @unlink($environmentPath); } } }