修复:统一开发环境上游数据库连接

This commit is contained in:
WuYongJie 2026-07-23 20:49:06 +08:00
parent f5500c6667
commit 4b56c9cd07
9 changed files with 104 additions and 6 deletions

View File

@ -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`。

View File

@ -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;
}
}

View File

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

View File

@ -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 驱动支持该提示;它不是权限控制,默认不强行开启。

View File

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

View File

@ -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 万行核心表对应范围必须单独验证,未验证前不得填写 `*`。

View File

@ -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';
}
}

View File

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

View File

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