diff --git a/AGENTS.md b/AGENTS.md index bc89741f..9f11f752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,5 +152,11 @@ - 每个来源最多安排 1 台主中继和 1 台备用中继;同一来源同一时刻只允许一个远端监听成功,备用机低频重试。隧道中断时页面继续读取该来源旧快照,禁止绕过缓存直接重试上游。 - 系统数据库的启动连接只允许存在于服务器私有 `runtime/.env`;超级管理员和普通管理员可在“数据库管理”中修改,但保存前必须验证 TLS、系统必需表和用户数据,并在持久化 `storage` 中生成私有备份。 - 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码在 `system_database_connections` 中同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password` 供受信任开发机共享;真实值仍不得进入代码、提交、PR、共享文档或日志。填写同一套系统数据库参数后必须自动关闭 `UPSTREAM_LOCAL_CONFIG_ENABLED` 并优先读取共享配置,禁止为了共享云数据库密码而复制生产 `APP_KEY`。只有隔离排错或尚未迁移的旧环境才允许显式开启本机覆盖,并通过私有 `DB_<来源>_*` 环境变量提供完整连接。系统数据库密码轮换前必须先完成共享云数据库密文重加密和备份。中继开发机只保留已收紧 ACL 的隧道端点配置,不保存数据库密码。 +- 本地开发的标准入口只有“系统设置 → 数据库管理 → 系统数据库”。开发者填写并验证同一套财务系统配置数据库后,系统用户、侧边栏、系统设置和云数据库登记必须继续使用该系统数据库;不得再把主业务连接的 `DB_HOST` 改回本机 MySQL 容器,否则会形成一套互不相干的本地数据,导致用户、配置、来源映射和业务结果与团队不一致。 +- 两个现有来源的标识和数据库映射固定为:`jiansun` 对应 `JianSun` 旧业务库,`operations_replica` 对应 `JSReportReplica` 的 `rpt` 只读视图。不得把 `shujuku`、本机测试库名或历史截图中的临时名称写回共享记录,也不得交换两个来源的账号或数据库名。 +- 开发者拉取包含本规则的最新代码后,必须确保 `docker-compose.local.yml` 已挂载 `./modules:/var/www/html/modules`,并至少重建一次应用容器、清除 Laravel 配置缓存。禁止只重启旧镜像后根据旧模块结果判断共享配置无效。 +- 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 网络代理。每台开发机的应用容器仍直接连接上游;只有在已证明 TCP 或 SQL Server 预登录阶段被当前网络拦截时才处理白名单、VPN 或受限隧道,不得把数据库名错误、共享密文错误、驱动 TLS 协商错误统称为“权限问题”。 +- 验收不能只做端口探测或数据库客户端直连。必须在本地应用容器内、清空本机上游凭据覆盖后,分别验证两套来源的共享配置解析、`SELECT 1` 和至少一个真实只读业务查询;输出不得包含地址、账号、密码或完整 DSN。数据库客户端看到的全部对象与项目页面结果并不等价,项目还受固定适配器、允许范围、公司/用户权限和本地快照约束;缺少业务适配器或刷新器时应实现对应读取链路,不得绕过缓存把页面直接接到 PDO。 详细 SOP 见 `docs/开发与部署SOP.md`。 diff --git a/app/Models/Settings/DatabaseConnection.php b/app/Models/Settings/DatabaseConnection.php index b29d98a9..de430301 100644 --- a/app/Models/Settings/DatabaseConnection.php +++ b/app/Models/Settings/DatabaseConnection.php @@ -38,6 +38,12 @@ class DatabaseConnection extends Model public function getPasswordAttribute(?string $value): ?string { + $shared = (string) ($this->attributes['shared_password'] ?? ''); + + if ($shared !== '') { + return app(UpstreamCredentialCipher::class)->decrypt($shared); + } + return $value === null ? null : Crypt::decryptString($value); } @@ -57,12 +63,6 @@ class DatabaseConnection extends Model public function resolvedPassword(): ?string { - $shared = (string) ($this->attributes['shared_password'] ?? ''); - - if ($shared !== '') { - return app(UpstreamCredentialCipher::class)->decrypt($shared); - } - return $this->password; } } diff --git a/config/jiansun.php b/config/jiansun.php index 11278728..836e5df2 100644 --- a/config/jiansun.php +++ b/config/jiansun.php @@ -26,6 +26,11 @@ return [ 'password' => env('DB_JIANSUN_PASSWORD'), 'odbc_driver' => env('DB_JIANSUN_ODBC_DRIVER', 'FreeTDS'), 'tds_version' => env('DB_JIANSUN_TDS_VERSION', '7.4'), + // 旧 SQL Server 的可选 TLS 预登录不稳定;仅本地开发默认关闭协商。 + 'encryption' => env( + 'DB_JIANSUN_ENCRYPTION', + env('APP_ENV', 'production') === 'production' ? 'request' : 'off' + ), 'connect_timeout' => (int) env('DB_JIANSUN_CONNECT_TIMEOUT', 5), 'query_timeout' => (int) env('DB_JIANSUN_QUERY_TIMEOUT', 15), ], diff --git a/config/operations_replica.php b/config/operations_replica.php index 95dfb177..a4747e8a 100644 --- a/config/operations_replica.php +++ b/config/operations_replica.php @@ -17,6 +17,11 @@ return [ 'password' => env('DB_OPERATIONS_REPLICA_PASSWORD'), 'odbc_driver' => env('DB_OPERATIONS_REPLICA_ODBC_DRIVER', 'FreeTDS'), 'tds_version' => env('DB_OPERATIONS_REPLICA_TDS_VERSION', '7.4'), + // 旧 SQL Server 的可选 TLS 预登录不稳定;仅本地开发默认关闭协商。 + 'encryption' => env( + 'DB_OPERATIONS_REPLICA_ENCRYPTION', + env('APP_ENV', 'production') === 'production' ? 'request' : 'off' + ), 'connect_timeout' => (int) env('DB_OPERATIONS_REPLICA_CONNECT_TIMEOUT', 5), 'query_timeout' => (int) env('DB_OPERATIONS_REPLICA_QUERY_TIMEOUT', 15), // 仅部分 ODBC 驱动支持该提示;它不是权限控制,默认不强行开启。 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index fc3801de..452aabf0 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -27,6 +27,7 @@ services: - ./app:/var/www/html/app - ./config:/var/www/html/config - ./database:/var/www/html/database + - ./modules:/var/www/html/modules - ./resources:/var/www/html/resources - ./routes:/var/www/html/routes networks: diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index cc74432b..685c2f49 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -222,6 +222,8 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300 开发机完成系统数据库配置后,系统自动设置 `UPSTREAM_STORED_CONFIG_ENABLED=true` 和 `UPSTREAM_LOCAL_CONFIG_ENABLED=false`,连接名称、地址、数据库名、账号、允许范围和共享密码均从系统数据库读取。不同开发机可以使用不同 `APP_KEY`,不得复制生产 `APP_KEY`。 +完成上述配置后,不得再把主业务连接的 `DB_HOST` 改回本机 MySQL 容器。系统用户、侧边栏、系统设置和云数据库登记都属于同一套系统数据库数据;切回本机 MySQL 会形成一套独立数据,页面可能仍可登录,但用户数量、连接名称、来源映射和业务结果都可能与其他开发机不同。标准来源映射固定为:`jiansun` 对应 `JianSun` 旧业务库,`operations_replica` 对应 `JSReportReplica` 的 `rpt` 只读视图。系统数据库负责统一保存连接配置,不代理网络流量;每台开发机仍由自己的应用容器直接连接上游。 + 只有隔离排错或尚未迁移的旧环境需要直接使用本机数据库参数时,才在私有 `runtime/.env` 设置 `UPSTREAM_LOCAL_CONFIG_ENABLED=true`,并完整填写 `DB_JIANSUN_*` 与 `DB_OPERATIONS_REPLICA_*`。开启后,该开发环境完全忽略共享云数据库配置。系统数据库密码轮换前必须先备份 `system_database_connections` 并使用新旧密码完成 `shared_password` 重加密;任何密码、密钥和私有配置不得进入 Git、聊天、日志或截图。 `JIANSUN_ALLOWED_SCOPES` 支持逗号分隔的精确范围和 `prefix.*`。首次上线先开放完成探测的小表范围,例如 `invoice-deductions.*`;约 280 万行核心表对应范围必须单独验证,未验证前不得填写 `*`。 diff --git a/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php b/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php index dc454cf7..3a6975f8 100644 --- a/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php +++ b/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php @@ -147,6 +147,7 @@ PHP; $pass = (string) ($connection['password'] ?? ''); $driver = trim((string) ($connection['odbc_driver'] ?? 'FreeTDS'), '{} '); $tdsVersion = trim((string) ($connection['tds_version'] ?? '7.4')); + $encryption = strtolower(trim((string) ($connection['encryption'] ?? 'off'))); $connectTimeout = min(max((int) ($connection['connect_timeout'] ?? 5), 1), 30); $queryTimeout = min(max((int) ($connection['query_timeout'] ?? 15), 1), 120); $applicationIntent = trim((string) ($connection['application_intent'] ?? '')); @@ -155,6 +156,7 @@ PHP; $host === '' || $db === '' || $user === '' || $pass === '' || $driver === '' || preg_match('/[;\x00-\x1F]/', $driver) === 1 || preg_match('/^\d+(?:\.\d+)?$/', $tdsVersion) !== 1 + || ! in_array($encryption, ['off', 'request', 'require', 'strict'], true) ) { throw new ServiceUnavailableHttpException(60, '上游数据库配置不完整,请联系管理员。'); } @@ -176,6 +178,7 @@ PHP; $tdsVersion, $connectTimeout ); + $dsn .= self::encryptionAttributes($driver, $encryption); if ($applicationIntent !== '') { $dsn .= ';ApplicationIntent=' . self::quoteOdbcValue($applicationIntent); @@ -217,4 +220,22 @@ PHP; { return '{' . str_replace('}', '}}', $value) . '}'; } + + private static function encryptionAttributes(string $driver, string $mode): string + { + if (strcasecmp($driver, 'FreeTDS') === 0) { + return ';Encryption=' . $mode; + } + + $encrypt = match ($mode) { + 'off', 'request' => 'no', + 'require' => 'yes', + 'strict' => 'strict', + }; + $attributes = ';Encrypt=' . $encrypt; + + return $mode === 'strict' + ? $attributes + : $attributes . ';TrustServerCertificate=yes'; + } } diff --git a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php index a3accbeb..4b64cd32 100644 --- a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php +++ b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php @@ -62,6 +62,7 @@ class UpstreamStoredConfiguration return match (trim($path, '.')) { 'enabled' => $connection->enabled, 'allowed_scopes' => $connection->allowed_scopes ?? [], + 'connection' => $this->connection($connection, $default), 'connection.host' => $connection->host, 'connection.port' => $connection->port, 'connection.database' => $connection->database_name, @@ -75,6 +76,23 @@ class UpstreamStoredConfiguration }; } + private function connection(DatabaseConnection $connection, mixed $default): array + { + $configured = is_array($default) ? $default : []; + + return array_replace($configured, [ + 'host' => $connection->host, + 'port' => $connection->port, + 'database' => $connection->database_name, + 'username' => $connection->username, + 'password' => $this->password($connection, $configured['password'] ?? null), + 'odbc_driver' => $connection->odbc_driver, + 'tds_version' => $connection->tds_version, + 'connect_timeout' => $connection->connect_timeout, + 'query_timeout' => $connection->query_timeout, + ]); + } + private function password(DatabaseConnection $connection, mixed $localPassword): mixed { try { diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index fa11c9e8..6637d751 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -140,6 +140,8 @@ class UpstreamDatabasesTest extends FeatureTestCase config(['upstreams.local_configuration.enabled' => false]); $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); + // 兼容尚未重建的旧本地镜像:旧模块仍直接读取 password 属性。 + $this->assertSame('shared-secret', $connection->fresh()->password); $this->assertSame( 'shared-secret', $sources->value('jiansun', 'connection.password') @@ -149,6 +151,44 @@ class UpstreamDatabasesTest extends FeatureTestCase } } + public function test_whole_connection_comes_from_shared_system_database(): void + { + $connection = $this->connection('shared-secret'); + $connection->update([ + 'source_key' => 'jiansun', + 'host' => 'shared-db.test', + 'port' => 14330, + 'database_name' => 'shared_database', + 'username' => 'shared_reader', + ]); + config([ + 'upstreams.local_configuration.enabled' => false, + 'jiansun.connection' => [ + 'host' => null, + 'port' => 1433, + 'database' => null, + 'username' => null, + 'password' => null, + 'odbc_driver' => 'FreeTDS', + 'tds_version' => '7.4', + 'encryption' => 'off', + 'connect_timeout' => 5, + 'query_timeout' => 15, + 'application_intent' => '', + ], + ]); + + $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); + $resolved = $sources->value('jiansun', 'connection', []); + + $this->assertSame('shared-db.test', $resolved['host']); + $this->assertSame(14330, $resolved['port']); + $this->assertSame('shared_database', $resolved['database']); + $this->assertSame('shared_reader', $resolved['username']); + $this->assertSame('shared-secret', $resolved['password']); + $this->assertSame('off', $resolved['encryption']); + } + public function test_global_wildcard_scope_is_rejected(): void { $this->withExceptionHandling();