822 lines
30 KiB
PHP
822 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\OsurgInitial;
|
|
use App\Models\SyncLog;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class SyncService
|
|
{
|
|
public function __construct(
|
|
private readonly string $peerIp,
|
|
private readonly int $peerPort,
|
|
private readonly string $token,
|
|
private readonly string $ftpUser,
|
|
private readonly string $ftpPass,
|
|
private readonly int $ftpPort,
|
|
private readonly string $ftpRemotePath,
|
|
) {}
|
|
|
|
public static function fromConfig(): static
|
|
{
|
|
return new static(
|
|
peerIp: OsurgInitial::val('sync_peer_ip', config('sync.peer_ip', '')),
|
|
peerPort: (int) OsurgInitial::val('sync_peer_port', config('sync.peer_port', 80)),
|
|
token: OsurgInitial::val('sync_token', config('sync.token', '')),
|
|
ftpUser: OsurgInitial::val('ftp_user', config('sync.ftp_user', '')),
|
|
ftpPass: OsurgInitial::val('ftp_pass', config('sync.ftp_pass', '')),
|
|
ftpPort: (int) OsurgInitial::val('ftp_port', config('sync.ftp_port', 2121)),
|
|
ftpRemotePath: OsurgInitial::val('ftp_remote_path', config('sync.ftp_remote_path', '')),
|
|
);
|
|
}
|
|
|
|
public function ping(): array
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
return ['connected' => false, 'path' => null];
|
|
}
|
|
|
|
$ip = escapeshellarg($this->peerIp);
|
|
$cmd = PHP_OS_FAMILY === 'Windows'
|
|
? "ping -n 1 -w 1000 {$ip}"
|
|
: "ping -c 1 -W 1 {$ip}";
|
|
|
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
return ['connected' => false, 'path' => null];
|
|
}
|
|
|
|
$path = null;
|
|
try {
|
|
$response = Http::timeout(3)
|
|
->withHeader('X-Sync-Token', $this->token)
|
|
->get("http://{$this->peerIp}:{$this->peerPort}/api/sync/ping");
|
|
|
|
if ($response->successful() && $response->json('status') === 'ok') {
|
|
$path = $response->json('path');
|
|
}
|
|
} catch (\Throwable) {
|
|
}
|
|
|
|
return ['connected' => true, 'path' => $path];
|
|
}
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
return $this->peerIp !== '' && $this->token !== '';
|
|
}
|
|
|
|
public function isFtpConfigured(): bool
|
|
{
|
|
return $this->peerIp !== '' && $this->ftpUser !== '' && $this->ftpRemotePath !== '';
|
|
}
|
|
|
|
public function sync(): array
|
|
{
|
|
set_time_limit(0);
|
|
$report = [];
|
|
$baseUrl = "http://{$this->peerIp}:{$this->peerPort}";
|
|
|
|
$pruned = $this->pruneOrphanedPending();
|
|
if ($pruned > 0) {
|
|
$report[] = "{$pruned} مسیر فایل یتیم از صف retry حذف شد.";
|
|
}
|
|
|
|
$healed = $this->healMissingFiles();
|
|
if ($healed > 0) {
|
|
$report[] = "{$healed} فایل گمشده شناسایی شد و در صف دریافت قرار گرفت.";
|
|
}
|
|
|
|
$peerCursor = \App\Models\OsurgInitial::val('sync_peer_cursor') ?: null;
|
|
$ourLastSync = \App\Models\OsurgInitial::val('our_sync_cursor') ?: null;
|
|
|
|
try {
|
|
$pullResponse = Http::timeout(30)
|
|
->withHeader('X-Sync-Token', $this->token)
|
|
->get("{$baseUrl}/api/sync/export", ['since' => $peerCursor]);
|
|
|
|
if ($pullResponse->successful()) {
|
|
$peerChanges = $pullResponse->json('changes', []);
|
|
|
|
if (! empty($peerChanges)) {
|
|
app()->instance('sync.applying', true);
|
|
|
|
$pullReport = [];
|
|
DB::transaction(function () use ($peerChanges, &$pullReport) {
|
|
foreach ($peerChanges as $change) {
|
|
$this->applyChange($change, 'دریافت', $pullReport);
|
|
}
|
|
});
|
|
foreach ($pullReport as $line) {
|
|
$report[] = $line;
|
|
}
|
|
|
|
$pullHasErrors = ! empty(array_filter($pullReport, fn ($l) => str_contains((string) $l, '✗')));
|
|
|
|
if (! $pullHasErrors) {
|
|
$datetimes = array_filter(array_column($peerChanges, 'datetime'));
|
|
$maxPeerDatetime = \Carbon\Carbon::parse(max($datetimes))->format('Y-m-d H:i:s');
|
|
if ($peerCursor === null || $maxPeerDatetime > $peerCursor) {
|
|
$this->savePeerCursor($maxPeerDatetime);
|
|
}
|
|
} else {
|
|
$report[] = 'هشدار: peer_cursor پیش نرفت — برخی تغییرات دریافتی اعمال نشد و دفعه بعد retry میشه.';
|
|
}
|
|
}
|
|
|
|
if ($this->isFtpConfigured()) {
|
|
$this->transferFilesViaFtp($peerChanges ?? [], 's2c', $report);
|
|
} else {
|
|
$this->syncFilesViaHttp($peerChanges ?? [], $baseUrl, $report);
|
|
}
|
|
|
|
$report[] = count($peerChanges ?? []) . ' تغییر از سیستم مقابل دریافت شد.';
|
|
} else {
|
|
$report[] = 'خطا در دریافت تغییرات از سیستم مقابل: ' . $pullResponse->status();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$report[] = 'خطا در اتصال به سیستم مقابل: ' . $e->getMessage();
|
|
return ['success' => false, 'report' => $report];
|
|
}
|
|
|
|
$ourChanges = SyncLog::changesSince($ourLastSync)->toArray();
|
|
|
|
try {
|
|
$pushResponse = Http::timeout(120)
|
|
->withHeader('X-Sync-Token', $this->token)
|
|
->post("{$baseUrl}/api/sync/apply", ['changes' => $ourChanges]);
|
|
|
|
if ($pushResponse->successful()) {
|
|
$pushReport = $pushResponse->json('report', []);
|
|
foreach ($pushReport as $line) {
|
|
$report[] = 'ارسال: ' . $line;
|
|
}
|
|
$report[] = count($ourChanges) . ' تغییر به سیستم مقابل ارسال شد.';
|
|
|
|
$hasErrors = ! empty(array_filter($pushReport, fn ($l) => str_contains((string) $l, '✗')));
|
|
|
|
if (! empty($ourChanges) && ! $hasErrors) {
|
|
$ourDatetimes = array_filter(array_column($ourChanges, 'datetime'));
|
|
$maxOurDatetime = \Carbon\Carbon::parse(max($ourDatetimes))->format('Y-m-d H:i:s');
|
|
if ($ourLastSync === null || $maxOurDatetime > $ourLastSync) {
|
|
$this->preQueueOutgoingFiles($ourChanges);
|
|
$this->saveOurCursor($maxOurDatetime);
|
|
}
|
|
} elseif ($hasErrors) {
|
|
$report[] = 'هشدار: cursor پیش نرفت — برخی تغییرات روی سیستم مقابل اعمال نشد و دفعه بعد retry میشه.';
|
|
}
|
|
|
|
if ($this->isFtpConfigured()) {
|
|
$this->transferFilesViaFtp($ourChanges, 'c2s', $report);
|
|
} else {
|
|
$this->pushFilesViaHttp($ourChanges, $baseUrl, $report);
|
|
}
|
|
} else {
|
|
$report[] = 'خطا در ارسال تغییرات به سیستم مقابل: ' . $pushResponse->status();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$report[] = 'خطا در ارسال به سیستم مقابل: ' . $e->getMessage();
|
|
}
|
|
|
|
SyncLog::markSynced(request()->ip() ?? '127.0.0.1');
|
|
|
|
return ['success' => true, 'report' => $report];
|
|
}
|
|
|
|
public function applyChanges(array $changes): array
|
|
{
|
|
$report = [];
|
|
app()->instance('sync.applying', true);
|
|
|
|
DB::transaction(function () use ($changes, &$report) {
|
|
foreach ($changes as $change) {
|
|
$this->applyChange($change, '', $report);
|
|
}
|
|
});
|
|
|
|
SyncLog::markSynced(request()->ip() ?? '');
|
|
|
|
return $report;
|
|
}
|
|
|
|
protected function transferFilesViaFtp(array $changes, string $side, array &$report): void
|
|
{
|
|
$currentPaths = $this->extractFilePaths($changes);
|
|
$pendingPaths = $this->getPendingFiles($side);
|
|
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
|
|
|
|
if (empty($allPaths)) {
|
|
return;
|
|
}
|
|
|
|
$ftp = $this->connectFtp();
|
|
if (! $ftp) {
|
|
$report[] = 'خطا در اتصال FTP به سیستم مقابل';
|
|
Log::error('FTP connection failed in transferFilesViaFtp');
|
|
return;
|
|
}
|
|
|
|
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
|
|
$remoteBase = rtrim($this->ftpRemotePath, '/\\');
|
|
$failedPaths = [];
|
|
|
|
foreach ($allPaths as $filePath) {
|
|
$localFile = $localBase . '/' . $filePath;
|
|
$remoteFile = $remoteBase . '/' . $filePath;
|
|
$remoteFolder = dirname($remoteFile);
|
|
|
|
if ($side === 'c2s') {
|
|
if (! file_exists($localFile)) {
|
|
$report[] = "فایل [{$filePath}] در سیستم محلی یافت نشد ✗";
|
|
Log::warning('FTP upload skipped - local file not found', [
|
|
'file' => $filePath,
|
|
'local_path' => $localFile,
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
if (! $this->createFtpDirectory($ftp, $remoteFolder)) {
|
|
$report[] = "ایجاد پوشه [{$remoteFolder}] برای فایل [{$filePath}] شکست خورد ✗";
|
|
Log::error('FTP mkdir failed', ['file' => $filePath, 'remote_folder' => $remoteFolder]);
|
|
$failedPaths[] = $filePath;
|
|
continue;
|
|
}
|
|
|
|
if ($this->ftpPut($ftp, $remoteFile, $localFile)) {
|
|
$report[] = "فایل [{$filePath}] ارسال شد (FTP) ✓";
|
|
Log::info('FTP file uploaded', ['file' => $filePath, 'size' => filesize($localFile)]);
|
|
} else {
|
|
$errorDetail = error_get_last();
|
|
$errorMsg = "فایل [{$filePath}] ارسال نشد (FTP)";
|
|
if ($errorDetail) {
|
|
$errorMsg .= ' - ' . $errorDetail['message'];
|
|
}
|
|
$report[] = $errorMsg . ' ✗';
|
|
$failedPaths[] = $filePath;
|
|
Log::error('FTP upload failed', [
|
|
'file' => $filePath, 'local_file' => $localFile,
|
|
'remote_file' => $remoteFile, 'error' => $errorDetail,
|
|
]);
|
|
}
|
|
} else {
|
|
if (file_exists($localFile)) {
|
|
Log::debug('FTP download skipped - file already exists', ['file' => $filePath]);
|
|
continue;
|
|
}
|
|
|
|
$localFolder = dirname($localFile);
|
|
if (! is_dir($localFolder)) {
|
|
if (! mkdir($localFolder, 0755, true)) {
|
|
$report[] = "ایجاد پوشه [{$localFolder}] شکست خورد ✗";
|
|
$failedPaths[] = $filePath;
|
|
Log::error('Local mkdir failed', ['folder' => $localFolder, 'error' => error_get_last()]);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if ($this->ftpGet($ftp, $localFile, $remoteFile)) {
|
|
$report[] = "فایل [{$filePath}] دریافت شد (FTP) ✓";
|
|
Log::info('FTP file downloaded', ['file' => $filePath, 'size' => file_exists($localFile) ? filesize($localFile) : 0]);
|
|
} else {
|
|
$errorDetail = error_get_last();
|
|
$errorMsg = "فایل [{$filePath}] دریافت نشد (FTP)";
|
|
if ($errorDetail) {
|
|
$errorMsg .= ' - ' . $errorDetail['message'];
|
|
}
|
|
$report[] = $errorMsg . ' ✗';
|
|
$failedPaths[] = $filePath;
|
|
Log::error('FTP download failed', [
|
|
'file' => $filePath, 'local_file' => $localFile,
|
|
'remote_file' => $remoteFile, 'error' => $errorDetail,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->savePendingFiles($side, $failedPaths);
|
|
if (! empty($failedPaths)) {
|
|
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
|
|
}
|
|
|
|
$this->ftpClose($ftp);
|
|
Log::info('FTP connection closed');
|
|
}
|
|
|
|
protected function ftpGet(mixed $ftp, string $localFile, string $remoteFile): bool
|
|
{
|
|
$tmpFile = $localFile . '.part';
|
|
|
|
if (file_exists($tmpFile)) {
|
|
@unlink($tmpFile);
|
|
}
|
|
|
|
$ok = @ftp_get($ftp, $tmpFile, $remoteFile, FTP_BINARY);
|
|
|
|
if ($ok && file_exists($tmpFile)) {
|
|
return rename($tmpFile, $localFile);
|
|
}
|
|
|
|
if (file_exists($tmpFile)) {
|
|
@unlink($tmpFile);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function ftpPut(mixed $ftp, string $remoteFile, string $localFile): bool
|
|
{
|
|
return ftp_put($ftp, $remoteFile, $localFile, FTP_BINARY);
|
|
}
|
|
|
|
protected function ftpClose(mixed $ftp): void
|
|
{
|
|
ftp_close($ftp);
|
|
}
|
|
|
|
protected function connectFtp(): mixed
|
|
{
|
|
if (! function_exists('ftp_connect')) {
|
|
Log::error('FTP extension is not available');
|
|
return null;
|
|
}
|
|
|
|
$ftp = ftp_connect($this->peerIp, $this->ftpPort, 30);
|
|
if (! $ftp) {
|
|
Log::error('FTP connection failed', [
|
|
'host' => $this->peerIp,
|
|
'port' => $this->ftpPort,
|
|
'error' => error_get_last(),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
if (! ftp_login($ftp, $this->ftpUser, $this->ftpPass)) {
|
|
Log::error('FTP login failed', [
|
|
'host' => $this->peerIp,
|
|
'port' => $this->ftpPort,
|
|
'user' => $this->ftpUser,
|
|
'error' => error_get_last(),
|
|
]);
|
|
ftp_close($ftp);
|
|
return null;
|
|
}
|
|
|
|
ftp_pasv($ftp, true);
|
|
Log::info('FTP connection established', [
|
|
'host' => $this->peerIp,
|
|
'port' => $this->ftpPort,
|
|
]);
|
|
|
|
return $ftp;
|
|
}
|
|
|
|
protected function createFtpDirectory($ftp, string $path): bool
|
|
{
|
|
$parts = explode('/', trim($path, '/'));
|
|
$currentPath = '';
|
|
|
|
foreach ($parts as $part) {
|
|
if (empty($part)) {
|
|
continue;
|
|
}
|
|
|
|
$currentPath .= '/' . $part;
|
|
|
|
$originalDir = ftp_pwd($ftp);
|
|
if (@ftp_chdir($ftp, $currentPath)) {
|
|
@ftp_chdir($ftp, $originalDir);
|
|
continue;
|
|
}
|
|
|
|
if (! @ftp_mkdir($ftp, $currentPath)) {
|
|
Log::error('FTP mkdir failed', [
|
|
'path' => $currentPath,
|
|
'full_path' => $path,
|
|
'error' => error_get_last(),
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
Log::debug('FTP directory created', ['path' => $currentPath]);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function extractFilePaths(array $changes): array
|
|
{
|
|
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
|
$paths = [];
|
|
foreach ($changes as $change) {
|
|
$data = $change['changed_data'] ?? [];
|
|
foreach ($fileFields as $field) {
|
|
if (empty($data[$field])) {
|
|
continue;
|
|
}
|
|
$files = is_array($data[$field]) ? $data[$field] : json_decode($data[$field], true) ?? [];
|
|
foreach ($files as $filePath) {
|
|
if ($filePath) {
|
|
$paths[] = ltrim(str_replace('\\', '/', $filePath), '/');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $paths;
|
|
}
|
|
|
|
private function pushFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
|
{
|
|
$currentPaths = $this->extractFilePaths($changes);
|
|
$pendingPaths = $this->getPendingFiles('c2s');
|
|
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
|
|
$failedPaths = [];
|
|
|
|
foreach ($allPaths as $filePath) {
|
|
$localPath = Storage::disk('public')->path($filePath);
|
|
|
|
if (! file_exists($localPath)) {
|
|
$report[] = "فایل [{$filePath}] در سیستم محلی یافت نشد ✗";
|
|
continue;
|
|
}
|
|
|
|
$stream = fopen($localPath, 'rb');
|
|
try {
|
|
$response = Http::timeout(600)
|
|
->withHeader('X-Sync-Token', $this->token)
|
|
->attach('file', $stream, basename($filePath))
|
|
->post("{$baseUrl}/api/sync/receive-file", ['path' => $filePath]);
|
|
|
|
if ($response->successful()) {
|
|
$report[] = "فایل [{$filePath}] ارسال شد (HTTP) ✓";
|
|
} else {
|
|
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $response->status();
|
|
$failedPaths[] = $filePath;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$report[] = "فایل [{$filePath}] ارسال نشد (HTTP) ✗ " . $e->getMessage();
|
|
$failedPaths[] = $filePath;
|
|
} finally {
|
|
if (is_resource($stream)) {
|
|
fclose($stream);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->savePendingFiles('c2s', $failedPaths);
|
|
if (! empty($failedPaths)) {
|
|
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
|
|
}
|
|
}
|
|
|
|
private function syncFilesViaHttp(array $changes, string $baseUrl, array &$report): void
|
|
{
|
|
$localBase = rtrim(Storage::disk('public')->path(''), '/\\');
|
|
$currentPaths = $this->extractFilePaths($changes);
|
|
$pendingPaths = $this->getPendingFiles('s2c');
|
|
$allPaths = array_values(array_unique(array_merge($pendingPaths, $currentPaths)));
|
|
$failedPaths = [];
|
|
|
|
foreach ($allPaths as $filePath) {
|
|
if (Storage::disk('public')->exists($filePath)) {
|
|
continue;
|
|
}
|
|
|
|
$localFile = $localBase . '/' . $filePath;
|
|
$partFile = $localFile . '.part';
|
|
$localDir = dirname($localFile);
|
|
|
|
try {
|
|
if (! is_dir($localDir)) {
|
|
mkdir($localDir, 0755, true);
|
|
}
|
|
|
|
if (file_exists($partFile)) {
|
|
@unlink($partFile);
|
|
}
|
|
|
|
$response = Http::timeout(60)
|
|
->withHeader('X-Sync-Token', $this->token)
|
|
->get("{$baseUrl}/api/sync/file", ['path' => $filePath]);
|
|
|
|
if ($response->successful()) {
|
|
file_put_contents($partFile, $response->body());
|
|
rename($partFile, $localFile);
|
|
$report[] = "فایل [{$filePath}] دریافت شد (HTTP) ✓";
|
|
} else {
|
|
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $response->status();
|
|
$failedPaths[] = $filePath;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
if (file_exists($partFile)) {
|
|
@unlink($partFile);
|
|
}
|
|
$report[] = "فایل [{$filePath}] دریافت نشد (HTTP) ✗ " . $e->getMessage();
|
|
$failedPaths[] = $filePath;
|
|
}
|
|
}
|
|
|
|
$this->savePendingFiles('s2c', $failedPaths);
|
|
if (! empty($failedPaths)) {
|
|
$report[] = count($failedPaths) . ' فایل در صف retry دفعه بعد قرار گرفت.';
|
|
}
|
|
}
|
|
|
|
private function applyChange(array $change, string $prefix, array &$report): void
|
|
{
|
|
$table = $change['table_name'];
|
|
$recordId = $change['record_id'];
|
|
$action = $change['action'];
|
|
$data = $change['changed_data'] ?? [];
|
|
|
|
$label = $prefix ? "{$prefix} [{$table}#{$recordId}]" : "[{$table}#{$recordId}]";
|
|
|
|
try {
|
|
if ($action === 'deleted') {
|
|
if ($table === 'patients') {
|
|
$this->deletePatientFilesFromDisk((int) $recordId);
|
|
$this->removePatientFilesFromPending((int) $recordId);
|
|
}
|
|
DB::table($table)->where('id', $recordId)->delete();
|
|
$report[] = "{$label} حذف ✓";
|
|
} elseif ($action === 'created') {
|
|
$exists = DB::table($table)->where('id', $recordId)->exists();
|
|
if ($exists) {
|
|
DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
|
|
$report[] = "{$label} ایجاد→بروزرسانی ✓";
|
|
} else {
|
|
DB::table($table)->insert($this->sanitize(array_merge(['id' => $recordId], $data)));
|
|
$report[] = "{$label} ایجاد ✓";
|
|
}
|
|
} elseif ($action === 'updated') {
|
|
if ($table === 'patients') {
|
|
$this->deleteRemovedPatientFiles((int) $recordId, $data);
|
|
}
|
|
DB::table($table)->where('id', $recordId)->update($this->sanitize($data));
|
|
$report[] = "{$label} بروزرسانی ✓";
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$report[] = "{$label} {$action} ✗ " . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
private function deletePatientFilesFromDisk(int $patientId): void
|
|
{
|
|
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
|
$row = DB::table('patients')->find($patientId);
|
|
if (! $row) {
|
|
return;
|
|
}
|
|
|
|
foreach ($fileFields as $field) {
|
|
if (empty($row->$field)) {
|
|
continue;
|
|
}
|
|
$files = json_decode($row->$field, true) ?? [];
|
|
foreach ($files as $path) {
|
|
if (! empty($path)) {
|
|
Storage::disk('public')->delete(ltrim(str_replace('\\', '/', $path), '/'));
|
|
Log::info('sync: deleted patient file from disk', ['file' => $path, 'patient_id' => $patientId]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function removePatientFilesFromPending(int $patientId): void
|
|
{
|
|
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
|
$row = DB::table('patients')->find($patientId);
|
|
if (! $row) {
|
|
return;
|
|
}
|
|
|
|
$patientPaths = [];
|
|
foreach ($fileFields as $field) {
|
|
if (empty($row->$field)) {
|
|
continue;
|
|
}
|
|
$files = json_decode($row->$field, true) ?? [];
|
|
foreach ($files as $path) {
|
|
if (! empty($path)) {
|
|
$patientPaths[] = ltrim(str_replace('\\', '/', $path), '/');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($patientPaths)) {
|
|
return;
|
|
}
|
|
|
|
foreach (['s2c', 'c2s'] as $direction) {
|
|
$pending = $this->getPendingFiles($direction);
|
|
$cleaned = array_values(array_diff($pending, $patientPaths));
|
|
if (count($cleaned) !== count($pending)) {
|
|
$this->savePendingFiles($direction, $cleaned);
|
|
Log::info('sync: removed deleted patient files from pending', [
|
|
'patient_id' => $patientId,
|
|
'direction' => $direction,
|
|
'removed' => count($pending) - count($cleaned),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
private function deleteRemovedPatientFiles(int $patientId, array $newData): void
|
|
{
|
|
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
|
|
|
foreach ($fileFields as $field) {
|
|
if (! array_key_exists($field, $newData)) {
|
|
continue;
|
|
}
|
|
|
|
$row = DB::table('patients')->find($patientId);
|
|
if (! $row || empty($row->$field)) {
|
|
continue;
|
|
}
|
|
|
|
$oldFiles = json_decode($row->$field, true) ?? [];
|
|
$newRaw = $newData[$field];
|
|
$newFiles = is_array($newRaw) ? $newRaw : (json_decode($newRaw, true) ?? []);
|
|
$newFiles = array_map(fn($p) => ltrim(str_replace('\\', '/', $p), '/'), $newFiles);
|
|
|
|
$removed = array_diff(
|
|
array_map(fn($p) => ltrim(str_replace('\\', '/', $p), '/'), $oldFiles),
|
|
$newFiles,
|
|
);
|
|
|
|
foreach ($removed as $path) {
|
|
if (! empty($path)) {
|
|
Storage::disk('public')->delete($path);
|
|
Log::info('sync: deleted removed patient file from disk', ['file' => $path, 'patient_id' => $patientId]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function pruneOrphanedPending(): int
|
|
{
|
|
$referenced = $this->getAllReferencedFilePaths();
|
|
$pruned = 0;
|
|
|
|
foreach (['s2c', 'c2s'] as $direction) {
|
|
$pending = $this->getPendingFiles($direction);
|
|
if (empty($pending)) {
|
|
continue;
|
|
}
|
|
|
|
$valid = array_values(array_filter($pending, fn($p) => in_array($p, $referenced)));
|
|
$removed = count($pending) - count($valid);
|
|
|
|
if ($removed > 0) {
|
|
$this->savePendingFiles($direction, $valid);
|
|
$pruned += $removed;
|
|
Log::info("pruneOrphanedPending: {$removed} orphaned paths removed from {$direction} pending");
|
|
}
|
|
}
|
|
|
|
return $pruned;
|
|
}
|
|
|
|
private function getAllReferencedFilePaths(): array
|
|
{
|
|
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
|
$paths = [];
|
|
|
|
$rows = DB::table('patients')
|
|
->where(function ($q) use ($fileFields) {
|
|
foreach ($fileFields as $field) {
|
|
$q->orWhereNotNull($field);
|
|
}
|
|
})
|
|
->get($fileFields);
|
|
|
|
foreach ($rows as $row) {
|
|
foreach ($fileFields as $field) {
|
|
if (empty($row->$field)) {
|
|
continue;
|
|
}
|
|
$files = json_decode($row->$field, true) ?? [];
|
|
foreach ($files as $path) {
|
|
if ($path) {
|
|
$paths[] = ltrim(str_replace('\\', '/', $path), '/');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_unique($paths);
|
|
}
|
|
|
|
|
|
public function healMissingFiles(): int
|
|
{
|
|
$fileFields = ['photos_before', 'photos_after', 'videos', 'audio_files'];
|
|
$missing = [];
|
|
|
|
$rows = DB::table('patients')
|
|
->where(function ($q) use ($fileFields) {
|
|
foreach ($fileFields as $field) {
|
|
$q->orWhereNotNull($field);
|
|
}
|
|
})
|
|
->get($fileFields);
|
|
|
|
foreach ($rows as $row) {
|
|
foreach ($fileFields as $field) {
|
|
if (empty($row->$field)) {
|
|
continue;
|
|
}
|
|
$files = json_decode($row->$field, true) ?? [];
|
|
foreach ($files as $filePath) {
|
|
if (! $filePath) {
|
|
continue;
|
|
}
|
|
$filePath = ltrim(str_replace('\\', '/', $filePath), '/');
|
|
if (! Storage::disk('public')->exists($filePath)) {
|
|
$missing[] = $filePath;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$missing = array_values(array_unique($missing));
|
|
|
|
if (! empty($missing)) {
|
|
$existing = $this->getPendingFiles('s2c');
|
|
$merged = array_values(array_unique(array_merge($existing, $missing)));
|
|
$this->savePendingFiles('s2c', $merged);
|
|
Log::info('healMissingFiles: added to pending queue', ['count' => count($missing)]);
|
|
}
|
|
|
|
return count($missing);
|
|
}
|
|
|
|
protected function getPendingFiles(string $direction): array
|
|
{
|
|
$record = \App\Models\OsurgInitial::where('init_parameter', "sync_pending_{$direction}")->first();
|
|
if (! $record || ! $record->attachment) {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($record->attachment, true);
|
|
|
|
return is_array($decoded) && array_is_list($decoded) ? $decoded : [];
|
|
}
|
|
|
|
protected function savePendingFiles(string $direction, array $files): void
|
|
{
|
|
$files = array_values(array_unique(array_filter($files)));
|
|
\App\Models\OsurgInitial::updateOrCreate(
|
|
['init_parameter' => "sync_pending_{$direction}"],
|
|
['attachment' => empty($files) ? null : json_encode($files, JSON_UNESCAPED_UNICODE)],
|
|
);
|
|
}
|
|
|
|
private function preQueueOutgoingFiles(array $changes): void
|
|
{
|
|
$paths = $this->extractFilePaths($changes);
|
|
if (empty($paths)) {
|
|
return;
|
|
}
|
|
$existing = $this->getPendingFiles('c2s');
|
|
$this->savePendingFiles('c2s', array_merge($existing, $paths));
|
|
}
|
|
|
|
private function saveOurCursor(string $cursor): void
|
|
{
|
|
\App\Models\OsurgInitial::updateOrCreate(
|
|
['init_parameter' => 'our_sync_cursor'],
|
|
['init_value' => $cursor],
|
|
);
|
|
cache()->forget('osurg_initial.our_sync_cursor');
|
|
}
|
|
|
|
private function savePeerCursor(string $cursor): void
|
|
{
|
|
\App\Models\OsurgInitial::updateOrCreate(
|
|
['init_parameter' => 'sync_peer_cursor'],
|
|
['init_value' => $cursor],
|
|
);
|
|
cache()->forget('osurg_initial.sync_peer_cursor');
|
|
}
|
|
|
|
private function sanitize(array $data): array
|
|
{
|
|
$result = [];
|
|
foreach ($data as $key => $value) {
|
|
if (is_array($value)) {
|
|
$result[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
} elseif (is_scalar($value) || $value === null) {
|
|
$result[$key] = $value;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
}
|