修复:完善本地初始化与共享系统数据库切换

This commit is contained in:
WuYongJie 2026-07-24 12:18:01 +08:00
parent 7660cec0f9
commit 9a65836e3f
31 changed files with 1260 additions and 136 deletions

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -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'] ?? []);
$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' => (string) (
data_get($connection, 'write.host.0')
?? data_get($connection, 'read.host.0')
?? ($connection['host'] ?? '')
),
'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',
]);
if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) {
file_put_contents($this->environmentPath, $current, LOCK_EX);
throw new RuntimeException('系统数据库配置保存失败,已恢复原配置。');
}
try {
if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) {
throw new RuntimeException('系统数据库配置写入失败。');
}
Artisan::call('config:clear');
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) {

View File

@ -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 === '') {
$this->assertSecret($password);
return $password;
}
private function assertSecret(string $secret): void
{
if ($secret === '') {
throw new RuntimeException('系统数据库密码不可用,无法处理共享云数据库密码。');
}
return hash_hkdf('sha256', $password, 32, self::AAD, $salt);
}
}

View File

@ -0,0 +1,14 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 系统数据库运行模式
|--------------------------------------------------------------------------
|
| bootstrap 仅用于全新本地环境首次登录;shared 表示已经切换到团队共享
| 系统数据库。旧环境没有该值时,页面会根据本地 Compose 主机名安全推断。
|
*/
'mode' => env('SYSTEM_DATABASE_MODE', ''),
];

View File

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

View File

@ -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
{
// 权限降级可能锁死现有管理员,回滚代码时保留已同步的权限。
}
};

View File

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

View File

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

View File

@ -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 能编译)

View File

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

View File

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

View File

@ -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、聊天、日志或截图。

View File

@ -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;
}
return $connection->resolvedPassword();
}
}

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View 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)
);
}
}
}

View File

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

View File

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

View 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);
}
}
}

View File

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