Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.12% |
124 / 129 |
|
78.57% |
11 / 14 |
CRAP | |
0.00% |
0 / 1 |
| FeedStorage | |
96.12% |
124 / 129 |
|
78.57% |
11 / 14 |
37 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fromConfig | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
3.00 | |||
| store | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
1 | |||
| latest | |
88.46% |
23 / 26 |
|
0.00% |
0 / 1 |
9.12 | |||
| get | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| exists | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| delete | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| lastModified | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| driver | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| filesystem | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| basePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| legacyPath | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| latestFromLegacy | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
6.01 | |||
| pruneOldVersions | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Infrastructure\Storage; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use DateTimeZone; |
| 9 | use Illuminate\Contracts\Filesystem\Filesystem; |
| 10 | use Illuminate\Support\Facades\Storage; |
| 11 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\FeedStorageVersionData; |
| 12 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\FeedStorageInterface; |
| 13 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\FeedStorageDriver; |
| 14 | |
| 15 | /** |
| 16 | * FeedStorage baseado em disco Laravel (Local ou Cloudflare R2 via S3). |
| 17 | * |
| 18 | * Estrutura: |
| 19 | * {base}/current.xml |
| 20 | * {base}/meta.json |
| 21 | * {base}/versions/{version}_{hash8}.xml |
| 22 | */ |
| 23 | final class FeedStorage implements FeedStorageInterface |
| 24 | { |
| 25 | public function __construct( |
| 26 | private readonly FeedStorageDriver $driver, |
| 27 | private readonly string $disk, |
| 28 | private readonly string $pathPrefixTemplate, |
| 29 | private readonly int $keepVersions = 10, |
| 30 | ) {} |
| 31 | |
| 32 | public static function fromConfig(): self |
| 33 | { |
| 34 | $driver = FeedStorageDriver::tryFrom((string) config('publishing.group_olx.storage.driver', 'local')) |
| 35 | ?? FeedStorageDriver::Local; |
| 36 | |
| 37 | $disk = match ($driver) { |
| 38 | FeedStorageDriver::Local => (string) config( |
| 39 | 'publishing.group_olx.storage.local_disk', |
| 40 | config('publishing.group_olx.feed_cache.disk', 'local'), |
| 41 | ), |
| 42 | FeedStorageDriver::R2 => (string) config('publishing.group_olx.storage.r2_disk', 'r2'), |
| 43 | }; |
| 44 | |
| 45 | $prefix = (string) config( |
| 46 | 'publishing.group_olx.storage.path_prefix', |
| 47 | 'publishing/{tenant_id}/group-olx', |
| 48 | ); |
| 49 | |
| 50 | $keep = (int) config('publishing.group_olx.storage.keep_versions', 10); |
| 51 | |
| 52 | return new self($driver, $disk, $prefix, max(1, $keep)); |
| 53 | } |
| 54 | |
| 55 | public function store(string $tenantId, string $xml): FeedStorageVersionData |
| 56 | { |
| 57 | $hash = hash('sha256', $xml); |
| 58 | $storedAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); |
| 59 | $version = $storedAt->format('Ymd\THis\Z'); |
| 60 | $base = $this->basePath($tenantId); |
| 61 | $versionPath = $base.'/versions/'.$version.'_'.substr($hash, 0, 8).'.xml'; |
| 62 | $currentPath = $base.'/current.xml'; |
| 63 | $metaPath = $base.'/meta.json'; |
| 64 | |
| 65 | $filesystem = $this->filesystem(); |
| 66 | $filesystem->put($versionPath, $xml); |
| 67 | $filesystem->put($currentPath, $xml); |
| 68 | $filesystem->put($metaPath, json_encode([ |
| 69 | 'tenant_id' => $tenantId, |
| 70 | 'version' => $version, |
| 71 | 'hash' => $hash, |
| 72 | 'stored_at' => $storedAt->format(DATE_ATOM), |
| 73 | 'path' => $versionPath, |
| 74 | 'current_path' => $currentPath, |
| 75 | 'driver' => $this->driver->value, |
| 76 | ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); |
| 77 | |
| 78 | $this->pruneOldVersions($filesystem, $base, $versionPath); |
| 79 | |
| 80 | return new FeedStorageVersionData( |
| 81 | tenantId: $tenantId, |
| 82 | version: $version, |
| 83 | hash: $hash, |
| 84 | storedAt: $storedAt, |
| 85 | path: $versionPath, |
| 86 | currentPath: $currentPath, |
| 87 | driver: $this->driver, |
| 88 | xml: $xml, |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | public function latest(string $tenantId, bool $withXml = true): ?FeedStorageVersionData |
| 93 | { |
| 94 | $metaPath = $this->basePath($tenantId).'/meta.json'; |
| 95 | $filesystem = $this->filesystem(); |
| 96 | |
| 97 | if (! $filesystem->exists($metaPath)) { |
| 98 | // Compatibilidade com path legado (Etapa 5). |
| 99 | return $this->latestFromLegacy($tenantId, $withXml); |
| 100 | } |
| 101 | |
| 102 | $raw = $filesystem->get($metaPath); |
| 103 | |
| 104 | if (! is_string($raw) || $raw === '') { |
| 105 | return null; |
| 106 | } |
| 107 | |
| 108 | /** @var array{tenant_id?: string, version?: string, hash?: string, stored_at?: string, path?: string, current_path?: string, driver?: string} $meta */ |
| 109 | $meta = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); |
| 110 | |
| 111 | if (! isset($meta['version'], $meta['hash'], $meta['stored_at'], $meta['path'], $meta['current_path'])) { |
| 112 | return null; |
| 113 | } |
| 114 | |
| 115 | $xml = null; |
| 116 | if ($withXml) { |
| 117 | $contents = $filesystem->get($meta['current_path']); |
| 118 | $xml = is_string($contents) && $contents !== '' ? $contents : null; |
| 119 | |
| 120 | if ($xml === null) { |
| 121 | return null; |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | return new FeedStorageVersionData( |
| 126 | tenantId: $tenantId, |
| 127 | version: $meta['version'], |
| 128 | hash: $meta['hash'], |
| 129 | storedAt: new DateTimeImmutable($meta['stored_at']), |
| 130 | path: $meta['path'], |
| 131 | currentPath: $meta['current_path'], |
| 132 | driver: FeedStorageDriver::tryFrom((string) ($meta['driver'] ?? $this->driver->value)) ?? $this->driver, |
| 133 | xml: $xml, |
| 134 | ); |
| 135 | } |
| 136 | |
| 137 | public function get(string $tenantId): ?string |
| 138 | { |
| 139 | return $this->latest($tenantId, withXml: true)?->xml; |
| 140 | } |
| 141 | |
| 142 | public function exists(string $tenantId): bool |
| 143 | { |
| 144 | $filesystem = $this->filesystem(); |
| 145 | $base = $this->basePath($tenantId); |
| 146 | |
| 147 | return $filesystem->exists($base.'/current.xml') |
| 148 | || $filesystem->exists($this->legacyPath($tenantId)); |
| 149 | } |
| 150 | |
| 151 | public function delete(string $tenantId): void |
| 152 | { |
| 153 | $filesystem = $this->filesystem(); |
| 154 | $base = $this->basePath($tenantId); |
| 155 | |
| 156 | if ($filesystem->exists($base.'/meta.json') || $filesystem->exists($base.'/current.xml')) { |
| 157 | $filesystem->deleteDirectory($base); |
| 158 | } |
| 159 | |
| 160 | $legacy = $this->legacyPath($tenantId); |
| 161 | if ($filesystem->exists($legacy)) { |
| 162 | $filesystem->delete($legacy); |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | public function lastModified(string $tenantId): ?int |
| 167 | { |
| 168 | $filesystem = $this->filesystem(); |
| 169 | $current = $this->basePath($tenantId).'/current.xml'; |
| 170 | |
| 171 | if ($filesystem->exists($current)) { |
| 172 | return $filesystem->lastModified($current); |
| 173 | } |
| 174 | |
| 175 | $legacy = $this->legacyPath($tenantId); |
| 176 | |
| 177 | return $filesystem->exists($legacy) ? $filesystem->lastModified($legacy) : null; |
| 178 | } |
| 179 | |
| 180 | public function driver(): FeedStorageDriver |
| 181 | { |
| 182 | return $this->driver; |
| 183 | } |
| 184 | |
| 185 | private function filesystem(): Filesystem |
| 186 | { |
| 187 | return Storage::disk($this->disk); |
| 188 | } |
| 189 | |
| 190 | private function basePath(string $tenantId): string |
| 191 | { |
| 192 | return str_replace('{tenant_id}', $tenantId, $this->pathPrefixTemplate); |
| 193 | } |
| 194 | |
| 195 | private function legacyPath(string $tenantId): string |
| 196 | { |
| 197 | $template = (string) config( |
| 198 | 'publishing.group_olx.feed_cache.path_template', |
| 199 | 'publishing/{tenant_id}/group-olx/vrsync.xml', |
| 200 | ); |
| 201 | |
| 202 | return str_replace('{tenant_id}', $tenantId, $template); |
| 203 | } |
| 204 | |
| 205 | private function latestFromLegacy(string $tenantId, bool $withXml): ?FeedStorageVersionData |
| 206 | { |
| 207 | $path = $this->legacyPath($tenantId); |
| 208 | $filesystem = $this->filesystem(); |
| 209 | |
| 210 | if (! $filesystem->exists($path)) { |
| 211 | return null; |
| 212 | } |
| 213 | |
| 214 | $xml = $filesystem->get($path); |
| 215 | |
| 216 | if (! is_string($xml) || $xml === '') { |
| 217 | return null; |
| 218 | } |
| 219 | |
| 220 | $mtime = $filesystem->lastModified($path) ?: time(); |
| 221 | $storedAt = (new DateTimeImmutable('@'.$mtime))->setTimezone(new DateTimeZone('UTC')); |
| 222 | |
| 223 | return new FeedStorageVersionData( |
| 224 | tenantId: $tenantId, |
| 225 | version: $storedAt->format('Ymd\THis\Z'), |
| 226 | hash: hash('sha256', $xml), |
| 227 | storedAt: $storedAt, |
| 228 | path: $path, |
| 229 | currentPath: $path, |
| 230 | driver: $this->driver, |
| 231 | xml: $withXml ? $xml : null, |
| 232 | ); |
| 233 | } |
| 234 | |
| 235 | private function pruneOldVersions(Filesystem $filesystem, string $base, string $keepPath): void |
| 236 | { |
| 237 | $versionsDir = $base.'/versions'; |
| 238 | $others = collect($filesystem->files($versionsDir)) |
| 239 | ->reject(static fn (string $file): bool => $file === $keepPath) |
| 240 | ->sort() |
| 241 | ->values() |
| 242 | ->all(); |
| 243 | |
| 244 | $allowedOthers = max(0, $this->keepVersions - 1); |
| 245 | $excess = count($others) - $allowedOthers; |
| 246 | |
| 247 | if ($excess <= 0) { |
| 248 | return; |
| 249 | } |
| 250 | |
| 251 | foreach (array_slice($others, 0, $excess) as $file) { |
| 252 | $filesystem->delete($file); |
| 253 | } |
| 254 | } |
| 255 | } |