修复:简化系统数据库接入并统一容器权限

This commit is contained in:
WuYongJie 2026-07-24 16:30:16 +08:00
parent 3a550f88c6
commit 49e507b7e2
19 changed files with 539 additions and 220 deletions

View File

@ -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` 来掩盖共享配置读取错误。

View File

@ -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
```
## 相关文档

View File

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

View File

@ -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 ([

View File

@ -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()
: '连接流程发生异常,系统已保留原数据库连接。请重试或联系负责人。',
]);
}

View File

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

View File

@ -0,0 +1,165 @@
<?php
namespace App\Utilities;
use RuntimeException;
/**
* 使用团队系统数据库密码解锁固定连接资料。
*
* 仓库只保存加密后的主机、端口、库名和账号;密码既是解锁密钥,也是
* 实际数据库密码。这样新人页面只需要一个输入框,不需要判断 TLS、
* 证书、端口或账号。
*/
class TeamSystemDatabaseProfile
{
private const CIPHER = 'aes-256-gcm';
private const ITERATIONS = 210000;
public function __construct(private readonly ?string $encodedProfile = null)
{
}
public function settings(string $password): array
{
if ($password === '') {
throw new RuntimeException('请输入系统数据库密码。');
}
$profile = $this->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;
}
}

View File

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

View File

@ -11,4 +11,18 @@ return [
|
*/
'mode' => env('SYSTEM_DATABASE_MODE', ''),
/*
|--------------------------------------------------------------------------
| 团队共享系统数据库连接资料
|--------------------------------------------------------------------------
|
| 这里只保存使用系统数据库密码加密后的主机、端口、库名和账号。新人
| 页面只填写密码即可解锁固定资料;真实密码不会写入代码或回显。
|
*/
'team_profile' => env(
'TEAM_SYSTEM_DATABASE_PROFILE',
'eyJ2IjoxLCJzYWx0IjoiMUNLdmVsREk4d3NBTTdUZGJsSDYydz09IiwiaXYiOiJaYW93YUM4YXYwZXMwd1oxIiwidGFnIjoiWUtpM3dJMXArZW1PY3Q0ZDlhaFF3Zz09IiwiZGF0YSI6ImxUaXZKZHVpVkF2R2RWM3Znd3hDVHViSVhKMFMzWWFwK1B3QUtcLzkyYm0wZnMzR05jZjZ2ZE5yeXZGbnlNelQ3d3JPbXNlc1B0MFpieWJhcDhkMktKMmZjb0lrZnRUdTBET1laZXc0VnFhRW5KR1VKQlNpaXZ5NXpYVmFVTWtLZnVMenNWNHEwZXc9PSJ9'
),
];

View File

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

View File

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

View File

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

View File

