修复:完善本地初始化与共享系统数据库切换
This commit is contained in:
parent
7660cec0f9
commit
9a65836e3f
@ -16,7 +16,7 @@ http://127.0.0.1:18088
|
||||
系统设置 → 数据库管理 → 系统数据库 → 修改连接
|
||||
```
|
||||
|
||||
填写负责人提供的“财务系统配置数据库”参数并点击“测试并保存”。保存成功后:
|
||||
填写负责人提供的“财务系统配置数据库”参数并点击“测试并安全切换”。保存成功后:
|
||||
|
||||
- 用户、侧边栏、系统设置和云数据库登记统一来自系统数据库;
|
||||
- 两个云数据库自动加载;
|
||||
@ -33,42 +33,45 @@ http://127.0.0.1:18088
|
||||
在 Git Bash、WSL、macOS 或 Linux 终端中执行:
|
||||
|
||||
```bash
|
||||
bash deploy/local-init.sh
|
||||
bash deploy/local-init.sh --build
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
1. 在 `runtime/.env` 不存在时生成仅供本机使用的环境配置和随机 `APP_KEY`;
|
||||
2. 构建并启动应用和临时 MySQL;
|
||||
3. 初始化临时管理员;
|
||||
4. 检查登录页是否可以访问;
|
||||
2. 首次构建并启动应用和临时 MySQL;
|
||||
3. 随机生成临时管理员密码,只写入被 Git 忽略的本机私有文件;
|
||||
4. 检查安装状态、数据库、管理员权限和登录页;
|
||||
5. 提示下一步配置系统数据库。
|
||||
|
||||
首次临时登录:
|
||||
|
||||
```text
|
||||
邮箱:local-bootstrap@caiwu.test
|
||||
密码:LocalOnly-18088!
|
||||
```
|
||||
首次临时登录凭据保存在 `runtime/bootstrap-credentials.env`。脚本只显示文件路径,不打印密码。不要提交、截图或转发该文件。
|
||||
|
||||
这个账号只存在于本机临时数据库中。系统数据库切换完成后,请使用系统数据库里属于自己的账号登录,不要继续把临时账号当作团队共享账号。
|
||||
|
||||
初始化中断后直接重新执行 `bash deploy/local-init.sh` 会复用已有镜像并从数据库初始化阶段继续。只有依赖、Dockerfile 或构建资源发生变化时才使用 `--build`;确认只启动现有镜像时可使用 `--skip-build`。
|
||||
|
||||
## 配置系统数据库
|
||||
|
||||
1. 打开 `http://127.0.0.1:18088/auth/login`。
|
||||
2. 首次使用临时账号登录。
|
||||
3. 打开“系统设置 → 数据库管理”。
|
||||
4. 在固定的“系统数据库”区域点击“修改连接”。
|
||||
5. 填写负责人提供的主机、端口、数据库名、账号、密码和 TLS 设置。
|
||||
6. 点击“测试并保存”。
|
||||
7. 返回数据库管理页面,确认两个云数据库已经自动出现。
|
||||
8. 退出临时账号,使用系统数据库中属于自己的账号重新登录。
|
||||
5. 填写负责人提供的主机、端口、数据库名、账号、密码和 TLS 设置。首次切换必须填写密码;只有连接身份完全不变时才允许留空。
|
||||
6. 点击“测试并安全切换”。
|
||||
7. 程序会检查迁移、管理员、公司权限映射、共享密码,并在新的 Laravel 进程中复核;失败自动恢复原连接。
|
||||
8. 成功后临时登录 Session 会立即失效,请使用系统数据库中属于自己的正式账号重新登录。
|
||||
9. 返回数据库管理页面,确认状态为“共享 · 已连接”,并确认两个云数据库已经自动出现。
|
||||
|
||||
连接测试发生在 `jiangling_app` 容器内。远程数据库填写实际 IP 或域名;数据库运行在 Windows 主机时填写 `host.docker.internal`。不要填写 `localhost` 或 `127.0.0.1`,它们在容器中代表应用容器自身。
|
||||
|
||||
“TLS · 未验证身份”只表示传输已经加密;需要验证数据库服务器身份时,同时勾选证书验证,并按需要填写容器内可读取的 CA 证书路径。
|
||||
|
||||
保存系统数据库时,程序会自动设置:
|
||||
|
||||
```dotenv
|
||||
UPSTREAM_STORED_CONFIG_ENABLED=true
|
||||
UPSTREAM_LOCAL_CONFIG_ENABLED=false
|
||||
SYSTEM_DATABASE_MODE=shared
|
||||
```
|
||||
|
||||
不要手工把 `DB_HOST` 改回 `mysql`,否则会重新使用互不相干的本机用户和配置。
|
||||
@ -100,6 +103,12 @@ 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 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 stop
|
||||
|
||||
@ -112,7 +121,7 @@ docker compose -f docker-compose.local.yml start
|
||||
## 修改后的生效方式
|
||||
|
||||
- PHP、配置、模块、页面模板和路由目录已经挂载到容器,通常清理缓存或重启应用即可;
|
||||
- Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`;
|
||||
- Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,执行 `bash deploy/local-init.sh --build`;
|
||||
- 修改 `runtime/.env` 后,执行:
|
||||
|
||||
```bash
|
||||
|
||||
@ -8,13 +8,13 @@
|
||||
2. 在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
bash deploy/local-init.sh
|
||||
bash deploy/local-init.sh --build
|
||||
```
|
||||
|
||||
3. 打开 `http://127.0.0.1:18088`。
|
||||
4. 首次使用脚本显示的本机临时账号登录。
|
||||
4. 首次从脚本提示的本机私有凭据文件读取临时账号并登录;不要把内容发到聊天或提交到 Git。
|
||||
5. 打开“系统设置 → 数据库管理 → 系统数据库”。
|
||||
6. 填写负责人提供的财务系统配置数据库并点击“测试并保存”。
|
||||
6. 填写负责人提供的财务系统配置数据库并点击“测试并安全切换”。
|
||||
7. 使用系统数据库中属于自己的账号重新登录。
|
||||
|
||||
完成系统数据库配置后,用户、侧边栏、系统设置和云数据库登记会自动统一。不需要再次填写两个云数据库密码,也不要复制生产环境或其他开发者的 `APP_KEY`、`.env`。
|
||||
|
||||
63
app/Console/Commands/ApplicationReady.php
Normal file
63
app/Console/Commands/ApplicationReady.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
class ApplicationReady extends Command
|
||||
{
|
||||
protected $signature = 'app:ready';
|
||||
|
||||
protected $description = '检查应用安装、数据库和管理员权限是否已经可用';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! config('app.installed')) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::selectOne('SELECT 1');
|
||||
|
||||
foreach ([
|
||||
'users',
|
||||
'companies',
|
||||
'roles',
|
||||
'permissions',
|
||||
'role_permissions',
|
||||
'user_roles',
|
||||
'user_companies',
|
||||
'system_database_connections',
|
||||
] as $table) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$administratorCount = DB::table('users as u')
|
||||
->join('user_roles as ur', 'ur.user_id', '=', 'u.id')
|
||||
->join('roles as r', 'r.id', '=', 'ur.role_id')
|
||||
->join('user_companies as uc', 'uc.user_id', '=', 'u.id')
|
||||
->join('role_permissions as rp', 'rp.role_id', '=', 'r.id')
|
||||
->join('permissions as p', 'p.id', '=', 'rp.permission_id')
|
||||
->where('u.enabled', 1)
|
||||
->whereIn('r.name', ['super_admin', 'admin'])
|
||||
->where('p.name', 'read-admin-panel')
|
||||
->distinct()
|
||||
->count('u.id');
|
||||
|
||||
if ($administratorCount < 1) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info('应用已就绪。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -81,10 +81,10 @@ class Install extends Command
|
||||
|
||||
DB::transaction(function () {
|
||||
$this->line('Creating company');
|
||||
Installer::createCompany($this->company_name, $this->company_email, $this->locale);
|
||||
$company = Installer::createCompany($this->company_name, $this->company_email, $this->locale);
|
||||
|
||||
$this->line('Creating admin');
|
||||
Installer::createUser($this->admin_email, $this->admin_password, $this->locale);
|
||||
Installer::createUser($this->admin_email, $this->admin_password, $this->locale, $company->id);
|
||||
});
|
||||
|
||||
$this->line('Applying the final touches');
|
||||
|
||||
103
app/Console/Commands/ReencryptUpstreamPasswords.php
Normal file
103
app/Console/Commands/ReencryptUpstreamPasswords.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use App\Utilities\UpstreamCredentialCipher;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ReencryptUpstreamPasswords extends Command
|
||||
{
|
||||
protected $signature = 'system-database:reencrypt-upstream-passwords {--apply : 备份后执行重加密}';
|
||||
|
||||
protected $description = '将旧 APP_KEY 云数据库密文迁移为可跨开发机共享的密文';
|
||||
|
||||
public function handle(
|
||||
SystemDatabaseConfiguration $configuration,
|
||||
UpstreamCredentialCipher $cipher
|
||||
): int {
|
||||
if ($configuration->current()['mode'] !== 'shared') {
|
||||
$this->components->error('当前不是共享系统数据库,拒绝执行。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$rows = DB::table('system_database_connections')
|
||||
->whereNotNull('password')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('shared_password')->orWhere('shared_password', '');
|
||||
})
|
||||
->get(['id', 'source_key', 'password', 'shared_password']);
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->components->info('没有需要迁移的共享云数据库密码。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('apply')) {
|
||||
$this->components->warn(sprintf(
|
||||
'检测到 %d 条旧密码记录。当前仅检查,使用 --apply 才会备份并迁移。',
|
||||
$rows->count()
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$backupPath = $this->backup($rows->all());
|
||||
|
||||
DB::transaction(function () use ($rows, $cipher) {
|
||||
foreach ($rows as $row) {
|
||||
$plain = Crypt::decryptString((string) $row->password);
|
||||
|
||||
DB::table('system_database_connections')
|
||||
->where('id', $row->id)
|
||||
->update([
|
||||
'shared_password' => $cipher->encrypt($plain),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->components->error(
|
||||
'迁移失败,未提交任何数据库修改。请确认当前环境仍持有旧记录对应的 APP_KEY。'
|
||||
);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info(sprintf(
|
||||
'已备份并迁移 %d 条记录。私有备份:%s',
|
||||
$rows->count(),
|
||||
$backupPath
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function backup(array $rows): string
|
||||
{
|
||||
$directory = storage_path('app/system-database-config-backups');
|
||||
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0700, true) && ! is_dir($directory)) {
|
||||
throw new RuntimeException('无法创建私有备份目录。');
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR
|
||||
. 'upstream-credentials-' . now()->format('Ymd-His-u') . '.json';
|
||||
$payload = json_encode($rows, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
|
||||
|
||||
if (file_put_contents($path, $payload, LOCK_EX) === false) {
|
||||
throw new RuntimeException('无法写入私有备份。');
|
||||
}
|
||||
|
||||
chmod($path, 0600);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/VerifySystemDatabase.php
Normal file
38
app/Console/Commands/VerifySystemDatabase.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class VerifySystemDatabase extends Command
|
||||
{
|
||||
protected $signature = 'system-database:verify';
|
||||
|
||||
protected $description = '在新的 Laravel 进程中验证当前共享系统数据库';
|
||||
|
||||
public function handle(SystemDatabaseConfiguration $configuration): int
|
||||
{
|
||||
try {
|
||||
$settings = $configuration->current();
|
||||
|
||||
if ($settings['mode'] !== 'shared') {
|
||||
throw new RuntimeException('当前不是共享系统数据库模式。');
|
||||
}
|
||||
|
||||
DB::purge((string) $settings['connection']);
|
||||
DB::reconnect((string) $settings['connection']);
|
||||
DB::selectOne('SELECT 1');
|
||||
$configuration->probe($settings);
|
||||
} catch (Throwable) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info('共享系统数据库已通过应用级验证。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -32,10 +32,19 @@ class Settings extends Controller
|
||||
$locale = session('locale') ?? config('app.locale');
|
||||
|
||||
// Create company
|
||||
Installer::createCompany($request->get('company_name'), $request->get('company_email'), $locale);
|
||||
$company = Installer::createCompany(
|
||||
$request->get('company_name'),
|
||||
$request->get('company_email'),
|
||||
$locale
|
||||
);
|
||||
|
||||
// Create user
|
||||
Installer::createUser($request->get('user_email'), $request->get('user_password'), $locale);
|
||||
Installer::createUser(
|
||||
$request->get('user_email'),
|
||||
$request->get('user_password'),
|
||||
$locale,
|
||||
$company->id
|
||||
);
|
||||
});
|
||||
|
||||
// Make the final touches
|
||||
|
||||
@ -32,9 +32,10 @@ class Databases extends Controller
|
||||
)
|
||||
{
|
||||
$systemDatabase = $systemDatabaseConfiguration->current();
|
||||
$sharedMode = $systemDatabase['mode'] === 'shared';
|
||||
|
||||
return view('system_settings.databases.index', [
|
||||
'connections' => $storedConfiguration->editable()
|
||||
'connections' => $sharedMode && $storedConfiguration->editable()
|
||||
? DatabaseConnection::query()
|
||||
->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)
|
||||
->orderBy('name')
|
||||
@ -42,15 +43,17 @@ class Databases extends Controller
|
||||
: collect(),
|
||||
'sourceLabels' => $this->sourceLabels(),
|
||||
'systemDatabase' => [
|
||||
'name' => '财务系统配置数据库',
|
||||
'name' => $sharedMode ? '财务系统配置数据库' : '本地临时引导库',
|
||||
'connection' => $systemDatabase['connection'],
|
||||
'driver' => $systemDatabase['driver'],
|
||||
'host' => $systemDatabase['host'] ?: '本机文件',
|
||||
'port' => $systemDatabase['port'],
|
||||
'database' => $systemDatabase['database'],
|
||||
'mode' => $systemDatabase['mode'],
|
||||
'tls_required' => $systemDatabase['tls_required'],
|
||||
'tls_verify_server_cert' => $systemDatabase['tls_verify_server_cert'],
|
||||
],
|
||||
'editable' => $storedConfiguration->editable(),
|
||||
'editable' => $sharedMode && $storedConfiguration->editable(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -65,6 +68,9 @@ class Databases extends Controller
|
||||
'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'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
@ -79,18 +85,37 @@ class Databases extends Controller
|
||||
'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]/'],
|
||||
]);
|
||||
|
||||
$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' => $data['password'] !== null && $data['password'] !== ''
|
||||
'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'] === '') {
|
||||
@ -109,18 +134,23 @@ class Databases extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
flash(sprintf(
|
||||
'系统数据库连接已验证并保存,自动识别表前缀“%s”,共检测到 %d 个用户;新配置将在下一次请求生效。',
|
||||
$result['prefix'] === '' ? '无前缀' : $result['prefix'],
|
||||
'系统数据库已安全切换,检测到 %d 个用户、%d 个管理员和 %d 个共享来源。请使用共享系统数据库中的正式账号重新登录。',
|
||||
$result['user_count'],
|
||||
$result['admin_count'],
|
||||
$result['source_count'],
|
||||
))->success();
|
||||
|
||||
return redirect()->route('system-settings.databases.index');
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
public function create(UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||||
|
||||
return view('system_settings.databases.form', [
|
||||
'connection' => null,
|
||||
@ -131,7 +161,7 @@ class Databases extends Controller
|
||||
|
||||
public function store(Request $request, UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||||
|
||||
$data = $this->validateConnection($request);
|
||||
$connection = DatabaseConnection::create($this->attributes($data, null, $request));
|
||||
@ -144,7 +174,7 @@ class Databases extends Controller
|
||||
|
||||
public function edit(int $connection, UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||||
$connection = $this->connection($connection);
|
||||
|
||||
return view('system_settings.databases.form', [
|
||||
@ -156,7 +186,7 @@ class Databases extends Controller
|
||||
|
||||
public function update(int $connection, Request $request, UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||||
$connection = $this->connection($connection);
|
||||
$oldSource = $connection->source_key;
|
||||
$data = $this->validateConnection($request, $connection);
|
||||
@ -172,7 +202,7 @@ class Databases extends Controller
|
||||
|
||||
public function destroy(int $connection, UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||||
$connection = $this->connection($connection);
|
||||
$source = $connection->source_key;
|
||||
$connection->delete();
|
||||
@ -185,7 +215,7 @@ class Databases extends Controller
|
||||
|
||||
public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||||
$connection = $this->connection($connection);
|
||||
|
||||
try {
|
||||
|
||||
@ -236,7 +236,7 @@ class Installer
|
||||
|
||||
public static function createCompany($name, $email, $locale)
|
||||
{
|
||||
dispatch_sync(new CreateCompany([
|
||||
return dispatch_sync(new CreateCompany([
|
||||
'name' => $name,
|
||||
'domain' => '',
|
||||
'email' => $email,
|
||||
@ -246,15 +246,27 @@ class Installer
|
||||
]));
|
||||
}
|
||||
|
||||
public static function createUser($email, $password, $locale)
|
||||
public static function createUser($email, $password, $locale, ?int $companyId = null)
|
||||
{
|
||||
$companyId ??= (int) company_id();
|
||||
$roleId = DB::table('roles')->where('name', 'super_admin')->value('id')
|
||||
?? DB::table('roles')->where('name', 'admin')->value('id');
|
||||
|
||||
if ($companyId < 1) {
|
||||
throw new \RuntimeException('安装管理员无法绑定到有效公司。');
|
||||
}
|
||||
|
||||
if (! $roleId) {
|
||||
throw new \RuntimeException('安装管理员无法绑定到有效角色。');
|
||||
}
|
||||
|
||||
dispatch_sync(new CreateUser([
|
||||
'name' => '',
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'locale' => $locale,
|
||||
'companies' => ['1'],
|
||||
'roles' => ['1'],
|
||||
'companies' => [$companyId],
|
||||
'roles' => [(int) $roleId],
|
||||
'enabled' => '1',
|
||||
]));
|
||||
}
|
||||
|
||||
@ -2,9 +2,12 @@
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
class SystemDatabaseConfiguration
|
||||
@ -22,26 +25,35 @@ class SystemDatabaseConfiguration
|
||||
$connection = (array) config('database.connections.' . $connectionName, []);
|
||||
$options = (array) ($connection['options'] ?? []);
|
||||
|
||||
return [
|
||||
'connection' => $connectionName,
|
||||
'driver' => (string) ($connection['driver'] ?? $connectionName),
|
||||
'host' => (string) (
|
||||
$host = (string) (
|
||||
data_get($connection, 'write.host.0')
|
||||
?? data_get($connection, 'read.host.0')
|
||||
?? ($connection['host'] ?? '')
|
||||
),
|
||||
);
|
||||
$tlsRequired = isset($options[PDO::MYSQL_ATTR_SSL_CA])
|
||||
|| isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]);
|
||||
$verifyServerCertificate = defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')
|
||||
? (bool) ($options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] ?? false)
|
||||
: false;
|
||||
|
||||
return [
|
||||
'connection' => $connectionName,
|
||||
'driver' => (string) ($connection['driver'] ?? $connectionName),
|
||||
'host' => $host,
|
||||
'port' => (int) ($connection['port'] ?? 3306),
|
||||
'database' => (string) ($connection['database'] ?? ''),
|
||||
'username' => (string) ($connection['username'] ?? ''),
|
||||
'password' => (string) ($connection['password'] ?? ''),
|
||||
'prefix' => (string) ($connection['prefix'] ?? ''),
|
||||
'tls_required' => isset($options[PDO::MYSQL_ATTR_SSL_CA])
|
||||
|| isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]),
|
||||
'mode' => $this->databaseMode($host, (string) ($connection['database'] ?? '')),
|
||||
'tls_required' => $tlsRequired,
|
||||
'tls_verify_server_cert' => $tlsRequired && $verifyServerCertificate,
|
||||
'tls_ca' => (string) ($options[PDO::MYSQL_ATTR_SSL_CA] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cipher:string,user_count:int,prefix:string}
|
||||
* @return array{cipher:string,user_count:int,admin_count:int,source_count:int,prefix:string}
|
||||
*/
|
||||
public function probe(array $settings): array
|
||||
{
|
||||
@ -49,6 +61,9 @@ class SystemDatabaseConfiguration
|
||||
throw new RuntimeException('系统数据库目前只支持 MariaDB/MySQL。');
|
||||
}
|
||||
|
||||
$this->assertHostResolves((string) $settings['host']);
|
||||
$this->assertTcpReachable((string) $settings['host'], (int) $settings['port']);
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 8,
|
||||
@ -57,8 +72,13 @@ class SystemDatabaseConfiguration
|
||||
if (! empty($settings['tls_required'])) {
|
||||
$options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT';
|
||||
|
||||
if (! empty($settings['tls_ca'])) {
|
||||
$options[PDO::MYSQL_ATTR_SSL_CA] = (string) $settings['tls_ca'];
|
||||
}
|
||||
|
||||
if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
|
||||
$options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = false;
|
||||
$options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] =
|
||||
! empty($settings['tls_verify_server_cert']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,20 +111,42 @@ class SystemDatabaseConfiguration
|
||||
array_map('strval', $tableNames),
|
||||
$this->current()['prefix']
|
||||
);
|
||||
$this->assertRequiredSchema($pdo, $prefix);
|
||||
$this->assertCurrentMigrations($pdo, $prefix);
|
||||
|
||||
$userCount = (int) $pdo->query(
|
||||
'SELECT COUNT(*) FROM `' . str_replace('`', '``', $prefix . 'users') . '`'
|
||||
'SELECT COUNT(*) FROM ' . $this->quoteIdentifier($prefix . 'users')
|
||||
)->fetchColumn();
|
||||
|
||||
if ($userCount < 1) {
|
||||
throw new RuntimeException('目标库没有任何用户,已拒绝切换。');
|
||||
}
|
||||
|
||||
return ['cipher' => $cipher, 'user_count' => $userCount, 'prefix' => $prefix];
|
||||
$adminCount = $this->administratorCount($pdo, $prefix);
|
||||
|
||||
if ($adminCount < 1) {
|
||||
throw new RuntimeException('目标库没有具备公司和后台权限的可登录管理员,已拒绝切换。');
|
||||
}
|
||||
|
||||
$sourceCount = $this->assertSharedSources(
|
||||
$pdo,
|
||||
$prefix,
|
||||
(string) $settings['password']
|
||||
);
|
||||
|
||||
return [
|
||||
'cipher' => $cipher,
|
||||
'user_count' => $userCount,
|
||||
'admin_count' => $adminCount,
|
||||
'source_count' => $sourceCount,
|
||||
'prefix' => $prefix,
|
||||
];
|
||||
} catch (PDOException $exception) {
|
||||
throw new RuntimeException($this->safeConnectionError($exception), 0, $exception);
|
||||
} catch (RuntimeException $exception) {
|
||||
throw $exception;
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException('系统数据库连接测试失败。', 0, $exception);
|
||||
throw new RuntimeException($this->safeConnectionError($exception), 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,7 +168,7 @@ class SystemDatabaseConfiguration
|
||||
throw new RuntimeException('无法创建系统数据库配置备份目录。');
|
||||
}
|
||||
|
||||
$backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His') . '.backup';
|
||||
$backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His-u') . '.backup';
|
||||
|
||||
if (file_put_contents($backupPath, $current, LOCK_EX) === false) {
|
||||
throw new RuntimeException('系统数据库配置备份失败,未执行切换。');
|
||||
@ -148,19 +190,51 @@ class SystemDatabaseConfiguration
|
||||
'DB_PASSWORD' => (string) $settings['password'],
|
||||
'DB_PREFIX' => $prefix,
|
||||
'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '',
|
||||
'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => 'false',
|
||||
'MYSQL_ATTR_SSL_CA' => ! empty($settings['tls_required'])
|
||||
? (string) ($settings['tls_ca'] ?? '')
|
||||
: '',
|
||||
'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => ! empty($settings['tls_required'])
|
||||
&& ! empty($settings['tls_verify_server_cert'])
|
||||
? 'true'
|
||||
: 'false',
|
||||
'SYSTEM_DATABASE_MODE' => 'shared',
|
||||
// 系统数据库保存后统一读取共享云数据库配置,不再要求开发机
|
||||
// 另外维护完整的 DB_<SOURCE>_* 连接参数。
|
||||
'UPSTREAM_STORED_CONFIG_ENABLED' => 'true',
|
||||
'UPSTREAM_LOCAL_CONFIG_ENABLED' => 'false',
|
||||
]);
|
||||
|
||||
try {
|
||||
if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) {
|
||||
file_put_contents($this->environmentPath, $current, LOCK_EX);
|
||||
throw new RuntimeException('系统数据库配置保存失败,已恢复原配置。');
|
||||
throw new RuntimeException('系统数据库配置写入失败。');
|
||||
}
|
||||
|
||||
Artisan::call('config:clear');
|
||||
$this->runFreshVerification('system-database:verify');
|
||||
} catch (Throwable $exception) {
|
||||
$restored = file_put_contents($this->environmentPath, $current, LOCK_EX) !== false;
|
||||
Artisan::call('config:clear');
|
||||
|
||||
if (! $restored) {
|
||||
throw new RuntimeException(
|
||||
'新配置未通过应用级验证,且旧配置自动恢复失败。请立即使用私有备份恢复。',
|
||||
0,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->runFreshVerification('app:ready');
|
||||
} catch (Throwable $rollbackException) {
|
||||
throw new RuntimeException(
|
||||
'新配置未通过应用级验证,旧配置文件已经恢复,但旧连接复核失败。',
|
||||
0,
|
||||
$rollbackException
|
||||
);
|
||||
}
|
||||
|
||||
throw new RuntimeException('新配置未通过应用级验证,已自动恢复原系统数据库。', 0, $exception);
|
||||
}
|
||||
|
||||
return $backupPath;
|
||||
}
|
||||
@ -172,7 +246,18 @@ class SystemDatabaseConfiguration
|
||||
*/
|
||||
public function detectTablePrefix(array $tableNames, string $preferredPrefix = ''): string
|
||||
{
|
||||
$requiredTables = ['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings'];
|
||||
$requiredTables = [
|
||||
'migrations',
|
||||
'users',
|
||||
'companies',
|
||||
'roles',
|
||||
'permissions',
|
||||
'role_permissions',
|
||||
'user_roles',
|
||||
'user_companies',
|
||||
'settings',
|
||||
'system_database_connections',
|
||||
];
|
||||
$candidates = null;
|
||||
|
||||
foreach ($requiredTables as $requiredTable) {
|
||||
@ -214,6 +299,226 @@ class SystemDatabaseConfiguration
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function databaseMode(string $host, string $database): string
|
||||
{
|
||||
$configured = strtolower(trim((string) config('system_database.mode', '')));
|
||||
|
||||
if (in_array($configured, ['bootstrap', 'shared'], true)) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return app()->environment(['local', 'development'])
|
||||
&& $host === 'mysql'
|
||||
&& $database === 'akaunting'
|
||||
? 'bootstrap'
|
||||
: 'shared';
|
||||
}
|
||||
|
||||
private function assertHostResolves(string $host): void
|
||||
{
|
||||
if (
|
||||
filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
|
||||
|| filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($host === '' || gethostbynamel($host) === false) {
|
||||
throw new RuntimeException('连接失败阶段:DNS 解析。请检查主机名称。');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTcpReachable(string $host, int $port): void
|
||||
{
|
||||
$address = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
|
||||
? sprintf('tcp://[%s]:%d', $host, $port)
|
||||
: sprintf('tcp://%s:%d', $host, $port);
|
||||
$socket = @stream_socket_client($address, $errorCode, $errorMessage, 4);
|
||||
|
||||
if (! is_resource($socket)) {
|
||||
throw new RuntimeException('连接失败阶段:TCP 端口。请检查端口、网络和访问白名单。');
|
||||
}
|
||||
|
||||
fclose($socket);
|
||||
}
|
||||
|
||||
private function assertRequiredSchema(PDO $pdo, string $prefix): void
|
||||
{
|
||||
$requiredColumns = [
|
||||
'users' => ['id', 'email', 'password', 'enabled'],
|
||||
'companies' => ['id', 'enabled'],
|
||||
'roles' => ['id', 'name'],
|
||||
'permissions' => ['id', 'name'],
|
||||
'role_permissions' => ['role_id', 'permission_id'],
|
||||
'user_roles' => ['user_id', 'role_id'],
|
||||
'user_companies' => ['user_id', 'company_id'],
|
||||
'system_database_connections' => [
|
||||
'company_id',
|
||||
'source_key',
|
||||
'password',
|
||||
'shared_password',
|
||||
'allowed_scopes',
|
||||
],
|
||||
];
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT column_name FROM information_schema.columns '
|
||||
. 'WHERE table_schema = DATABASE() AND table_name = ?'
|
||||
);
|
||||
|
||||
foreach ($requiredColumns as $table => $columns) {
|
||||
$statement->execute([$prefix . $table]);
|
||||
$available = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
|
||||
$missing = array_diff($columns, $available);
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'目标库表结构不完整:%s 缺少 %d 个必要字段。',
|
||||
$table,
|
||||
count($missing)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertCurrentMigrations(PDO $pdo, string $prefix): void
|
||||
{
|
||||
$files = glob(database_path('migrations/*.php')) ?: [];
|
||||
$required = array_map(
|
||||
static fn (string $path): string => pathinfo($path, PATHINFO_FILENAME),
|
||||
$files
|
||||
);
|
||||
$applied = array_map(
|
||||
'strval',
|
||||
$pdo->query(
|
||||
'SELECT migration FROM ' . $this->quoteIdentifier($prefix . 'migrations')
|
||||
)->fetchAll(PDO::FETCH_COLUMN)
|
||||
);
|
||||
$missing = array_diff($required, $applied);
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'目标库缺少当前代码要求的 %d 个数据库迁移,已拒绝切换。',
|
||||
count($missing)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function administratorCount(PDO $pdo, string $prefix): int
|
||||
{
|
||||
$users = $this->quoteIdentifier($prefix . 'users');
|
||||
$roles = $this->quoteIdentifier($prefix . 'roles');
|
||||
$permissions = $this->quoteIdentifier($prefix . 'permissions');
|
||||
$userRoles = $this->quoteIdentifier($prefix . 'user_roles');
|
||||
$userCompanies = $this->quoteIdentifier($prefix . 'user_companies');
|
||||
$rolePermissions = $this->quoteIdentifier($prefix . 'role_permissions');
|
||||
|
||||
return (int) $pdo->query(
|
||||
"SELECT COUNT(DISTINCT u.id) FROM {$users} u "
|
||||
. "INNER JOIN {$userRoles} ur ON ur.user_id = u.id "
|
||||
. "INNER JOIN {$roles} r ON r.id = ur.role_id "
|
||||
. "INNER JOIN {$userCompanies} uc ON uc.user_id = u.id "
|
||||
. "INNER JOIN {$rolePermissions} rp ON rp.role_id = r.id "
|
||||
. "INNER JOIN {$permissions} p ON p.id = rp.permission_id "
|
||||
. "WHERE u.enabled = 1 "
|
||||
. "AND r.name IN ('super_admin', 'admin') "
|
||||
. "AND p.name = 'read-admin-panel'"
|
||||
)->fetchColumn();
|
||||
}
|
||||
|
||||
private function assertSharedSources(PDO $pdo, string $prefix, string $systemDatabasePassword): int
|
||||
{
|
||||
$requiredSources = array_keys((array) config('upstreams.sources', []));
|
||||
|
||||
if ($requiredSources === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$table = $this->quoteIdentifier($prefix . 'system_database_connections');
|
||||
$statement = $pdo->query(
|
||||
"SELECT source_key, shared_password FROM {$table} WHERE company_id = 0"
|
||||
);
|
||||
$records = [];
|
||||
|
||||
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $record) {
|
||||
$source = (string) ($record['source_key'] ?? '');
|
||||
|
||||
if ($source !== '') {
|
||||
$records[$source] = (string) ($record['shared_password'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredSources as $source) {
|
||||
if (! array_key_exists($source, $records)) {
|
||||
throw new RuntimeException('目标库缺少已登记的共享云数据库来源:' . $source . '。');
|
||||
}
|
||||
|
||||
if ($records[$source] === '') {
|
||||
throw new RuntimeException('共享云数据库密码尚未迁移:' . $source . '。');
|
||||
}
|
||||
|
||||
try {
|
||||
app(UpstreamCredentialCipher::class)->decryptWithSecret(
|
||||
$records[$source],
|
||||
$systemDatabasePassword
|
||||
);
|
||||
} catch (DecryptException|RuntimeException) {
|
||||
throw new RuntimeException('系统数据库密码无法解锁共享云数据库配置:' . $source . '。');
|
||||
}
|
||||
}
|
||||
|
||||
return count($requiredSources);
|
||||
}
|
||||
|
||||
private function safeConnectionError(Throwable $exception): string
|
||||
{
|
||||
$driverCode = $exception instanceof PDOException
|
||||
? (int) ($exception->errorInfo[1] ?? 0)
|
||||
: 0;
|
||||
$message = strtolower($exception->getMessage());
|
||||
|
||||
if ($driverCode === 1045 || str_contains($message, 'access denied')) {
|
||||
return '连接失败阶段:账号认证。请检查数据库账号和密码。';
|
||||
}
|
||||
|
||||
if ($driverCode === 1049 || str_contains($message, 'unknown database')) {
|
||||
return '连接失败阶段:选择数据库。请检查数据库名称和账号权限。';
|
||||
}
|
||||
|
||||
if (
|
||||
$driverCode === 2026
|
||||
|| str_contains($message, 'ssl')
|
||||
|| str_contains($message, 'certificate')
|
||||
|| str_contains($message, 'tls')
|
||||
) {
|
||||
return '连接失败阶段:TLS 协商或证书验证。请检查 TLS 和 CA 配置。';
|
||||
}
|
||||
|
||||
if ($driverCode === 2002 || str_contains($message, 'timed out')) {
|
||||
return '连接失败阶段:数据库握手。请检查网络、端口和访问白名单。';
|
||||
}
|
||||
|
||||
return '连接失败阶段:数据库登录或初始化。请检查库名、账号权限和 TLS 配置。';
|
||||
}
|
||||
|
||||
protected function runFreshVerification(string $command): void
|
||||
{
|
||||
$process = new Process(
|
||||
[PHP_BINARY, base_path('artisan'), $command, '--no-ansi', '--quiet'],
|
||||
base_path()
|
||||
);
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('新的 Laravel 进程未通过应用级数据库验证。');
|
||||
}
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return '`' . str_replace('`', '``', $identifier) . '`';
|
||||
}
|
||||
|
||||
private function assertValidPrefix(string $prefix): void
|
||||
{
|
||||
if (strlen($prefix) > 50 || preg_match('/^[A-Za-z0-9_]*$/D', $prefix) !== 1) {
|
||||
|
||||
@ -20,18 +20,24 @@ class UpstreamCredentialCipher
|
||||
private const AAD = 'CaiWuXiTong upstream credential v1';
|
||||
|
||||
public function encrypt(string $value): string
|
||||
{
|
||||
return $this->encryptWithSecret($value, $this->systemDatabasePassword());
|
||||
}
|
||||
|
||||
public function encryptWithSecret(string $value, string $secret): string
|
||||
{
|
||||
if ($value === '') {
|
||||
throw new RuntimeException('云数据库密码不能为空。');
|
||||
}
|
||||
|
||||
$this->assertSecret($secret);
|
||||
$salt = random_bytes(16);
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$encrypted = openssl_encrypt(
|
||||
$value,
|
||||
self::CIPHER,
|
||||
$this->deriveKey($salt),
|
||||
$this->deriveKey($salt, $secret),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
@ -54,11 +60,18 @@ class UpstreamCredentialCipher
|
||||
}
|
||||
|
||||
public function decrypt(string $payload): string
|
||||
{
|
||||
return $this->decryptWithSecret($payload, $this->systemDatabasePassword());
|
||||
}
|
||||
|
||||
public function decryptWithSecret(string $payload, string $secret): string
|
||||
{
|
||||
if (! str_starts_with($payload, self::PREFIX)) {
|
||||
throw new DecryptException('共享云数据库密码格式不受支持。');
|
||||
}
|
||||
|
||||
$this->assertSecret($secret);
|
||||
|
||||
try {
|
||||
$encoded = substr($payload, strlen(self::PREFIX));
|
||||
$json = base64_decode($encoded, true);
|
||||
@ -87,7 +100,7 @@ class UpstreamCredentialCipher
|
||||
$decrypted = openssl_decrypt(
|
||||
$encrypted,
|
||||
self::CIPHER,
|
||||
$this->deriveKey($salt),
|
||||
$this->deriveKey($salt, $secret),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
@ -106,15 +119,25 @@ class UpstreamCredentialCipher
|
||||
}
|
||||
}
|
||||
|
||||
private function deriveKey(string $salt): string
|
||||
private function deriveKey(string $salt, string $secret): string
|
||||
{
|
||||
return hash_hkdf('sha256', $secret, 32, self::AAD, $salt);
|
||||
}
|
||||
|
||||
private function systemDatabasePassword(): string
|
||||
{
|
||||
$connection = (string) config('database.default', 'mysql');
|
||||
$password = (string) config('database.connections.' . $connection . '.password', '');
|
||||
|
||||
if ($password === '') {
|
||||
throw new RuntimeException('系统数据库密码不可用,无法处理共享云数据库密码。');
|
||||
$this->assertSecret($password);
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
return hash_hkdf('sha256', $password, 32, self::AAD, $salt);
|
||||
private function assertSecret(string $secret): void
|
||||
{
|
||||
if ($secret === '') {
|
||||
throw new RuntimeException('系统数据库密码不可用,无法处理共享云数据库密码。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
config/system_database.php
Normal file
14
config/system_database.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 系统数据库运行模式
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| bootstrap 仅用于全新本地环境首次登录;shared 表示已经切换到团队共享
|
||||
| 系统数据库。旧环境没有该值时,页面会根据本地 Compose 主机名安全推断。
|
||||
|
|
||||
*/
|
||||
'mode' => env('SYSTEM_DATABASE_MODE', ''),
|
||||
];
|
||||
@ -54,7 +54,7 @@ return new class extends Migration
|
||||
}
|
||||
|
||||
if (! $superAdmin) {
|
||||
$superAdminId = DB::table('roles')->insertGetId([
|
||||
DB::table('roles')->insert([
|
||||
'name' => 'super_admin',
|
||||
'display_name' => '超级管理员',
|
||||
'description' => '系统最高权限',
|
||||
@ -63,14 +63,6 @@ return new class extends Migration
|
||||
'created_by' => 0,
|
||||
'created_from' => 'system',
|
||||
]);
|
||||
|
||||
$permissionIds = DB::table('permissions')->pluck('id');
|
||||
foreach ($permissionIds as $permissionId) {
|
||||
DB::table('role_permissions')->insertOrIgnore([
|
||||
'role_id' => $superAdminId,
|
||||
'permission_id' => $permissionId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$adminId = $this->firstOrCreateRole(
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (
|
||||
! Schema::hasTable('roles')
|
||||
|| ! Schema::hasTable('permissions')
|
||||
|| ! Schema::hasTable('role_permissions')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$superAdminId = DB::table('roles')->where('name', 'super_admin')->value('id');
|
||||
|
||||
if (! $superAdminId) {
|
||||
$superAdminId = DB::table('roles')->insertGetId([
|
||||
'name' => 'super_admin',
|
||||
'display_name' => '超级管理员',
|
||||
'description' => '系统最高权限',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'created_by' => 0,
|
||||
'created_from' => 'system',
|
||||
]);
|
||||
}
|
||||
|
||||
$adminId = DB::table('roles')->where('name', 'admin')->value('id');
|
||||
|
||||
if (! $adminId) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (
|
||||
DB::table('role_permissions')
|
||||
->where('role_id', $adminId)
|
||||
->pluck('permission_id') as $permissionId
|
||||
) {
|
||||
DB::table('role_permissions')->insertOrIgnore([
|
||||
'role_id' => $superAdminId,
|
||||
'permission_id' => $permissionId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 权限降级可能锁死现有管理员,回滚代码时保留已同步的权限。
|
||||
}
|
||||
};
|
||||
@ -4,6 +4,7 @@ namespace Database\Seeds;
|
||||
use App\Abstracts\Model;
|
||||
use App\Traits\Permissions as Helper;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Permissions extends Seeder
|
||||
{
|
||||
@ -170,5 +171,21 @@ class Permissions extends Seeder
|
||||
];
|
||||
|
||||
$this->attachPermissionsByRoleNames($rows);
|
||||
|
||||
$adminId = DB::table('roles')->where('name', 'admin')->value('id');
|
||||
$superAdminId = DB::table('roles')->where('name', 'super_admin')->value('id');
|
||||
|
||||
if ($adminId && $superAdminId) {
|
||||
foreach (
|
||||
DB::table('role_permissions')
|
||||
->where('role_id', $adminId)
|
||||
->pluck('permission_id') as $permissionId
|
||||
) {
|
||||
DB::table('role_permissions')->insertOrIgnore([
|
||||
'role_id' => $superAdminId,
|
||||
'permission_id' => $permissionId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ use App\Traits\Jobs;
|
||||
use App\Traits\Modules;
|
||||
use Artisan;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TestCompany extends Seeder
|
||||
{
|
||||
@ -80,13 +81,16 @@ class TestCompany extends Seeder
|
||||
|
||||
public function createUser()
|
||||
{
|
||||
$roleId = DB::table('roles')->where('name', 'super_admin')->value('id')
|
||||
?? DB::table('roles')->where('name', 'admin')->value('id');
|
||||
|
||||
$this->dispatch(new CreateUser([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@company.com',
|
||||
'password' => '123456',
|
||||
'locale' => 'en-GB',
|
||||
'companies' => [company_id()],
|
||||
'roles' => ['1'],
|
||||
'roles' => [(int) $roleId],
|
||||
'enabled' => '1',
|
||||
]));
|
||||
|
||||
|
||||
@ -72,14 +72,13 @@ COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
|
||||
COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini
|
||||
COPY deploy/entrypoint.sh /usr/local/bin/jiangling-entrypoint
|
||||
|
||||
# ---------------- 安装 PHP 依赖(生产模式,跳过 ide-helper 等脚本)----------------
|
||||
# ---------------- 安装 PHP 依赖(本地开发模式,包含 PHPUnit 等测试工具)----------------
|
||||
RUN composer install --prefer-dist \
|
||||
--no-interaction \
|
||||
--no-scripts \
|
||||
--no-progress \
|
||||
--no-ansi \
|
||||
--no-dev \
|
||||
&& composer dump-autoload --no-scripts --no-dev --optimize
|
||||
&& composer dump-autoload --no-scripts --optimize
|
||||
|
||||
# ---------------- 构建前端资源 ----------------
|
||||
# 先装依赖(python3/make/g++ 让 node-sass 能编译)
|
||||
|
||||
@ -5,8 +5,64 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
compose_file="docker-compose.local.yml"
|
||||
environment_file="runtime/.env"
|
||||
bootstrap_email="local-bootstrap@caiwu.test"
|
||||
bootstrap_password="LocalOnly-18088!"
|
||||
credential_file="runtime/bootstrap-credentials.env"
|
||||
app_image="jiangling_akaunting:local"
|
||||
build_mode="auto"
|
||||
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
--build)
|
||||
build_mode="build"
|
||||
;;
|
||||
--skip-build)
|
||||
build_mode="skip"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "用法:bash deploy/local-init.sh [--build|--skip-build]"
|
||||
echo " --build 强制重新构建本地开发镜像"
|
||||
echo " --skip-build 只启动已有镜像;镜像不存在时停止"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "错误:未知参数 $argument" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
sed -n "s/^${key}=//p" "$environment_file" \
|
||||
| tail -n 1 \
|
||||
| tr -d '\r' \
|
||||
| sed 's/^"//; s/"$//'
|
||||
}
|
||||
|
||||
set_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local temporary
|
||||
temporary="${environment_file}.tmp.$$"
|
||||
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { found = 0 }
|
||||
index($0, key "=") == 1 {
|
||||
print key "=" value
|
||||
found = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!found) {
|
||||
print key "=" value
|
||||
}
|
||||
}
|
||||
' "$environment_file" > "$temporary"
|
||||
|
||||
cp "$temporary" "$environment_file"
|
||||
rm -f "$temporary"
|
||||
chmod 600 "$environment_file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo "==> 工作目录: $(pwd)"
|
||||
|
||||
@ -48,7 +104,8 @@ CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
UPSTREAM_STORED_CONFIG_ENABLED=true
|
||||
SYSTEM_DATABASE_MODE=bootstrap
|
||||
UPSTREAM_STORED_CONFIG_ENABLED=false
|
||||
UPSTREAM_LOCAL_CONFIG_ENABLED=false
|
||||
EOF
|
||||
|
||||
@ -56,10 +113,25 @@ EOF
|
||||
echo "==> 已生成本机私有 runtime/.env(不会提交到 Git)。"
|
||||
fi
|
||||
|
||||
echo "==> [1/4] 构建镜像并启动本地容器..."
|
||||
docker compose -f "$compose_file" up -d --build
|
||||
image_exists=0
|
||||
if docker image inspect "$app_image" >/dev/null 2>&1; then
|
||||
image_exists=1
|
||||
fi
|
||||
|
||||
echo "==> [2/4] 等待临时 MySQL 就绪..."
|
||||
if [ "$build_mode" = "build" ] || { [ "$build_mode" = "auto" ] && [ "$image_exists" -eq 0 ]; }; then
|
||||
echo "==> [1/5] 构建本地开发镜像..."
|
||||
docker compose -f "$compose_file" build app
|
||||
elif [ "$build_mode" = "skip" ] && [ "$image_exists" -eq 0 ]; then
|
||||
echo "错误:本地开发镜像不存在,不能使用 --skip-build。" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "==> [1/5] 复用现有本地开发镜像。需要更新依赖时请使用 --build。"
|
||||
fi
|
||||
|
||||
echo "==> [2/5] 启动本地容器..."
|
||||
docker compose -f "$compose_file" up -d --no-build
|
||||
|
||||
echo "==> [3/5] 等待临时 MySQL 就绪..."
|
||||
mysql_ready=0
|
||||
for i in $(seq 1 60); do
|
||||
if docker compose -f "$compose_file" exec -T mysql \
|
||||
@ -78,11 +150,32 @@ if [ "$mysql_ready" -ne 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
db_host="$(sed -n 's/^DB_HOST=//p' "$environment_file" | tail -n 1 | tr -d '\r')"
|
||||
app_installed="$(sed -n 's/^APP_INSTALLED=//p' "$environment_file" | tail -n 1 | tr '[:upper:]' '[:lower:]' | tr -d '\r')"
|
||||
db_host="$(read_env_value DB_HOST)"
|
||||
app_installed="$(read_env_value APP_INSTALLED | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [ "$db_host" = "mysql" ]; then
|
||||
set_env_value SYSTEM_DATABASE_MODE bootstrap
|
||||
set_env_value UPSTREAM_STORED_CONFIG_ENABLED false
|
||||
set_env_value UPSTREAM_LOCAL_CONFIG_ENABLED false
|
||||
fi
|
||||
|
||||
if [ "$db_host" = "mysql" ] && [ "$app_installed" != "true" ] && [ "$app_installed" != "1" ]; then
|
||||
echo "==> [3/4] 初始化仅供首次登录使用的本机引导库..."
|
||||
echo "==> [4/5] 初始化仅供首次登录使用的本机引导库..."
|
||||
|
||||
if [ ! -f "$credential_file" ]; then
|
||||
umask 077
|
||||
bootstrap_password="Cw!$(od -An -N16 -tx1 /dev/urandom | tr -d ' \r\n')"
|
||||
cat > "$credential_file" <<EOF
|
||||
BOOTSTRAP_EMAIL=local-bootstrap@caiwu.test
|
||||
BOOTSTRAP_PASSWORD=$bootstrap_password
|
||||
EOF
|
||||
chmod 600 "$credential_file" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 文件由脚本生成,值只包含固定邮箱和受限字符集的随机密码。
|
||||
# shellcheck disable=SC1090
|
||||
source "$credential_file"
|
||||
|
||||
docker compose -f "$compose_file" exec -T app \
|
||||
php artisan install --no-interaction \
|
||||
--db-host=mysql \
|
||||
@ -92,37 +185,53 @@ if [ "$db_host" = "mysql" ] && [ "$app_installed" != "true" ] && [ "$app_install
|
||||
--db-password=akaunting123 \
|
||||
--db-prefix=ak_ \
|
||||
--company-name="本地引导环境" \
|
||||
--company-email="$bootstrap_email" \
|
||||
--admin-email="$bootstrap_email" \
|
||||
--admin-password="$bootstrap_password" \
|
||||
--company-email="$BOOTSTRAP_EMAIL" \
|
||||
--admin-email="$BOOTSTRAP_EMAIL" \
|
||||
--admin-password="$BOOTSTRAP_PASSWORD" \
|
||||
--locale=zh-CN
|
||||
else
|
||||
echo "==> [3/4] 当前环境已经初始化,跳过本机引导库安装。"
|
||||
echo "==> [4/5] 当前环境已经初始化,跳过本机引导库安装。"
|
||||
fi
|
||||
|
||||
echo "==> [5/5] 验证安装状态、数据库和管理员权限..."
|
||||
docker compose -f "$compose_file" exec -T 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
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "错误:应用未通过就绪检查,请运行以下命令查看日志:" >&2
|
||||
echo "docker compose -f $compose_file logs --tail=100 app" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> [4/4] 验证登录页..."
|
||||
http_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||
http://127.0.0.1:18088/auth/login || true)"
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "错误:登录页返回 HTTP ${http_code:-000},请运行以下命令查看日志:" >&2
|
||||
echo "docker compose -f $compose_file logs --tail=100 app" >&2
|
||||
echo "错误:登录页返回 HTTP ${http_code:-000}。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "====================================================="
|
||||
echo "本地测试环境已启动:http://127.0.0.1:18088"
|
||||
echo "本地测试环境已就绪:http://127.0.0.1:18088"
|
||||
|
||||
db_host="$(sed -n 's/^DB_HOST=//p' "$environment_file" | tail -n 1 | tr -d '\r')"
|
||||
db_host="$(read_env_value DB_HOST)"
|
||||
if [ "$db_host" = "mysql" ]; then
|
||||
echo "首次临时账号:$bootstrap_email"
|
||||
echo "首次临时密码:$bootstrap_password"
|
||||
echo "临时登录凭据保存在本机私有文件:$credential_file"
|
||||
echo "请自行打开该文件,不要把内容提交到 Git、聊天或日志。"
|
||||
echo ""
|
||||
echo "下一步:登录后打开“系统设置 → 数据库管理 → 系统数据库”,"
|
||||
echo "填写负责人提供的财务系统配置数据库并点击“测试并保存”。"
|
||||
echo "保存后两个云数据库会自动加载;不要复制 APP_KEY 或填写上游密码。"
|
||||
echo "填写负责人提供的团队共享系统数据库并点击“测试并安全切换”。"
|
||||
else
|
||||
echo "已检测到系统数据库配置,请使用系统数据库中属于自己的账号登录。"
|
||||
echo "已连接团队共享系统数据库,请使用其中属于自己的正式账号登录。"
|
||||
fi
|
||||
echo "====================================================="
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
# - mysql 只用于首次登录和填写“系统数据库”前的临时引导
|
||||
# - 保存系统数据库后,用户、设置和云数据库登记都来自共享系统数据库
|
||||
#
|
||||
# 首次启动:bash deploy/local-init.sh
|
||||
# 首次启动:bash deploy/local-init.sh --build
|
||||
# 停止: docker compose -f docker-compose.local.yml down
|
||||
# 日志: docker compose -f docker-compose.local.yml logs -f app
|
||||
#
|
||||
@ -31,10 +31,11 @@ services:
|
||||
- ./modules:/var/www/html/modules
|
||||
- ./resources:/var/www/html/resources
|
||||
- ./routes:/var/www/html/routes
|
||||
- ./tests:/var/www/html/tests
|
||||
networks:
|
||||
- jiangling_net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/auth/login"]
|
||||
test: ["CMD", "php", "artisan", "app:ready", "--quiet"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
@ -225,6 +225,19 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300
|
||||
|
||||
开发机完成系统数据库配置后,系统自动设置 `UPSTREAM_STORED_CONFIG_ENABLED=true` 和 `UPSTREAM_LOCAL_CONFIG_ENABLED=false`,连接名称、地址、数据库名、账号、允许范围和共享密码均从系统数据库读取。不同开发机可以使用不同 `APP_KEY`,不得复制生产 `APP_KEY`。
|
||||
|
||||
本地首次启动时页面必须明确显示“本地临时引导库”,不能把本机 MySQL 标成财务系统配置数据库。共享系统数据库保存前必须验证当前代码迁移、必要表字段、至少一个具备公司映射和 `read-admin-panel` 权限的管理员、已登记来源及其 `shared_password`。保存后使用新的 Laravel 进程复核;失败恢复私有 `.env` 备份,成功则立即清除临时账号 Session 并要求使用共享库正式账号重新登录。
|
||||
|
||||
系统数据库连接测试从应用容器发起。Windows 宿主机上的数据库使用 `host.docker.internal`,远程数据库使用实际 IP 或域名;`localhost` 和 `127.0.0.1` 代表应用容器自身。TLS 页面必须区分“仅加密”和“已验证服务器证书”,自定义 CA 路径必须是容器内可读取的私有文件,不能进入 Git。
|
||||
|
||||
旧记录只有 `password`、没有 `shared_password` 时,必须在仍持有旧 `APP_KEY` 的受信任环境中先执行只读检查,再经负责人批准执行迁移:
|
||||
|
||||
```bash
|
||||
php artisan system-database:reencrypt-upstream-passwords
|
||||
php artisan system-database:reencrypt-upstream-passwords --apply
|
||||
```
|
||||
|
||||
命令执行前会在持久化 `storage` 中保存不含明文的私有备份;无法使用旧 `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、聊天、日志或截图。
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use App\Models\Settings\DatabaseConnection;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
@ -24,9 +23,14 @@ class UpstreamStoredConfiguration
|
||||
|| (bool) config('upstreams.stored_configuration.enabled', false);
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('upstreams.stored_configuration.enabled', false);
|
||||
}
|
||||
|
||||
public function find(string $source): ?DatabaseConnection
|
||||
{
|
||||
if (! $this->editable()) {
|
||||
if (! $this->enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -93,13 +97,8 @@ class UpstreamStoredConfiguration
|
||||
]);
|
||||
}
|
||||
|
||||
private function password(DatabaseConnection $connection, mixed $localPassword): mixed
|
||||
private function password(DatabaseConnection $connection, mixed $_localPassword): mixed
|
||||
{
|
||||
try {
|
||||
return $connection->resolvedPassword();
|
||||
} catch (DecryptException) {
|
||||
// 旧记录或尚未迁移的环境仍允许使用本机私有密码。
|
||||
return $localPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
</source>
|
||||
<php>
|
||||
<server name="APP_ENV" value="testing"/>
|
||||
<server name="APP_CONFIG_CACHE" value="bootstrap/cache/config-testing.php"/>
|
||||
<server name="APP_INSTALLED" value="true"/>
|
||||
<server name="BCRYPT_ROUNDS" value="4"/>
|
||||
<server name="CACHE_DRIVER" value="array"/>
|
||||
|
||||
@ -14,7 +14,13 @@
|
||||
<section aria-labelledby="system-database-heading">
|
||||
<div class="mb-3">
|
||||
<h2 id="system-database-heading" class="font-semibold text-gray-900">系统数据库</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-600">固定一条,不可删除。系统用户、侧边栏权限、系统设置和云数据库配置都保存在这里。</p>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-600">
|
||||
@if ($systemDatabase['mode'] === 'bootstrap')
|
||||
当前仅用于首次登录。连接团队共享系统数据库后,用户、权限、设置和云数据库配置才会统一。
|
||||
@else
|
||||
当前已使用团队共享系统数据库,系统用户、侧边栏权限、系统设置和云数据库配置都保存在这里。
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-[#D8D2C7] bg-white">
|
||||
@ -33,7 +39,13 @@
|
||||
<td class="px-5 py-4 font-semibold text-gray-900">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span>{{ $systemDatabase['name'] }}</span>
|
||||
<span class="inline-flex rounded-full bg-[#F7F0DE] px-2.5 py-1 text-xs font-semibold text-[#856632]">固定 · 不可删除</span>
|
||||
<span @class([
|
||||
'inline-flex rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
'bg-yellow-100 text-[#935F07]' => $systemDatabase['mode'] === 'bootstrap',
|
||||
'bg-green-100 text-[#53793E]' => $systemDatabase['mode'] === 'shared',
|
||||
])>
|
||||
{{ $systemDatabase['mode'] === 'bootstrap' ? '临时 · 待切换' : '共享 · 已连接' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs font-normal text-gray-500">{{ strtoupper($systemDatabase['driver']) }} 系统数据库</p>
|
||||
</td>
|
||||
@ -42,9 +54,17 @@
|
||||
<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-green-700' => $systemDatabase['tls_required'],
|
||||
'bg-yellow-100 text-yellow-800' => ! $systemDatabase['tls_required'],
|
||||
])>{{ $systemDatabase['tls_required'] ? 'TLS 已启用' : '未启用 TLS' }}</span>
|
||||
'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>
|
||||
@ -108,7 +128,13 @@
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" class="px-5 py-12 text-center text-gray-500">还没有云数据库。点击“添加云数据库”开始配置。</td></tr>
|
||||
<tr>
|
||||
<td colspan="6" class="px-5 py-12 text-center text-gray-500">
|
||||
{{ $systemDatabase['mode'] === 'bootstrap'
|
||||
? '连接团队共享系统数据库后,两套云数据库配置会自动显示在这里。'
|
||||
: '共享系统数据库中还没有云数据库配置。' }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -16,44 +16,60 @@
|
||||
@endif
|
||||
|
||||
<div class="mb-6 rounded-lg bg-yellow-50 px-4 py-3 text-sm leading-6 text-yellow-800">
|
||||
保存前会先验证 TLS、系统必需表和用户数据;任何一项不完整都会拒绝切换。密码留空表示保留当前密码。
|
||||
@if ($values['mode'] === 'bootstrap')
|
||||
当前是本地临时引导库。首次连接团队共享系统数据库时必须填写目标密码。
|
||||
@else
|
||||
当前是团队共享系统数据库。只有主机、端口、库名和账号均未改变时,密码才可以留空。
|
||||
@endif
|
||||
保存前会检查迁移、管理员、权限映射和共享云数据库配置;失败会自动恢复原连接。
|
||||
</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-purple-700 focus:ring-purple-700">
|
||||
<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-purple-700 focus:ring-purple-700">
|
||||
<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-purple-700 focus:ring-purple-700">
|
||||
<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-purple-700 focus:ring-purple-700">
|
||||
<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-purple-700 focus:ring-purple-700">
|
||||
<span class="mt-1 block text-xs text-gray-500">留空保留当前密码,页面不会回显。</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-purple-700 focus:ring-purple-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>
|
||||
|
||||
<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-purple-700 px-5 py-2 text-sm font-semibold text-white hover:bg-purple-800">测试并保存</button>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use RuntimeException;
|
||||
|
||||
trait CreatesApplication
|
||||
{
|
||||
@ -13,10 +14,24 @@ trait CreatesApplication
|
||||
*/
|
||||
public function createApplication()
|
||||
{
|
||||
$testingConfigCache = __DIR__ . '/../bootstrap/cache/config-testing.php';
|
||||
|
||||
putenv('APP_CONFIG_CACHE=' . $testingConfigCache);
|
||||
$_ENV['APP_CONFIG_CACHE'] = $testingConfigCache;
|
||||
$_SERVER['APP_CONFIG_CACHE'] = $testingConfigCache;
|
||||
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
if (
|
||||
! $app->environment('testing')
|
||||
|| config('database.default') !== 'sqlite'
|
||||
|| config('database.connections.sqlite.database') !== ':memory:'
|
||||
) {
|
||||
throw new RuntimeException('测试环境未使用内存 SQLite,已拒绝执行以保护共享数据库。');
|
||||
}
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
|
||||
20
tests/Feature/Installation/ApplicationReadyTest.php
Normal file
20
tests/Feature/Installation/ApplicationReadyTest.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Installation;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApplicationReadyTest extends TestCase
|
||||
{
|
||||
public function test_ready_command_requires_installed_application_with_authorized_administrator(): void
|
||||
{
|
||||
config(['app.installed' => true]);
|
||||
$this->artisan('app:ready')->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_ready_command_rejects_uninstalled_application(): void
|
||||
{
|
||||
config(['app.installed' => false]);
|
||||
$this->artisan('app:ready')->assertFailed();
|
||||
}
|
||||
}
|
||||
80
tests/Feature/Installation/BootstrapInstallationTest.php
Normal file
80
tests/Feature/Installation/BootstrapInstallationTest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Installation;
|
||||
|
||||
use App\Utilities\Installer;
|
||||
use Database\Seeds\Permissions;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\CreatesApplication;
|
||||
|
||||
class BootstrapInstallationTest extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_fresh_bootstrap_administrator_can_open_wizard_dashboard_and_database_settings(): void
|
||||
{
|
||||
$this->artisan('db:seed', [
|
||||
'--class' => Permissions::class,
|
||||
'--force' => true,
|
||||
])->assertSuccessful();
|
||||
config([
|
||||
'mail.from.address' => 'test@example.com',
|
||||
'mail.from.name' => 'CaiWuXiTong Test',
|
||||
]);
|
||||
Notification::fake();
|
||||
|
||||
$company = Installer::createCompany(
|
||||
'本地引导环境',
|
||||
'local-bootstrap@caiwu.test',
|
||||
'zh-CN'
|
||||
);
|
||||
Installer::createUser(
|
||||
'local-bootstrap@caiwu.test',
|
||||
'temporary-test-secret',
|
||||
'zh-CN',
|
||||
$company->id
|
||||
);
|
||||
|
||||
$user = user_model_class()::where('email', 'local-bootstrap@caiwu.test')->firstOrFail();
|
||||
$role = $user->roles()->where('name', 'super_admin')->firstOrFail();
|
||||
|
||||
$this->assertTrue($role->hasPermission('read-admin-panel'));
|
||||
$this->assertTrue($role->hasPermission('create-common-companies'));
|
||||
$this->assertTrue($role->hasPermission('read-common-companies'));
|
||||
$this->assertTrue($role->hasPermission('read-common-dashboards'));
|
||||
$this->assertFalse($role->hasPermission('read-client-portal'));
|
||||
$this->assertTrue($user->companies()->whereKey($company->id)->exists());
|
||||
$this->assertTrue($user->can('read-admin-panel'));
|
||||
$this->assertTrue($user->can('read-common-companies'));
|
||||
$this->assertTrue($user->hasPermission('read-admin-panel'));
|
||||
$this->assertTrue($user->hasPermission('read-common-companies'));
|
||||
|
||||
$company->makeCurrent();
|
||||
app('url')->defaults(['company_id' => $company->id]);
|
||||
config([
|
||||
'debugbar.enabled' => false,
|
||||
'system_database.mode' => 'bootstrap',
|
||||
'upstreams.stored_configuration.enabled' => false,
|
||||
]);
|
||||
foreach (array_keys(config('firewall.middleware', [])) as $middleware) {
|
||||
config(["firewall.middleware.{$middleware}.enabled" => false]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
route('wizard.edit'),
|
||||
route('dashboard'),
|
||||
route('system-settings.databases.index'),
|
||||
] as $url) {
|
||||
$response = $this->actingAs($user)->get($url);
|
||||
|
||||
$this->assertNotSame(
|
||||
403,
|
||||
$response->getStatusCode(),
|
||||
$url . ' 不应返回 403:' . mb_substr(strip_tags($response->getContent()), 0, 500)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,9 +5,11 @@ namespace Tests\Feature\SystemSettings;
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Auth\Role;
|
||||
use App\Models\Settings\DatabaseConnection;
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use App\Utilities\UpstreamCredentialCipher;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamSourceRegistry;
|
||||
use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
@ -23,7 +25,11 @@ class UpstreamDatabasesTest extends FeatureTestCase
|
||||
);
|
||||
|
||||
$connection = (string) config('database.default');
|
||||
config(["database.connections.$connection.password" => 'shared-system-database-secret']);
|
||||
config([
|
||||
"database.connections.$connection.password" => 'shared-system-database-secret',
|
||||
'system_database.mode' => 'shared',
|
||||
'upstreams.stored_configuration.enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_super_admin_can_add_multiple_encrypted_database_configurations(): void
|
||||
@ -212,12 +218,12 @@ class UpstreamDatabasesTest extends FeatureTestCase
|
||||
->get(route('system-settings.databases.index'))
|
||||
->assertOk()
|
||||
->assertSeeText('财务系统配置数据库')
|
||||
->assertSeeText('固定 · 不可删除')
|
||||
->assertSeeText('共享 · 已连接')
|
||||
->assertSeeText('云数据库');
|
||||
|
||||
$this->get(route('system-settings.databases.system.edit'))
|
||||
->assertOk()
|
||||
->assertSeeText('测试并保存');
|
||||
->assertSeeText('测试并安全切换');
|
||||
}
|
||||
|
||||
public function test_database_page_separates_system_and_cloud_database_configuration(): void
|
||||
@ -232,14 +238,14 @@ class UpstreamDatabasesTest extends FeatureTestCase
|
||||
->assertSeeTextInOrder([
|
||||
'系统数据库',
|
||||
'财务系统配置数据库',
|
||||
'固定 · 不可删除',
|
||||
'共享 · 已连接',
|
||||
'云数据库',
|
||||
'添加云数据库',
|
||||
]);
|
||||
|
||||
$this->get(route('system-settings.databases.system.edit'))
|
||||
->assertOk()
|
||||
->assertSeeText('测试并保存')
|
||||
->assertSeeText('测试并安全切换')
|
||||
->assertDontSee('system-database-test-secret');
|
||||
}
|
||||
|
||||
@ -248,7 +254,7 @@ class UpstreamDatabasesTest extends FeatureTestCase
|
||||
$this->loginAs()
|
||||
->get(route('system-settings.databases.create'))
|
||||
->assertOk()
|
||||
->assertSeeText('新增数据库')
|
||||
->assertSeeText('添加数据库')
|
||||
->assertDontSeeText('Not able to create a new user.');
|
||||
}
|
||||
|
||||
@ -276,6 +282,83 @@ class UpstreamDatabasesTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_bootstrap_page_is_not_presented_as_shared_database(): void
|
||||
{
|
||||
config([
|
||||
'system_database.mode' => 'bootstrap',
|
||||
'upstreams.stored_configuration.enabled' => false,
|
||||
]);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('system-settings.databases.index'))
|
||||
->assertOk()
|
||||
->assertSeeText('本地临时引导库')
|
||||
->assertSeeText('临时 · 待切换')
|
||||
->assertDontSeeText('添加云数据库');
|
||||
}
|
||||
|
||||
public function test_first_switch_requires_password_when_connection_identity_changes(): 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_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,
|
||||
'admin_count' => 2,
|
||||
'source_count' => 2,
|
||||
'prefix' => 'ak_',
|
||||
]);
|
||||
$configuration->shouldReceive('persist')->once();
|
||||
$this->app->instance(SystemDatabaseConfiguration::class, $configuration);
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
private function payload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
|
||||
@ -15,7 +15,10 @@ class SystemDatabaseConfigurationTest extends TestCase
|
||||
$prefix = $configuration->detectTablePrefix([
|
||||
'ak_migrations',
|
||||
'ak_users',
|
||||
'ak_companies',
|
||||
'ak_roles',
|
||||
'ak_permissions',
|
||||
'ak_role_permissions',
|
||||
'ak_user_roles',
|
||||
'ak_user_companies',
|
||||
'ak_settings',
|
||||
@ -28,7 +31,7 @@ class SystemDatabaseConfigurationTest extends TestCase
|
||||
public function test_it_prefers_the_current_prefix_when_target_contains_multiple_complete_system_sets(): void
|
||||
{
|
||||
$configuration = new SystemDatabaseConfiguration('unused');
|
||||
$bases = ['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings'];
|
||||
$bases = $this->requiredTables();
|
||||
$tables = [];
|
||||
|
||||
foreach (['a_', 'ak_'] as $prefix) {
|
||||
@ -58,7 +61,7 @@ class SystemDatabaseConfigurationTest extends TestCase
|
||||
public function test_it_rejects_an_ambiguous_target_when_current_prefix_is_not_present(): void
|
||||
{
|
||||
$configuration = new SystemDatabaseConfiguration('unused');
|
||||
$bases = ['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings'];
|
||||
$bases = $this->requiredTables();
|
||||
$tables = [];
|
||||
|
||||
foreach (['first_', 'second_'] as $prefix) {
|
||||
@ -72,4 +75,20 @@ class SystemDatabaseConfigurationTest extends TestCase
|
||||
|
||||
$configuration->detectTablePrefix($tables, 'a_');
|
||||
}
|
||||
|
||||
private function requiredTables(): array
|
||||
{
|
||||
return [
|
||||
'migrations',
|
||||
'users',
|
||||
'companies',
|
||||
'roles',
|
||||
'permissions',
|
||||
'role_permissions',
|
||||
'user_roles',
|
||||
'user_companies',
|
||||
'settings',
|
||||
'system_database_connections',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
52
tests/Unit/Utilities/SystemDatabasePersistenceTest.php
Normal file
52
tests/Unit/Utilities/SystemDatabasePersistenceTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Utilities;
|
||||
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SystemDatabasePersistenceTest extends TestCase
|
||||
{
|
||||
public function test_failed_fresh_process_verification_restores_original_environment(): void
|
||||
{
|
||||
$environmentPath = storage_path('framework/testing-system-database.env');
|
||||
$original = "DB_HOST=mysql\nDB_DATABASE=akaunting\nSYSTEM_DATABASE_MODE=bootstrap\n";
|
||||
file_put_contents($environmentPath, $original);
|
||||
|
||||
$configuration = new class($environmentPath) extends SystemDatabaseConfiguration {
|
||||
public array $commands = [];
|
||||
|
||||
protected function runFreshVerification(string $command): void
|
||||
{
|
||||
$this->commands[] = $command;
|
||||
|
||||
if ($command === 'system-database:verify') {
|
||||
throw new RuntimeException('candidate failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$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->fail('验证失败时应抛出异常。');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertSame('新配置未通过应用级验证,已自动恢复原系统数据库。', $exception->getMessage());
|
||||
$this->assertSame($original, file_get_contents($environmentPath));
|
||||
$this->assertSame(['system-database:verify', 'app:ready'], $configuration->commands);
|
||||
} finally {
|
||||
@unlink($environmentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -53,4 +53,21 @@ class UpstreamCredentialCipherTest extends TestCase
|
||||
$this->assertStringStartsWith('caiwu-system-db-v1:', $encrypted);
|
||||
$this->assertStringNotContainsString('cloud-readonly-secret', $encrypted);
|
||||
}
|
||||
|
||||
public function test_candidate_system_database_password_can_be_verified_before_switch(): void
|
||||
{
|
||||
$cipher = app(UpstreamCredentialCipher::class);
|
||||
$encrypted = $cipher->encryptWithSecret(
|
||||
'cloud-readonly-secret',
|
||||
'candidate-system-database-secret'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
'cloud-readonly-secret',
|
||||
$cipher->decryptWithSecret($encrypted, 'candidate-system-database-secret')
|
||||
);
|
||||
|
||||
$this->expectException(DecryptException::class);
|
||||
$cipher->decryptWithSecret($encrypted, 'wrong-candidate-secret');
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user