diff --git a/AGENTS.md b/AGENTS.md index e00fec51..47d7ed97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,7 @@ - 数据库迁移默认关闭;检测到新增迁移时发布必须停止,获得负责人授权后才可显式开启。 - 新镜像必须先通过本机后端健康检查,再通过公网 HTTPS 页面验收;失败时自动恢复上一个镜像,并保留日志用于排查。 - 发布完成必须报告:分支、提交 SHA、镜像标签、服务器容器状态、真实页面结果、是否执行迁移、清理结果。 +- 容器内所有 `php artisan`、初始化、测试和后台刷新命令必须以 `www-data` 运行;手工命令统一使用 `docker compose exec -u www-data ...` 或 `docker exec -u www-data ...`。禁止默认 `root` 执行后在 `storage`、`bootstrap/cache` 或 `.env` 留下网页进程不可写的文件。入口脚本启动时必须修复旧属主,`app:ready` 必须以 `www-data` 同时验证数据库、管理员和运行目录写权限。 ### 8.6 上游数据库读取规则 @@ -155,8 +156,8 @@ - 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1 -SourceKey <来源标识>` 安装。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。 - 每个来源最多安排 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 容器,否则会形成一套互不相干的本地数据,导致用户、配置、来源映射和业务结果与团队不一致。 +- 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码在 `system_database_connections` 中同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password` 供受信任开发机共享;真实值仍不得进入代码、提交、PR、共享文档或日志。系统数据库固定主机、端口、库名和账号以密文团队资料保存在代码中,不含数据库密码;新人页面只允许填写系统数据库密码,由它解锁固定资料并自动启用 TLS。禁止恢复主机、端口、账号、TLS、证书或 CA 路径等新人表单。系统数据库密码轮换前必须先完成共享云数据库密文重加密、数据库备份和团队连接资料密文更新。只有隔离排错或尚未迁移的旧环境才允许显式开启本机覆盖,并通过私有 `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` 来掩盖共享配置读取错误。 diff --git a/LOCAL_DEPLOY.md b/LOCAL_DEPLOY.md index e7619488..89e10753 100644 --- a/LOCAL_DEPLOY.md +++ b/LOCAL_DEPLOY.md @@ -13,14 +13,15 @@ http://127.0.0.1:18088 登录后必须在: ```text -系统设置 → 数据库管理 → 系统数据库 → 修改连接 +系统设置 → 数据库管理 → 系统数据库 → 连接 ``` -填写负责人提供的“财务系统配置数据库”参数并点击“测试并安全切换”。保存成功后: +只填写负责人提供的系统数据库密码并点击“连接系统数据库”。连接成功后: - 用户、侧边栏、系统设置和云数据库登记统一来自系统数据库; - 两个云数据库自动加载; - 云数据库共享密码由系统数据库登录密码解锁; +- 主机、端口、库名、账号和 TLS 均由项目自动设置; - 每台开发机可以使用自己的 `APP_KEY`,禁止复制生产环境或其他开发者的 `APP_KEY`; - 不需要在本机填写 `DB_JIANSUN_*` 或 `DB_OPERATIONS_REPLICA_*`。 @@ -57,16 +58,14 @@ bash deploy/local-init.sh --build 1. 打开 `http://127.0.0.1:18088/auth/login`。 2. 首次使用临时账号登录。 3. 打开“系统设置 → 数据库管理”。 -4. 在固定的“系统数据库”区域点击“修改连接”。 -5. 填写负责人提供的主机、端口、数据库名、账号、密码和 TLS 设置。首次切换必须填写密码;只有连接身份完全不变时才允许留空。 -6. 点击“测试并安全切换”。 +4. 在固定的“系统数据库”区域点击“连接”。 +5. 只填写负责人提供的系统数据库密码。 +6. 点击“连接系统数据库”。 7. 程序会检查迁移、管理员、公司权限映射、共享密码,并在新的 Laravel 进程中复核;失败自动恢复原连接。 8. 成功后临时登录 Session 会立即失效,请使用系统数据库中属于自己的正式账号重新登录。 9. 返回数据库管理页面,确认状态为“共享 · 已连接”,并确认两个云数据库已经自动出现。 -连接测试发生在 `jiangling_app` 容器内。远程数据库填写实际 IP 或域名;数据库运行在 Windows 主机时填写 `host.docker.internal`。不要填写 `localhost` 或 `127.0.0.1`,它们在容器中代表应用容器自身。 - -“TLS · 未验证身份”只表示传输已经加密;需要验证数据库服务器身份时,同时勾选证书验证,并按需要填写容器内可读取的 CA 证书路径。 +连接地址、账号和传输加密由项目内的团队固定连接资料自动设置。普通开发者不填写、不修改,也不需要理解证书或 CA 路径。密码错误会在当前页面明确提示;共享数据库暂时不可达时仍保留本机引导库,不会进入 500 页面。 保存系统数据库时,程序会自动设置: @@ -108,6 +107,8 @@ SYSTEM_DATABASE_MODE=shared ## 常用命令 +所有 Laravel 命令都必须使用 `-u www-data`。省略后 Docker 会默认使用 `root`,可能生成网页进程无法写入的缓存、日志或系统数据库备份,造成“测试通过但页面 500”。 + ```bash # 查看状态 docker compose -f docker-compose.local.yml ps @@ -122,13 +123,13 @@ docker compose -f docker-compose.local.yml logs -f snapshot_worker docker compose -f docker-compose.local.yml restart app # 清理应用缓存 -docker compose -f docker-compose.local.yml exec app php artisan optimize:clear +docker compose -f docker-compose.local.yml exec -u www-data app php artisan optimize:clear # 检查应用是否真正就绪 -docker compose -f docker-compose.local.yml exec app php artisan app:ready +docker compose -f docker-compose.local.yml exec -u www-data app php artisan app:ready # 运行测试(首次需使用 local-init.sh --build 构建开发依赖) -docker compose -f docker-compose.local.yml exec app php artisan test +docker compose -f docker-compose.local.yml exec -u www-data app php artisan test --do-not-cache-result # 停止容器,但保留本地数据 docker compose -f docker-compose.local.yml stop @@ -147,7 +148,7 @@ docker compose -f docker-compose.local.yml start ```bash docker compose -f docker-compose.local.yml restart app -docker compose -f docker-compose.local.yml exec app php artisan optimize:clear +docker compose -f docker-compose.local.yml exec -u www-data app php artisan optimize:clear ``` ## 相关文档 diff --git a/README.md b/README.md index 46be1fc0..377dbc3b 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ bash deploy/local-init.sh --build 3. 打开 `http://127.0.0.1:18088`。 4. 首次从脚本提示的本机私有凭据文件读取临时账号并登录;不要把内容发到聊天或提交到 Git。 5. 打开“系统设置 → 数据库管理 → 系统数据库”。 -6. 填写负责人提供的财务系统配置数据库并点击“测试并安全切换”。 +6. 点击系统数据库的“连接”,只填写负责人提供的系统数据库密码。 7. 使用系统数据库中属于自己的账号重新登录。 -完成系统数据库配置后,用户、侧边栏、系统设置和云数据库登记会自动统一。不需要再次填写两个云数据库密码,也不要复制生产环境或其他开发者的 `APP_KEY`、`.env`。 +连接成功后,用户、侧边栏、系统设置、云数据库登记和共享快照会自动统一。不需要填写主机、端口、库名、账号、TLS、证书或两个云数据库密码,也不要复制生产环境或其他开发者的 `APP_KEY`、`.env`。 完整步骤和排错说明见 [LOCAL_DEPLOY.md](LOCAL_DEPLOY.md)。 diff --git a/app/Console/Commands/ApplicationReady.php b/app/Console/Commands/ApplicationReady.php index 9524131d..689cdb8c 100644 --- a/app/Console/Commands/ApplicationReady.php +++ b/app/Console/Commands/ApplicationReady.php @@ -20,6 +20,18 @@ class ApplicationReady extends Command } try { + foreach ([ + storage_path('app'), + storage_path('framework/views'), + storage_path('logs'), + base_path('bootstrap/cache'), + base_path('.env'), + ] as $writablePath) { + if (! is_writable($writablePath)) { + return self::FAILURE; + } + } + DB::selectOne('SELECT 1'); foreach ([ diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index 57931df6..cdcf7e78 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\SystemSettings; use App\Models\Settings\DatabaseConnection; use App\Utilities\SystemAccess; use App\Utilities\SystemDatabaseConfiguration; +use App\Utilities\TeamSystemDatabaseProfile; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -57,80 +58,39 @@ class Databases extends Controller ]); } - public function editSystem(SystemDatabaseConfiguration $configuration) + public function editSystem() { - $current = $configuration->current(); - - return view('system_settings.databases.system', [ - 'values' => [ - 'host' => $current['host'], - 'port' => $current['port'], - 'database' => $current['database'], - 'username' => $current['username'], - 'tls_required' => $current['tls_required'], - 'tls_verify_server_cert' => $current['tls_verify_server_cert'], - 'tls_ca' => $current['tls_ca'], - 'mode' => $current['mode'], - ], - ]); + return view('system_settings.databases.system'); } - public function updateSystem(Request $request, SystemDatabaseConfiguration $configuration) + public function updateSystem( + Request $request, + SystemDatabaseConfiguration $configuration, + TeamSystemDatabaseProfile $teamProfile + ) { - $current = $configuration->current(); $data = $request->validate([ - 'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], - 'port' => ['required', 'integer', 'between:1,65535'], - 'database' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], - 'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], - 'password' => ['nullable', 'string', 'max:1000', 'not_regex:/[\r\n\x00]/'], - 'tls_required' => ['nullable', 'boolean'], - 'tls_verify_server_cert' => ['nullable', 'boolean'], - 'tls_ca' => ['nullable', 'string', 'max:500', 'not_regex:/[\r\n\x00]/'], + 'password' => ['required', 'string', 'max:1000', 'not_regex:/[\r\n\x00]/'], ]); - $sameConnection = trim($data['host']) === $current['host'] - && (int) $data['port'] === $current['port'] - && trim($data['database']) === $current['database'] - && trim($data['username']) === $current['username']; - $providedPassword = $data['password'] !== null && $data['password'] !== ''; - - if (! $providedPassword && ! $sameConnection) { - throw ValidationException::withMessages([ - 'password' => '首次连接或修改主机、端口、数据库、账号时必须填写目标数据库密码。', - ]); - } - - $settings = [ - 'driver' => 'mysql', - 'host' => trim($data['host']), - 'port' => (int) $data['port'], - 'database' => trim($data['database']), - 'username' => trim($data['username']), - 'password' => $providedPassword - ? $data['password'] - : $current['password'], - 'tls_required' => $request->boolean('tls_required'), - 'tls_verify_server_cert' => $request->boolean('tls_required') - && $request->boolean('tls_verify_server_cert'), - 'tls_ca' => $request->boolean('tls_required') - ? trim((string) ($data['tls_ca'] ?? '')) - : '', - ]; - - if ($settings['password'] === '') { - throw ValidationException::withMessages([ - 'password' => '当前没有可保留的密码,请填写数据库密码。', - ]); - } - try { + $settings = $teamProfile->settings($data['password']); $result = $configuration->probe($settings); $settings['prefix'] = $result['prefix']; $configuration->persist($settings); - } catch (\RuntimeException $exception) { + } catch (\Throwable $exception) { + if (! $exception instanceof \RuntimeException) { + try { + report($exception); + } catch (\Throwable) { + // 即使日志目录本身异常,也必须返回表单错误,不能再次升级为 500。 + } + } + throw ValidationException::withMessages([ - 'connection' => $exception->getMessage(), + 'password' => $exception instanceof \RuntimeException + ? $exception->getMessage() + : '连接流程发生异常,系统已保留原数据库连接。请重试或联系负责人。', ]); } diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index 915dd9b2..22a53227 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -14,9 +14,15 @@ class SystemDatabaseConfiguration { private string $environmentPath; - public function __construct(?string $environmentPath = null) + private ?string $backupDirectory; + + public function __construct( + ?string $environmentPath = null, + ?string $backupDirectory = null + ) { $this->environmentPath = $environmentPath ?: base_path('.env'); + $this->backupDirectory = $backupDirectory; } public function current(): array @@ -162,19 +168,15 @@ class SystemDatabaseConfiguration throw new RuntimeException('无法读取服务器私有启动配置。'); } - $backupDirectory = storage_path('app/system-database-config-backups'); - - if (! is_dir($backupDirectory) && ! mkdir($backupDirectory, 0700, true) && ! is_dir($backupDirectory)) { - throw new RuntimeException('无法创建系统数据库配置备份目录。'); - } + $backupDirectory = $this->writableBackupDirectory(); $backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His-u') . '.backup'; - if (file_put_contents($backupPath, $current, LOCK_EX) === false) { + if (@file_put_contents($backupPath, $current, LOCK_EX) === false) { throw new RuntimeException('系统数据库配置备份失败,未执行切换。'); } - chmod($backupPath, 0600); + @chmod($backupPath, 0600); $prefix = array_key_exists('prefix', $settings) ? (string) $settings['prefix'] @@ -239,6 +241,33 @@ class SystemDatabaseConfiguration return $backupPath; } + private function writableBackupDirectory(): string + { + $preferred = $this->backupDirectory + ?: storage_path('app/system-database-config-backups'); + + if ( + (is_dir($preferred) && is_writable($preferred)) + || ( + ! file_exists($preferred) + && @mkdir($preferred, 0700, true) + && is_writable($preferred) + ) + ) { + return $preferred; + } + + // 某些开发机曾用 root 执行诊断命令,可能留下仅 root 可写的旧目录。 + // storage/app 由容器入口统一授权给网页进程,可安全作为备份回退位置。 + $fallback = storage_path('app'); + + if (is_dir($fallback) && is_writable($fallback)) { + return $fallback; + } + + throw new RuntimeException('系统数据库配置备份目录不可写,未执行切换。'); + } + /** * 根据一组完整的系统必需表自动识别目标库表前缀。 * @@ -326,7 +355,7 @@ class SystemDatabaseConfiguration } if ($host === '' || gethostbynamel($host) === false) { - throw new RuntimeException('连接失败阶段:DNS 解析。请检查主机名称。'); + throw new RuntimeException('系统数据库地址暂时无法解析,请稍后重试或联系负责人。'); } } @@ -338,7 +367,7 @@ class SystemDatabaseConfiguration $socket = @stream_socket_client($address, $errorCode, $errorMessage, 4); if (! is_resource($socket)) { - throw new RuntimeException('连接失败阶段:TCP 端口。请检查端口、网络和访问白名单。'); + throw new RuntimeException('系统数据库暂时无法连接,请稍后重试或联系负责人。'); } fclose($socket); @@ -481,11 +510,11 @@ class SystemDatabaseConfiguration $message = strtolower($exception->getMessage()); if ($driverCode === 1045 || str_contains($message, 'access denied')) { - return '连接失败阶段:账号认证。请检查数据库账号和密码。'; + return '系统数据库密码不正确,请重新输入。'; } if ($driverCode === 1049 || str_contains($message, 'unknown database')) { - return '连接失败阶段:选择数据库。请检查数据库名称和账号权限。'; + return '团队系统数据库配置异常,请联系负责人。'; } if ( @@ -494,14 +523,14 @@ class SystemDatabaseConfiguration || str_contains($message, 'certificate') || str_contains($message, 'tls') ) { - return '连接失败阶段:TLS 协商或证书验证。请检查 TLS 和 CA 配置。'; + return '系统数据库加密连接失败,请稍后重试或联系负责人。'; } if ($driverCode === 2002 || str_contains($message, 'timed out')) { - return '连接失败阶段:数据库握手。请检查网络、端口和访问白名单。'; + return '系统数据库暂时无法连接,请稍后重试或联系负责人。'; } - return '连接失败阶段:数据库登录或初始化。请检查库名、账号权限和 TLS 配置。'; + return '系统数据库连接初始化失败,请稍后重试或联系负责人。'; } protected function runFreshVerification(string $command): void diff --git a/app/Utilities/TeamSystemDatabaseProfile.php b/app/Utilities/TeamSystemDatabaseProfile.php new file mode 100644 index 00000000..27e2704e --- /dev/null +++ b/app/Utilities/TeamSystemDatabaseProfile.php @@ -0,0 +1,165 @@ +decrypt($this->profile(), $password); + + return [ + 'driver' => 'mysql', + 'host' => $this->requiredString($profile, 'host'), + 'port' => $this->requiredPort($profile), + 'database' => $this->requiredString($profile, 'database'), + 'username' => $this->requiredString($profile, 'username'), + 'password' => $password, + 'tls_required' => true, + 'tls_verify_server_cert' => false, + 'tls_ca' => '', + ]; + } + + /** + * 仅供负责人轮换团队连接资料时生成新的密文,不输出明文密码。 + */ + public static function seal(array $profile, string $password): string + { + $salt = random_bytes(16); + $iv = random_bytes(12); + $key = hash_pbkdf2('sha256', $password, $salt, self::ITERATIONS, 32, true); + $tag = ''; + $plaintext = json_encode($profile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $ciphertext = openssl_encrypt( + $plaintext, + self::CIPHER, + $key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if ($ciphertext === false) { + throw new RuntimeException('团队系统数据库连接资料加密失败。'); + } + + return base64_encode(json_encode([ + 'v' => 1, + 'salt' => base64_encode($salt), + 'iv' => base64_encode($iv), + 'tag' => base64_encode($tag), + 'data' => base64_encode($ciphertext), + ], JSON_THROW_ON_ERROR)); + } + + private function profile(): string + { + $profile = trim((string) ( + $this->encodedProfile + ?? config('system_database.team_profile', '') + )); + + if ($profile === '') { + throw new RuntimeException('团队系统数据库连接资料尚未安装,请联系负责人更新代码。'); + } + + return $profile; + } + + private function decrypt(string $encodedProfile, string $password): array + { + try { + $envelope = json_decode( + base64_decode($encodedProfile, true) ?: throw new RuntimeException(), + true, + 16, + JSON_THROW_ON_ERROR + ); + $salt = base64_decode((string) ($envelope['salt'] ?? ''), true); + $iv = base64_decode((string) ($envelope['iv'] ?? ''), true); + $tag = base64_decode((string) ($envelope['tag'] ?? ''), true); + $ciphertext = base64_decode((string) ($envelope['data'] ?? ''), true); + + if ( + ($envelope['v'] ?? null) !== 1 + || $salt === false + || strlen($salt) !== 16 + || $iv === false + || strlen($iv) !== 12 + || $tag === false + || strlen($tag) !== 16 + || $ciphertext === false + ) { + throw new RuntimeException(); + } + + $key = hash_pbkdf2('sha256', $password, $salt, self::ITERATIONS, 32, true); + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if ($plaintext === false) { + throw new RuntimeException(); + } + + $profile = json_decode($plaintext, true, 16, JSON_THROW_ON_ERROR); + + if (! is_array($profile)) { + throw new RuntimeException(); + } + + return $profile; + } catch (\Throwable) { + throw new RuntimeException('系统数据库密码不正确,请重新输入。'); + } + } + + private function requiredString(array $profile, string $key): string + { + $value = trim((string) ($profile[$key] ?? '')); + + if ($value === '' || preg_match('/[;\x00-\x1F]/', $value) === 1) { + throw new RuntimeException('团队系统数据库连接资料不完整,请联系负责人更新代码。'); + } + + return $value; + } + + private function requiredPort(array $profile): int + { + $port = (int) ($profile['port'] ?? 0); + + if ($port < 1 || $port > 65535) { + throw new RuntimeException('团队系统数据库连接资料不完整,请联系负责人更新代码。'); + } + + return $port; + } +} diff --git a/compose.jiangling.yml b/compose.jiangling.yml index 15294d65..28ebaf40 100644 --- a/compose.jiangling.yml +++ b/compose.jiangling.yml @@ -65,7 +65,9 @@ services: - ./runtime/.env:/var/www/html/.env - caiwu_storage:/var/www/html/storage healthcheck: - test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/"] + test: + - "CMD-SHELL" + - "runuser -u www-data -- php artisan app:ready --quiet && curl -fsS -o /dev/null http://127.0.0.1:18088/" interval: 15s timeout: 5s retries: 10 diff --git a/config/system_database.php b/config/system_database.php index 02c23620..580559a3 100644 --- a/config/system_database.php +++ b/config/system_database.php @@ -11,4 +11,18 @@ return [ | */ 'mode' => env('SYSTEM_DATABASE_MODE', ''), + + /* + |-------------------------------------------------------------------------- + | 团队共享系统数据库连接资料 + |-------------------------------------------------------------------------- + | + | 这里只保存使用系统数据库密码加密后的主机、端口、库名和账号。新人 + | 页面只填写密码即可解锁固定资料;真实密码不会写入代码或回显。 + | + */ + 'team_profile' => env( + 'TEAM_SYSTEM_DATABASE_PROFILE', + 'eyJ2IjoxLCJzYWx0IjoiMUNLdmVsREk4d3NBTTdUZGJsSDYydz09IiwiaXYiOiJaYW93YUM4YXYwZXMwd1oxIiwidGFnIjoiWUtpM3dJMXArZW1PY3Q0ZDlhaFF3Zz09IiwiZGF0YSI6ImxUaXZKZHVpVkF2R2RWM3Znd3hDVHViSVhKMFMzWWFwK1B3QUtcLzkyYm0wZnMzR05jZjZ2ZE5yeXZGbnlNelQ3d3JPbXNlc1B0MFpieWJhcDhkMktKMmZjb0lrZnRUdTBET1laZXc0VnFhRW5KR1VKQlNpaXZ5NXpYVmFVTWtLZnVMenNWNHEwZXc9PSJ9' + ), ]; diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index ab1db11a..d30e1076 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,35 +1,55 @@ #!/bin/sh set -eu -mkdir -p \ - storage/app/public \ - storage/app/temp \ - storage/app/uploads \ - storage/framework/cache/data \ - storage/framework/sessions \ - storage/framework/views \ - storage/logs \ - bootstrap/cache +if [ "$(id -u)" = "0" ]; then + mkdir -p \ + storage/app/public \ + storage/app/temp \ + storage/app/uploads \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache -chown -R www-data:www-data storage bootstrap/cache + # 修复旧容器或 root CLI 遗留的属主;之后所有 Artisan 命令都降权执行, + # 避免再次产生网页进程无法写入的缓存、日志和备份文件。 + chown -R www-data:www-data storage bootstrap/cache -if [ -f .env ]; then - chown www-data:www-data .env - chmod 660 .env + if [ -f .env ]; then + chown www-data:www-data .env + chmod 660 .env + fi fi +assert_web_writable() { + runuser -u www-data -- test -w storage/app + runuser -u www-data -- test -w storage/framework/views + runuser -u www-data -- test -w storage/logs + runuser -u www-data -- test -w bootstrap/cache + + if [ -f .env ]; then + runuser -u www-data -- test -w .env + fi +} + +run_artisan_as_web() { + runuser -u www-data -- php artisan "$@" +} + if [ "${1:-}" = "apache2-foreground" ]; then sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf - php artisan optimize:clear - php artisan package:discover --ansi + assert_web_writable + run_artisan_as_web optimize:clear + run_artisan_as_web package:discover --ansi if grep -Eq '^APP_INSTALLED=(true|1)$' .env; then if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then - php artisan migrate --force + run_artisan_as_web migrate --force fi - php artisan config:cache - php artisan view:cache + run_artisan_as_web config:cache + run_artisan_as_web view:cache fi fi diff --git a/deploy/local-init.sh b/deploy/local-init.sh index 7b317bc3..089a1d6f 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -176,7 +176,7 @@ EOF # shellcheck disable=SC1090 source "$credential_file" - docker compose -f "$compose_file" exec -T app \ + docker compose -f "$compose_file" exec -T -u www-data app \ php artisan install --no-interaction \ --db-host=mysql \ --db-port=3306 \ @@ -194,11 +194,11 @@ else fi echo "==> [5/5] 验证安装状态、数据库和管理员权限..." -docker compose -f "$compose_file" exec -T app php artisan optimize:clear >/dev/null +docker compose -f "$compose_file" exec -T -u www-data app php artisan optimize:clear >/dev/null ready=0 for i in $(seq 1 30); do - if docker compose -f "$compose_file" exec -T app php artisan app:ready --quiet; then + if docker compose -f "$compose_file" exec -T -u www-data app php artisan app:ready --quiet; then ready=1 break fi @@ -230,7 +230,7 @@ if [ "$db_host" = "mysql" ]; then echo "请自行打开该文件,不要把内容提交到 Git、聊天或日志。" echo "" echo "下一步:登录后打开“系统设置 → 数据库管理 → 系统数据库”," - echo "填写负责人提供的团队共享系统数据库并点击“测试并安全切换”。" + echo "点击“连接”,只填写负责人提供的系统数据库密码。" else echo "已连接团队共享系统数据库,请使用其中属于自己的正式账号登录。" fi diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d17c8e94..16dd4b63 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -35,7 +35,7 @@ services: networks: - jiangling_net healthcheck: - test: ["CMD", "php", "artisan", "app:ready", "--quiet"] + test: ["CMD", "runuser", "-u", "www-data", "--", "php", "artisan", "app:ready", "--quiet"] interval: 15s timeout: 5s retries: 10 @@ -44,6 +44,7 @@ services: snapshot_worker: image: jiangling_akaunting:local container_name: jiangling_snapshot_worker + user: www-data restart: unless-stopped depends_on: app: diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index a8f75a0f..9d4909c1 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -139,7 +139,7 @@ git pull --ff-only ### 8.1 统一接入边界 -本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。系统数据库的启动连接只存在于服务器私有 `runtime/.env`;显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库端点及账号保存在系统库中。密码同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password`,因此受信任开发机填写同一套系统数据库参数后即可读取共享云数据库配置,不需要复制生产 `APP_KEY` 或再次填写云数据库密码。 +本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。生产系统数据库的启动连接只存在于服务器私有 `runtime/.env`;本地新人流程使用不含密码的加密团队连接资料,开发者只填写系统数据库密码即可解锁固定主机、端口、库名和账号,TLS 由系统自动启用。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库端点及账号保存在系统库中。密码同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password`,因此不需要复制生产 `APP_KEY` 或再次填写云数据库密码。 固定调用链如下: @@ -263,7 +263,9 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300 本地首次启动时页面必须明确显示“本地临时引导库”,不能把本机 MySQL 标成财务系统配置数据库。共享系统数据库保存前必须验证当前代码迁移、必要表字段、至少一个具备公司映射和 `read-admin-panel` 权限的管理员、已登记来源及其 `shared_password`。保存后使用新的 Laravel 进程复核;失败恢复私有 `.env` 备份,成功则立即清除临时账号 Session 并要求使用共享库正式账号重新登录。 -系统数据库连接测试从应用容器发起。Windows 宿主机上的数据库使用 `host.docker.internal`,远程数据库使用实际 IP 或域名;`localhost` 和 `127.0.0.1` 代表应用容器自身。TLS 页面必须区分“仅加密”和“已验证服务器证书”,自定义 CA 路径必须是容器内可读取的私有文件,不能进入 Git。 +系统数据库连接测试从应用容器发起,但新人页面只显示密码输入框。主机、端口、库名、账号和 TLS 均来自使用系统数据库密码加密的团队固定连接资料;不得把这些高级连接项重新暴露给普通开发者。密码错误必须回到原页面显示中文提示,网络或数据库暂不可用也必须保留旧连接并显示中文原因,不得出现 500。 + +容器内的 Laravel 初始化、缓存、测试、迁移和快照刷新必须统一以 `www-data` 执行。`docker compose exec app php artisan ...` 会默认使用 `root`,可能在持久化 `storage` 中创建网页进程不可写的目录或文件;统一改用 `docker compose exec -u www-data app php artisan ...`。容器入口负责修复历史属主,后台刷新进程固定使用 `www-data`,健康检查也以同一身份验证运行目录写权限,禁止只看登录页 HTTP 200。 旧记录只有 `password`、没有 `shared_password` 时,必须在仍持有旧 `APP_KEY` 的受信任环境中先执行只读检查,再经负责人批准执行迁移: diff --git a/resources/views/system_settings/databases/index.blade.php b/resources/views/system_settings/databases/index.blade.php index c6fcb372..24a03331 100644 --- a/resources/views/system_settings/databases/index.blade.php +++ b/resources/views/system_settings/databases/index.blade.php @@ -30,7 +30,6 @@ 名称 地址 数据库 - 传输加密 操作 @@ -51,23 +50,10 @@ {{ $systemDatabase['host'] }}{{ $systemDatabase['port'] ? ':' . $systemDatabase['port'] : '' }} {{ $systemDatabase['database'] ?: $systemDatabase['connection'] }} - - $systemDatabase['tls_required'] && $systemDatabase['tls_verify_server_cert'], - 'bg-yellow-100 text-[#935F07]' => ! $systemDatabase['tls_required'] || ! $systemDatabase['tls_verify_server_cert'], - ])> - @if ($systemDatabase['tls_required'] && $systemDatabase['tls_verify_server_cert']) - TLS · 已验证证书 - @elseif ($systemDatabase['tls_required']) - TLS · 未验证身份 - @else - 未启用 TLS - @endif - - - 修改连接 + + {{ $systemDatabase['mode'] === 'bootstrap' ? '连接' : '重新验证' }} + diff --git a/resources/views/system_settings/databases/system.blade.php b/resources/views/system_settings/databases/system.blade.php index 7bdda57b..852be9ae 100644 --- a/resources/views/system_settings/databases/system.blade.php +++ b/resources/views/system_settings/databases/system.blade.php @@ -5,71 +5,29 @@
@include('system_settings.partials.navigation') -
+ @csrf @method('PATCH') @if ($errors->any()) -