@ -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` 的受信任环境中先执行只读检查,再经负责人批准执行迁移:

View File

@ -30,7 +30,6 @@
<th class="px-5 py-3 font-semibold">名称</th>
<th class="px-5 py-3 font-semibold">地址</th>
<th class="px-5 py-3 font-semibold">数据库</th>
<th class="px-5 py-3 font-semibold">传输加密</th>
<th class="px-5 py-3 text-right font-semibold">操作</th>
</tr>
</thead>
@ -51,23 +50,10 @@
</td>
<td class="px-5 py-4 text-gray-600">{{ $systemDatabase['host'] }}{{ $systemDatabase['port'] ? ':' . $systemDatabase['port'] : '' }}</td>
<td class="px-5 py-4 text-gray-600">{{ $systemDatabase['database'] ?: $systemDatabase['connection'] }}</td>
<td class="px-5 py-4">
<span @class([
'inline-flex rounded-full px-2.5 py-1 text-xs font-semibold',
'bg-green-100 text-[#53793E]' => $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
</span>
</td>
<td class="px-5 py-4 text-right whitespace-nowrap">
<a href="{{ route('system-settings.databases.system.edit') }}" class="font-semibold text-[#856632] hover:text-[#6A5129] hover:underline">修改连接</a>
<a href="{{ route('system-settings.databases.system.edit') }}" class="font-semibold text-[#856632] hover:text-[#6A5129] hover:underline">
{{ $systemDatabase['mode'] === 'bootstrap' ? '连接' : '重新验证' }}
</a>
</td>
</tr>
</tbody>

View File

@ -5,71 +5,29 @@
<div class="my-6">
@include('system_settings.partials.navigation')
<form method="POST" action="{{ route('system-settings.databases.system.update') }}" class="max-w-4xl rounded-xl border border-gray-200 bg-white p-6">
<form method="POST" action="{{ route('system-settings.databases.system.update') }}" class="max-w-xl rounded-xl border border-[#D8D2C7] bg-white p-6">
@csrf
@method('PATCH')
@if ($errors->any())
<div class="mb-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
<div class="mb-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-[#B80000]" role="alert">
@foreach ($errors->all() as $error)<p>{{ $error }}</p>@endforeach
</div>
@endif
<div class="mb-6 rounded-lg bg-yellow-50 px-4 py-3 text-sm leading-6 text-yellow-800">
@if ($values['mode'] === 'bootstrap')
当前是本地临时引导库。首次连接团队共享系统数据库时必须填写目标密码。
@else
当前是团队共享系统数据库。只有主机、端口、库名和账号均未改变时,密码才可以留空。
@endif
保存前会检查迁移、管理员、权限映射和共享云数据库配置;失败会自动恢复原连接。
<div class="mb-6 rounded-lg bg-[#F7F0DE] px-4 py-3 text-sm leading-6 text-[#856632]">
只需填写负责人提供的系统数据库密码。连接地址、账号和加密方式由系统自动设置。
</div>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<label class="block sm:col-span-2">
<span class="text-sm font-semibold text-gray-700">主机地址 *</span>
<input name="host" value="{{ old('host', $values['host']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
<span class="mt-1 block text-xs leading-5 text-gray-500">连接从应用容器发起。Windows 主机上的数据库请填写 <code>host.docker.internal</code>,不要填写 localhost 或 127.0.0.1;远程数据库填写实际 IP 或域名。</span>
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">端口 *</span>
<input type="number" name="port" value="{{ old('port', $values['port']) }}" required min="1" max="65535" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">数据库名称 *</span>
<input name="database" value="{{ old('database', $values['database']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">数据库账号 *</span>
<input name="username" value="{{ old('username', $values['username']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">数据库密码</span>
<input type="password" name="password" maxlength="1000" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
<span class="mt-1 block text-xs text-gray-500">首次连接或修改连接身份时必须填写;页面不会回显密码。</span>
</label>
</div>
<div class="mt-6 border-t border-gray-100 pt-5">
<input type="hidden" name="tls_required" value="0">
<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700">
<input type="checkbox" name="tls_required" value="1" @checked((bool) old('tls_required', $values['tls_required'])) class="rounded border-gray-300 text-[#856632] focus:ring-[#856632]">
要求 TLS 加密连接
</label>
<input type="hidden" name="tls_verify_server_cert" value="0">
<label class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-gray-700">
<input type="checkbox" name="tls_verify_server_cert" value="1" @checked((bool) old('tls_verify_server_cert', $values['tls_verify_server_cert'])) class="rounded border-gray-300 text-[#856632] focus:ring-[#856632]">
验证数据库服务器证书和身份
</label>
<label class="mt-4 block">
<span class="text-sm font-semibold text-gray-700">CA 证书路径(可选)</span>
<input name="tls_ca" value="{{ old('tls_ca', $values['tls_ca']) }}" maxlength="500" autocomplete="off" placeholder="容器内可读取的 CA 文件路径" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
<span class="mt-1 block text-xs text-gray-500">留空时使用容器系统信任库。只有启用 TLS 时该项才生效。</span>
</label>
</div>
<label class="block">
<span class="text-xs font-medium text-[#171717]">系统数据库密码</span>
<input type="password" name="password" required maxlength="1000" autocomplete="current-password" autofocus class="mt-1 h-[34px] w-full rounded-lg border border-[#D8D2C7] bg-white px-3 text-[13px] text-[#171717] focus:border-[#856632] focus:outline-none focus:ring-2 focus:ring-[#856632]/20">
<span class="mt-2 block text-xs leading-5 text-[#595959]">密码不会显示,也不会提交到 Git。连接失败时仍保留当前数据库。</span>
</label>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
<a href="{{ route('system-settings.databases.index') }}" class="rounded-lg px-5 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100">取消</a>
<button type="submit" class="rounded-lg bg-[#856632] px-5 py-2 text-sm font-semibold text-white hover:bg-[#6A5129] focus:outline-none focus:ring-2 focus:ring-[#856632] focus:ring-offset-2">测试并安全切换</button>
<a href="{{ route('system-settings.databases.index') }}" class="inline-flex h-[34px] items-center rounded-lg px-4 text-[13px] font-medium text-[#595959] hover:bg-[#F8F6F1] focus:outline-none focus:ring-2 focus:ring-[#856632]/20">取消</a>
<button type="submit" class="h-[34px] rounded-lg bg-[#856632] px-4 text-[13px] font-medium text-white hover:bg-[#6A5129] focus:outline-none focus:ring-2 focus:ring-[#856632]/20 focus:ring-offset-2">连接系统数据库</button>
</div>
</form>
</div>

View File

@ -6,6 +6,7 @@ use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Settings\DatabaseConnection;
use App\Utilities\SystemDatabaseConfiguration;
use App\Utilities\TeamSystemDatabaseProfile;
use App\Utilities\UpstreamCredentialCipher;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\DB;
@ -250,7 +251,11 @@ class UpstreamDatabasesTest extends FeatureTestCase
$this->get(route('system-settings.databases.system.edit'))
->assertOk()
->assertSeeText('测试并安全切换');
->assertSeeText('连接系统数据库')
->assertSee('name="password"', false)
->assertDontSee('name="host"', false)
->assertDontSeeText('要求 TLS 加密连接')
->assertDontSeeText('CA 证书路径');
}
public function test_database_page_separates_system_and_cloud_database_configuration(): void
@ -272,7 +277,9 @@ class UpstreamDatabasesTest extends FeatureTestCase
$this->get(route('system-settings.databases.system.edit'))
->assertOk()
->assertSeeText('测试并安全切换')
->assertSeeText('连接系统数据库')
->assertDontSee('name="host"', false)
->assertDontSee('name="tls_required"', false)
->assertDontSee('system-database-test-secret');
}
@ -324,42 +331,37 @@ class UpstreamDatabasesTest extends FeatureTestCase
->assertDontSeeText('添加云数据库');
}
public function test_first_switch_requires_password_when_connection_identity_changes(): void
public function test_first_switch_only_requires_system_database_password(): void
{
config(['system_database.mode' => 'bootstrap']);
$this->withExceptionHandling();
$this->loginAs()
->patch(route('system-settings.databases.system.update'), [
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
'password' => '',
'tls_required' => '1',
'tls_verify_server_cert' => '0',
])
->assertSessionHasErrors('password');
}
public function test_wrong_system_database_password_stays_on_the_form(): void
{
config(['system_database.mode' => 'bootstrap']);
$this->withExceptionHandling();
$this->loginAs()
->from(route('system-settings.databases.system.edit'))
->patch(route('system-settings.databases.system.update'), [
'password' => 'definitely-not-the-team-password',
])
->assertRedirect(route('system-settings.databases.system.edit'))
->assertSessionHasErrors([
'password' => '系统数据库密码不正确,请重新输入。',
]);
}
public function test_successful_system_database_switch_invalidates_bootstrap_session(): void
{
$current = [
'connection' => 'sqlite',
'driver' => 'sqlite',
'host' => 'local-bootstrap',
'port' => 3306,
'database' => 'bootstrap',
'username' => 'bootstrap',
'password' => 'bootstrap-secret',
'prefix' => '',
'mode' => 'bootstrap',
'tls_required' => false,
'tls_verify_server_cert' => false,
'tls_ca' => '',
];
$configuration = Mockery::mock(SystemDatabaseConfiguration::class);
$configuration->shouldReceive('current')->once()->andReturn($current);
$configuration->shouldReceive('probe')->once()->andReturn([
'cipher' => 'TLS_AES_256_GCM_SHA384',
'user_count' => 7,
@ -369,23 +371,58 @@ class UpstreamDatabasesTest extends FeatureTestCase
]);
$configuration->shouldReceive('persist')->once();
$this->app->instance(SystemDatabaseConfiguration::class, $configuration);
$teamProfile = Mockery::mock(TeamSystemDatabaseProfile::class);
$teamProfile->shouldReceive('settings')->once()->with('new-shared-secret')->andReturn([
'driver' => 'mysql',
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
'password' => 'new-shared-secret',
'tls_required' => true,
'tls_verify_server_cert' => false,
'tls_ca' => '',
]);
$this->app->instance(TeamSystemDatabaseProfile::class, $teamProfile);
$this->loginAs()
->patch(route('system-settings.databases.system.update'), [
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
'password' => 'new-shared-secret',
'tls_required' => '1',
'tls_verify_server_cert' => '0',
'tls_ca' => '',
])
->assertRedirect(route('login'));
$this->assertGuest();
}
public function test_unexpected_system_database_error_returns_to_form_instead_of_500(): void
{
$this->withExceptionHandling();
$configuration = Mockery::mock(SystemDatabaseConfiguration::class);
$configuration->shouldReceive('probe')->once()->andThrow(new \LogicException('internal detail'));
$this->app->instance(SystemDatabaseConfiguration::class, $configuration);
$teamProfile = Mockery::mock(TeamSystemDatabaseProfile::class);
$teamProfile->shouldReceive('settings')->once()->andReturn([
'driver' => 'mysql',
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
'password' => 'new-shared-secret',
'tls_required' => true,
'tls_verify_server_cert' => false,
'tls_ca' => '',
]);
$this->app->instance(TeamSystemDatabaseProfile::class, $teamProfile);
$this->loginAs()
->from(route('system-settings.databases.system.edit'))
->patch(route('system-settings.databases.system.update'), [
'password' => 'new-shared-secret',
])
->assertRedirect(route('system-settings.databases.system.edit'))
->assertSessionHasErrors('password');
}
private function payload(array $overrides = []): array
{
return array_merge([

View File

@ -0,0 +1,47 @@
<?php
namespace Tests\Unit\Deployment;
use Tests\TestCase;
class WebProcessOwnershipTest extends TestCase
{
public function test_container_entrypoint_runs_laravel_maintenance_as_web_user(): void
{
$entrypoint = file_get_contents(base_path('deploy/entrypoint.sh'));
$this->assertIsString($entrypoint);
$this->assertStringContainsString(
'runuser -u www-data -- php artisan "$@"',
$entrypoint
);
$this->assertStringContainsString('assert_web_writable', $entrypoint);
$this->assertDoesNotMatchRegularExpression('/^\s*php artisan /m', $entrypoint);
}
public function test_local_worker_healthcheck_and_initialization_use_web_user(): void
{
$compose = file_get_contents(base_path('docker-compose.local.yml'));
$initializer = file_get_contents(base_path('deploy/local-init.sh'));
$this->assertIsString($compose);
$this->assertStringContainsString(
'test: ["CMD", "runuser", "-u", "www-data", "--", "php", "artisan", "app:ready", "--quiet"]',
$compose
);
$this->assertMatchesRegularExpression(
'/snapshot_worker:.*?user: www-data/s',
$compose
);
$this->assertIsString($initializer);
$this->assertStringContainsString(
'exec -T -u www-data app',
$initializer
);
$this->assertDoesNotMatchRegularExpression(
'/exec -T app\s+(?:\\\\\s*)?php artisan/',
$initializer
);
}
}

View File

@ -49,4 +49,42 @@ class SystemDatabasePersistenceTest extends TestCase
@unlink($environmentPath);
}
}
public function test_root_owned_or_invalid_backup_directory_uses_writable_storage_fallback(): void
{
$environmentPath = storage_path('framework/testing-system-database-fallback.env');
$blockedBackupPath = storage_path('framework/testing-blocked-backup-path');
file_put_contents($environmentPath, "DB_HOST=mysql\nDB_DATABASE=akaunting\n");
file_put_contents($blockedBackupPath, 'not-a-directory');
$configuration = new class($environmentPath, $blockedBackupPath) extends SystemDatabaseConfiguration {
protected function runFreshVerification(string $command): void
{
}
};
try {
$backupPath = $configuration->persist([
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
'password' => 'shared-secret',
'prefix' => 'ak_',
'tls_required' => true,
'tls_verify_server_cert' => false,
'tls_ca' => '',
]);
$this->assertSame(storage_path('app'), dirname($backupPath));
$this->assertFileExists($backupPath);
} finally {
@unlink($environmentPath);
@unlink($blockedBackupPath);
if (isset($backupPath)) {
@unlink($backupPath);
}
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Tests\Unit\Utilities;
use App\Utilities\TeamSystemDatabaseProfile;
use RuntimeException;
use Tests\TestCase;
class TeamSystemDatabaseProfileTest extends TestCase
{
public function test_password_unlocks_the_fixed_team_profile(): void
{
$encoded = TeamSystemDatabaseProfile::seal([
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
], 'correct-password');
$settings = (new TeamSystemDatabaseProfile($encoded))->settings('correct-password');
$this->assertSame('shared-db.test', $settings['host']);
$this->assertSame(3306, $settings['port']);
$this->assertSame('shared_system', $settings['database']);
$this->assertSame('shared_writer', $settings['username']);
$this->assertSame('correct-password', $settings['password']);
$this->assertTrue($settings['tls_required']);
$this->assertFalse($settings['tls_verify_server_cert']);
$this->assertSame('', $settings['tls_ca']);
}
public function test_wrong_password_does_not_reveal_connection_details(): void
{
$encoded = TeamSystemDatabaseProfile::seal([
'host' => 'shared-db.test',
'port' => 3306,
'database' => 'shared_system',
'username' => 'shared_writer',
], 'correct-password');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('系统数据库密码不正确,请重新输入。');
(new TeamSystemDatabaseProfile($encoded))->settings('wrong-password');
}
}