Files
satellite-provider/SatelliteProvider.Services.TileDownloader/TileService.cs
T
Oleksandr Bezdieniezhnykh e9d6db077c [AZ-484] Fix multi-source tile reads: drop Dapper enum handler
Two integration-test failures uncovered after the initial commit:

1) GetTilesByRegionAsync outer ORDER BY referenced 'updated_at' but
   the inner DISTINCT ON subquery aliased it to 'UpdatedAt' (Postgres
   folds to 'updatedat'). DISTINCT ON already guarantees one row per
   (latitude, longitude, ...) so the third tiebreak was unreachable;
   removed it.

2) Dapper 2.1.35 silently bypasses SqlMapper.TypeHandler<T> for enum
   types during read deserialization (Dapper issue #259). The
   TileSourceTypeHandler worked for writes but reads fell through to
   Enum.TryParse, which cannot map 'google_maps' to GoogleMaps.

   Pivoted: TileEntity.Source is now a string (the wire value).
   TileSource enum stays as the public producer surface in
   Common.Enums; TileSourceConverter (Common.Enums) provides
   ToWireValue / FromWireValue / IsValidWireValue at the boundary.
   TileSourceTypeHandler deleted; registration removed from
   DapperEnumTypeHandlers.RegisterAll.

   tile-storage.md Inv-5 amended to document the storage choice.
   _docs/LESSONS.md L-001 records the Dapper bypass for future cycles.

Full suite passes (213 unit + integration suite incl. AZ-484
AC-1..AC-5, security SEC-01..SEC-04, AZ-356/362/357).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 06:44:34 +03:00

188 lines
6.5 KiB
C#

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
namespace SatelliteProvider.Services.TileDownloader;
public class TileService : ITileService
{
private static readonly TimeSpan TileCacheAbsolute = TimeSpan.FromHours(1);
private static readonly TimeSpan TileCacheSliding = TimeSpan.FromMinutes(30);
private static readonly TimeSpan TileResponseMaxAge = TimeSpan.FromDays(1);
private const string TileImageContentType = "image/jpeg";
private readonly ISatelliteDownloader _downloader;
private readonly ITileRepository _tileRepository;
private readonly IMemoryCache _cache;
private readonly MapConfig _mapConfig;
private readonly ILogger<TileService> _logger;
public TileService(
ISatelliteDownloader downloader,
ITileRepository tileRepository,
IMemoryCache cache,
IOptions<MapConfig> mapConfig,
ILogger<TileService> logger)
{
_downloader = downloader;
_tileRepository = tileRepository;
_cache = cache;
_mapConfig = mapConfig.Value;
_logger = logger;
}
public async Task<List<TileMetadata>> DownloadAndStoreTilesAsync(
double latitude,
double longitude,
double sizeMeters,
int zoomLevel,
CancellationToken cancellationToken = default)
{
var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
var existingTilesList = existingTiles.ToList();
var centerPoint = new GeoPoint(latitude, longitude);
var existingTileInfos = existingTilesList
.Select(t => new ExistingTileInfo(t.Latitude, t.Longitude, t.TileZoom))
.ToList();
var downloadedTiles = await _downloader.GetTilesWithMetadataAsync(
centerPoint,
sizeMeters / 2,
zoomLevel,
existingTileInfos,
cancellationToken);
var result = new List<TileMetadata>();
foreach (var existingTile in existingTilesList)
{
result.Add(MapToMetadata(existingTile));
}
foreach (var downloadedTile in downloadedTiles)
{
var tileEntity = BuildTileEntity(downloadedTile);
await _tileRepository.InsertAsync(tileEntity);
result.Add(MapToMetadata(tileEntity));
}
return result;
}
public async Task<TileMetadata?> GetTileAsync(Guid id)
{
var tile = await _tileRepository.GetByIdAsync(id);
return tile != null ? MapToMetadata(tile) : null;
}
public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
double latitude,
double longitude,
double sizeMeters,
int zoomLevel)
{
var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
return tiles.Select(MapToMetadata);
}
public async Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default)
{
var cacheKey = $"tile_{z}_{x}_{y}";
var etag = $"\"{z}_{x}_{y}\"";
if (_cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null)
{
return new TileBytes(cachedBytes, TileImageContentType, etag, TileResponseMaxAge);
}
string filePath;
var existing = await _tileRepository.GetByTileCoordinatesAsync(z, x, y);
if (existing != null && File.Exists(existing.FilePath))
{
filePath = existing.FilePath;
}
else
{
var tileCenter = GeoUtils.TileToWorldPos(x, y, z);
var downloaded = await _downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z, cancellationToken);
var entity = BuildTileEntity(downloaded);
await _tileRepository.InsertAsync(entity);
filePath = entity.FilePath;
}
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
_cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TileCacheAbsolute,
SlidingExpiration = TileCacheSliding
});
return new TileBytes(bytes, TileImageContentType, etag, TileResponseMaxAge);
}
public async Task<TileMetadata> DownloadAndStoreSingleTileAsync(
double latitude,
double longitude,
int zoomLevel,
CancellationToken cancellationToken = default)
{
var downloaded = await _downloader.DownloadSingleTileAsync(latitude, longitude, zoomLevel, cancellationToken);
var entity = BuildTileEntity(downloaded);
await _tileRepository.InsertAsync(entity);
return MapToMetadata(entity);
}
private TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded)
{
var now = DateTime.UtcNow;
return new TileEntity
{
Id = Guid.NewGuid(),
TileZoom = downloaded.ZoomLevel,
TileX = downloaded.X,
TileY = downloaded.Y,
Latitude = downloaded.CenterLatitude,
Longitude = downloaded.CenterLongitude,
TileSizeMeters = downloaded.TileSizeMeters,
TileSizePixels = _mapConfig.TileSizePixels,
ImageType = "jpg",
MapsVersion = null,
Version = null,
FilePath = downloaded.FilePath,
Source = TileSourceConverter.ToWireValue(TileSource.GoogleMaps),
CapturedAt = now,
CreatedAt = now,
UpdatedAt = now
};
}
private static TileMetadata MapToMetadata(TileEntity entity)
{
return new TileMetadata
{
Id = entity.Id,
TileZoom = entity.TileZoom,
TileX = entity.TileX,
TileY = entity.TileY,
Latitude = entity.Latitude,
Longitude = entity.Longitude,
TileSizeMeters = entity.TileSizeMeters,
TileSizePixels = entity.TileSizePixels,
ImageType = entity.ImageType,
Version = entity.Version,
FilePath = entity.FilePath,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
}