Lot C : backfill des colonnes de stockage (C2) et quota autoritaire (C3)

C2 — POST /api/Resource/backfill-storage, SuperAdmin, dryRun à true par
défaut : la migration se joue sur une base vide, ce backfill sur des lignes
de production. StoragePath par ResourceStorage.PathFor, SizeBytes par HEAD.

La méthode annoncée au plan — « SizeBytes par listing du bucket Firebase » —
était inapplicable : le serveur n'avait aucun client de stockage. Le sondage
passe donc par HEAD sur l'URL publique, comme le fait déjà la migration, et
le sondeur est extrait plutôt que recopié (Helpers/ResourceSizeProbe,
consommé par MigrationController et par le backfill). Même raisonnement que
pour ResourceStorage : deux copies auraient divergé sur ce qui compte, le
sort réservé aux échecs.

L'extraction a bouché un trou que personne ne cherchait. L'original ne notait
l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas : il
répond 404, sans Content-Length. Ces ressources arrivaient à 0 octet sans
figurer dans le rapport — invisibles au quota et invisibles au diagnostic,
exactement ce que le commentaire d'origine voulait empêcher.

Le « 37 lignes sur 45 » du plan n'étant pas vérifiable, le backfill rend son
propre inventaire : Orphans (aucune URL, blob peut-être jamais téléversé) et
Unsized (URL présente, bucket muet) restent séparés, ce sont deux causes
distinctes.

C3 — pré-vol du quota sur les deux chemins de création, suppression du blob
à Delete, angle mort d'Update tranché.

Deux défauts trouvés en câblant, qui n'étaient documentés nulle part :

- Le pré-vol existait déjà à moitié. Upload (multipart) contrôlait et
  renvoyait 413, Create (JSON) ne contrôlait rien — or c'est le chemin
  qu'emprunte manager-app, qui crée la ligne puis téléverse.
- Les deux lectures du quota divergeaient. Upload lisait le quota du plan,
  GetQuota celui de l'instance avec le plan en repli. Une instance à quota
  surchargé — le mécanisme même de l'add-on — affichait un chiffre à l'écran
  et se faisait bloquer sur un autre. Helpers/StorageQuota devient la seule
  source de vérité pour les deux.

Delete supprime le blob AVANT la ligne et renvoie 502 en conservant la ligne
si le bucket échoue. manager-app faisait l'inverse en avalant l'échec dans un
print : la ligne disparaissait, le blob restait, et n'ayant plus de ligne il
devenait invisible au quota tout en restant facturé. Une ressource encore
listée se rattrape ; un blob que plus aucune ligne ne désigne, non.

L'angle mort laissé ouvert par C1 était une fausse crainte : PathFor ne
construit qu'un pictures/{instanceId}/{resourceId}, le type n'entre pas dans
le chemin, il décide seulement s'il y en a un. Recalculer ne peut donc pas
pointer ailleurs, et Update rejoue Apply.

Aucun secret nouveau : FirebaseAdmin était déjà référencé pour les
notifications push et Startup charge déjà un service account, donc
Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la
clé Firebase:StorageBucket, vide par défaut — à renseigner en prod (I9),
sans quoi Delete ne supprime rien et ne prétend pas le contraire.

dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).

Contient aussi le correctif d'indexation préparé en parallèle : un job
Hangfire par section dans BackfillInstanceAsync au lieu d'une boucle, et un
backoff sur 429/503 dans GoogleEmbeddingService.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-12 12:05:25 +02:00
parent f9059ed198
commit 269b3f6703
15 changed files with 905 additions and 70 deletions

View File

@ -14,7 +14,10 @@ namespace ManagerService.Tests.Controllers
{
public class ResourceControllerTests
{
private static ResourceController BuildController(MyInfoMateDbContext db)
private static ResourceController BuildController(
MyInfoMateDbContext db,
FakeHttpClientFactory httpClientFactory = null,
FakeBlobService blobService = null)
{
var cfg = FakeMongoConfig.Create();
var controller = new ResourceController(
@ -22,7 +25,9 @@ namespace ManagerService.Tests.Controllers
new ResourceDatabaseService(cfg),
new SectionDatabaseService(cfg),
new ConfigurationDatabaseService(cfg),
db);
db,
httpClientFactory ?? new FakeHttpClientFactory(),
blobService ?? new FakeBlobService());
FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test"));
return controller;
}
@ -165,7 +170,7 @@ namespace ManagerService.Tests.Controllers
{
using var db = DbContextFactory.Create();
var result = BuildController(db).Delete("unknown");
var result = BuildController(db).Delete("unknown").GetAwaiter().GetResult();
Assert.IsType<NotFoundObjectResult>(result);
}
@ -177,11 +182,347 @@ namespace ManagerService.Tests.Controllers
db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image });
db.SaveChanges();
var result = BuildController(db).Delete("r1");
var result = BuildController(db).Delete("r1").GetAwaiter().GetResult();
var obj = Assert.IsType<ObjectResult>(result);
Assert.Equal(202, obj.StatusCode);
Assert.Equal(0, db.Resources.Count());
}
// ── QUOTA & BLOB (C3) ────────────────────────────────────────────────
private static void SeedPlan(MyInfoMateDbContext db, long planQuota, long instanceQuota = 0)
{
db.SubscriptionPlans.Add(new SubscriptionPlan
{
Id = "plan-test", Name = "Test", StorageQuotaBytes = planQuota
});
db.Instances.Add(new Instance
{
Id = "inst-test", Name = "Test", SubscriptionPlanId = "plan-test",
StorageQuotaBytes = instanceQuota
});
db.SaveChanges();
}
[Fact]
public void Create_OverQuota_Returns413()
{
using var db = DbContextFactory.Create();
SeedPlan(db, planQuota: 1000);
db.Resources.Add(new Resource
{
Id = "r0", InstanceId = "inst-test", Label = "Déjà là",
Type = ResourceType.Image, SizeBytes = 900
});
db.SaveChanges();
var result = BuildController(db).Create(new ResourceDTO
{
instanceId = "inst-test", label = "Trop gros",
type = ResourceType.Image, sizeBytes = 200
});
Assert.Equal(413, result.StatusCode);
Assert.Equal(1, db.Resources.Count());
}
[Fact]
public void Create_UrlType_IgnoresQuota()
{
using var db = DbContextFactory.Create();
SeedPlan(db, planQuota: 1000);
db.Resources.Add(new Resource
{
Id = "r0", InstanceId = "inst-test", Label = "Déjà là",
Type = ResourceType.Image, SizeBytes = 1000
});
db.SaveChanges();
// Un lien externe n'occupe rien dans le bucket : il ne doit pas être refusé
// pour un quota de stockage, même sur une instance pleine.
var result = BuildController(db).Create(new ResourceDTO
{
instanceId = "inst-test", label = "Lien",
type = ResourceType.ImageUrl, url = "https://ailleurs/img.png"
});
Assert.Equal(200, result.StatusCode);
}
/// <summary>
/// La divergence que StorageQuota ferme : la surcharge portée par l'instance
/// l'emporte sur le plan. Avant, le pré-vol lisait le plan et bloquait à 1 000
/// alors que l'écran de quota annonçait 10 000.
/// </summary>
[Fact]
public void Create_InstanceOverridesPlanQuota()
{
using var db = DbContextFactory.Create();
SeedPlan(db, planQuota: 1000, instanceQuota: 10000);
db.Resources.Add(new Resource
{
Id = "r0", InstanceId = "inst-test", Label = "Déjà là",
Type = ResourceType.Image, SizeBytes = 900
});
db.SaveChanges();
var result = BuildController(db).Create(new ResourceDTO
{
instanceId = "inst-test", label = "Dans le quota surchargé",
type = ResourceType.Image, sizeBytes = 200
});
Assert.Equal(200, result.StatusCode);
}
[Fact]
public void Delete_RemovesBlobBeforeRow()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
StoragePath = "pictures/inst-test/r1"
});
db.SaveChanges();
var blob = new FakeBlobService();
var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult();
Assert.Equal(202, result.StatusCode);
Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths);
Assert.Equal(0, db.Resources.Count());
}
/// <summary>
/// Le cœur du choix : un échec de bucket conserve la ligne. Une ligne encore
/// listée se rattrape ; un blob dont plus aucune ligne ne porte le chemin est
/// facturé sans que rien ne le désigne.
/// </summary>
[Fact]
public void Delete_BlobFailure_KeepsRowAndReturns502()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
StoragePath = "pictures/inst-test/r1"
});
db.SaveChanges();
var blob = new FakeBlobService(BlobDeleteOutcome.Failed);
var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult();
Assert.Equal(502, result.StatusCode);
Assert.Equal(1, db.Resources.Count());
}
[Fact]
public void Delete_MissingStoragePath_IsRebuiltFromTheDeterministicPath()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "Avant le backfill", Type = ResourceType.Image
});
db.SaveChanges();
var blob = new FakeBlobService();
BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult();
Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths);
}
[Fact]
public void Update_TypeBecomesFile_FillsStoragePath()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.ImageUrl,
Url = "https://ailleurs/img.png"
});
db.SaveChanges();
BuildController(db).Update(new ResourceDTO
{
id = "r1", type = ResourceType.Image, sizeBytes = 256
});
var resource = db.Resources.Single();
Assert.Equal("pictures/inst-test/r1", resource.StoragePath);
Assert.Equal(256, resource.SizeBytes);
}
[Fact]
public void Update_TypeBecomesUrl_ClearsStorageColumns()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
StoragePath = "pictures/inst-test/r1", SizeBytes = 4096
});
db.SaveChanges();
BuildController(db).Update(new ResourceDTO { id = "r1", type = ResourceType.ImageUrl });
var resource = db.Resources.Single();
Assert.Null(resource.StoragePath);
Assert.Equal(0, resource.SizeBytes);
}
// ── BACKFILL STORAGE (C2) ────────────────────────────────────────────
private static ResourceBackfillReportDTO RunBackfill(
ResourceController controller, bool dryRun = false, string instanceId = null)
{
var result = controller.BackfillStorage(dryRun, instanceId).GetAwaiter().GetResult();
var ok = Assert.IsType<OkObjectResult>(result);
return Assert.IsType<ResourceBackfillReportDTO>(ok.Value);
}
[Fact]
public void Backfill_FillsStoragePathAndSizeBytes()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A",
Type = ResourceType.Image, Url = "https://bucket/r1"
});
db.SaveChanges();
var http = new FakeHttpClientFactory(new Dictionary<string, long> { ["https://bucket/r1"] = 2048 });
var report = RunBackfill(BuildController(db, http));
var resource = db.Resources.Single();
Assert.Equal("pictures/inst-test/r1", resource.StoragePath);
Assert.Equal(2048, resource.SizeBytes);
Assert.Equal(1, report.StoragePathFilled);
Assert.Equal(1, report.SizeBytesFilled);
}
/// <summary>
/// Le point du lien L10 : un backfill qui écrirait quand même laisserait le quota
/// de C3 porter sur des zéros. Une ligne non sondable doit ressortir nommée.
/// </summary>
[Fact]
public void Backfill_UnreachableBlob_IsReportedNotCountedAsZero()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "Absente",
Type = ResourceType.Image, Url = "https://bucket/disparue"
});
db.SaveChanges();
var report = RunBackfill(BuildController(db, new FakeHttpClientFactory()));
Assert.Equal(0, report.SizeBytesFilled);
Assert.Single(report.Unsized);
Assert.Contains("r1", report.Unsized[0]);
// Le chemin, lui, est déterministe : il se calcule même sans blob joignable.
Assert.Equal("pictures/inst-test/r1", db.Resources.Single().StoragePath);
}
[Fact]
public void Backfill_FileTypeWithoutUrl_IsListedAsOrphan()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "Sans URL", Type = ResourceType.PDF
});
db.SaveChanges();
var report = RunBackfill(BuildController(db));
Assert.Single(report.Orphans);
Assert.Empty(report.Unsized);
}
[Fact]
public void Backfill_UrlType_KeepsNoPathAndNoSize()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "Lien", Type = ResourceType.ImageUrl,
Url = "https://ailleurs/img.png", StoragePath = "pictures/inst-test/r1", SizeBytes = 999
});
db.SaveChanges();
var report = RunBackfill(BuildController(db));
var resource = db.Resources.Single();
Assert.Null(resource.StoragePath);
Assert.Equal(0, resource.SizeBytes);
Assert.Equal(1, report.UrlTypesNormalised);
}
[Fact]
public void Backfill_AlreadyCompleteRow_IsLeftAlone()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image,
Url = "https://bucket/r1", StoragePath = "pictures/inst-test/r1", SizeBytes = 512
});
db.SaveChanges();
var http = new FakeHttpClientFactory(new Dictionary<string, long> { ["https://bucket/r1"] = 999999 });
var report = RunBackfill(BuildController(db, http));
// La taille en base a été écrite par la création, qui connaissait le fichier :
// la resonder n'apporterait rien et écraserait une valeur plus sûre.
Assert.Equal(512, db.Resources.Single().SizeBytes);
Assert.Equal(1, report.AlreadyComplete);
Assert.Equal(0, report.SizeBytesFilled);
}
[Fact]
public void Backfill_DryRun_WritesNothing()
{
using var db = DbContextFactory.Create();
db.Resources.Add(new Resource
{
Id = "r1", InstanceId = "inst-test", Label = "A",
Type = ResourceType.Image, Url = "https://bucket/r1"
});
db.SaveChanges();
var http = new FakeHttpClientFactory(new Dictionary<string, long> { ["https://bucket/r1"] = 2048 });
var report = RunBackfill(BuildController(db, http), dryRun: true);
Assert.True(report.DryRun);
Assert.Equal(1, report.StoragePathFilled);
// Sans vider le ChangeTracker, l'entité modifiée en mémoire répondrait la
// nouvelle valeur alors que rien n'a été enregistré : l'assertion serait creuse.
db.ChangeTracker.Clear();
var reread = db.Resources.Single();
Assert.Null(reread.StoragePath);
Assert.Equal(0, reread.SizeBytes);
}
[Fact]
public void Backfill_ScopedToInstance_IgnoresOthers()
{
using var db = DbContextFactory.Create();
db.Resources.AddRange(
new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image },
new Resource { Id = "r2", InstanceId = "other-inst", Label = "B", Type = ResourceType.Image }
);
db.SaveChanges();
var report = RunBackfill(BuildController(db), instanceId: "inst-test");
Assert.Equal(1, report.Examined);
Assert.Null(db.Resources.Single(r => r.Id == "r2").StoragePath);
}
}
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ManagerService.Services;
namespace ManagerService.Tests.Infrastructure
{
/// <summary>
/// Bucket en mémoire. Enregistre les chemins qu'on lui demande de supprimer, ce qui
/// permet de vérifier non seulement que la suppression a eu lieu, mais qu'elle a porté
/// sur le bon emplacement — le chemin étant reconstruit quand StoragePath est nul.
/// </summary>
public class FakeBlobService : IResourceBlobService
{
private readonly BlobDeleteOutcome _outcome;
public FakeBlobService(BlobDeleteOutcome outcome = BlobDeleteOutcome.Deleted)
{
_outcome = outcome;
}
public List<string> DeletedPaths { get; } = new();
public bool IsConfigured => _outcome != BlobDeleteOutcome.NotConfigured;
public Task<BlobDeleteOutcome> DeleteAsync(string storagePath)
{
DeletedPaths.Add(storagePath);
return Task.FromResult(_outcome);
}
}
}

View File

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace ManagerService.Tests.Infrastructure
{
/// <summary>
/// Fabrique d'HttpClient sans réseau, pour les chemins qui sondent le poids d'un blob
/// par HEAD (<c>ResourceSizeProbe</c>). Les tailles sont données par URL ; une URL
/// absente de la table répond sans <c>Content-Length</c>, ce qui est exactement le cas
/// « blob introuvable » que le backfill doit inventorier au lieu de le compter pour zéro.
/// </summary>
public class FakeHttpClientFactory : IHttpClientFactory
{
private readonly IDictionary<string, long> _sizesByUrl;
private readonly ISet<string> _throwingUrls;
public FakeHttpClientFactory(IDictionary<string, long> sizesByUrl = null, ISet<string> throwingUrls = null)
{
_sizesByUrl = sizesByUrl ?? new Dictionary<string, long>();
_throwingUrls = throwingUrls ?? new HashSet<string>();
}
public HttpClient CreateClient(string name = "") =>
new HttpClient(new StubHandler(_sizesByUrl, _throwingUrls));
private class StubHandler : HttpMessageHandler
{
private readonly IDictionary<string, long> _sizesByUrl;
private readonly ISet<string> _throwingUrls;
public StubHandler(IDictionary<string, long> sizesByUrl, ISet<string> throwingUrls)
{
_sizesByUrl = sizesByUrl;
_throwingUrls = throwingUrls;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
if (_throwingUrls.Contains(url))
throw new HttpRequestException("hôte injoignable");
var response = new HttpResponseMessage(HttpStatusCode.OK);
if (_sizesByUrl.TryGetValue(url, out var size))
{
// Content-Length ne se pose que sur un contenu : un HEAD réel renvoie
// l'en-tête sans le corps, ce que StringContent + la longueur imite
// suffisamment pour la sonde, qui ne lit que l'en-tête.
response.Content = new ByteArrayContent(Array.Empty<byte>());
response.Content.Headers.ContentLength = size;
}
else
{
response.StatusCode = HttpStatusCode.NotFound;
response.Content = new ByteArrayContent(Array.Empty<byte>());
response.Content.Headers.ContentLength = null;
}
return Task.FromResult(response);
}
}
}
}

View File

@ -383,7 +383,9 @@ namespace ManagerService.Controllers
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
if (plan != null)
{
if (storageQuota == 0) storageQuota = plan.StorageQuotaBytes;
// Même résolveur que le pré-vol de ResourceController : le chiffre
// affiché ici et celui qui bloque un téléversement doivent être le même.
storageQuota = StorageQuota.Resolve(storageQuota, plan.StorageQuotaBytes);
if (aiQuota == 0) aiQuota = plan.AiTokensPerMonth;
}
}

View File

@ -226,36 +226,18 @@ namespace ManagerService.Controllers
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
var source = instanceIds.SelectMany(id => _resourceSvc.GetAll(id)).ToList();
// Fetch sizes en parallèle par batches de 30
// Sondage des tailles par HEAD, extrait dans ResourceSizeProbe : le backfill
// des lignes existantes (C2) en a besoin à l'identique, et deux copies
// divergeraient sur ce qui compte — le sort réservé aux échecs.
// L'échec était avalé sans laisser de trace : la ressource arrivait à
// 0 octet et le quota la comptait pour rien, sans que personne puisse
// savoir lesquelles. La sonde les signale, y compris le cas ajouté à
// l'extraction : une réponse sans Content-Length, qu'aucun catch n'attrapait.
var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
var unsized = new System.Collections.Concurrent.ConcurrentDictionary<string, bool>();
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
const int batchSize = 30;
for (int i = 0; i < urlSource.Count; i += batchSize)
{
var batch = urlSource.Skip(i).Take(batchSize);
await Task.WhenAll(batch.Select(async r =>
{
try
{
var req = new HttpRequestMessage(HttpMethod.Head, r.Url);
var resp = await httpClient.SendAsync(req);
if (resp.Content.Headers.ContentLength.HasValue)
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
}
catch
{
// L'échec était avalé sans laisser de trace : la ressource
// arrivait à 0 octet et le quota de stockage la comptait pour
// rien, sans que personne puisse savoir lesquelles. On le note.
unsized[r.Id] = true;
}
}));
}
var probe = await ResourceSizeProbe.ProbeAsync(
httpClient, source.Select(r => (r.Id, r.Url)));
foreach (var old in source)
{
@ -281,12 +263,12 @@ namespace ManagerService.Controllers
// l'était à la main. Les deux passent par le même calculateur que la
// création et le backfill (lien L5) — sinon les trois divergent sur
// les types URL, qui n'ont ni blob ni poids à compter dans le quota.
ResourceStorage.Apply(entity, sizemap.TryGetValue(old.Id, out var size) ? size : 0);
ResourceStorage.Apply(entity, probe.TryGetSize(old.Id, out var size) ? size : 0);
if (!dryRun)
_db.Resources.Add(entity);
if (unsized.ContainsKey(old.Id) && ResourceStorage.HasBlob(entity.Type))
if (probe.IsUnsized(old.Id) && ResourceStorage.HasBlob(entity.Type))
report.Errors.Add($"Resource {old.Id} ({old.Label}) : taille inconnue (HEAD en échec), SizeBytes à 0 — le quota de stockage la comptera pour rien");
report.Migrated.Resources++;

View File

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Manager.DTOs;
using Manager.Services;
using ManagerService.Data;
@ -30,18 +32,22 @@ namespace ManagerService.Controllers
private SectionDatabaseService _sectionService;
private ConfigurationDatabaseService _configurationService;
private readonly ILogger<ResourceController> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IResourceBlobService _blobService;
IHexIdGeneratorService idService = new HexIdGeneratorService();
private static int MaxWidth = 1024;
private static int MaxHeight = 1024;
public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext)
public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService)
{
_logger = logger;
_resourceService = resourceService;
_sectionService = sectionService;
_configurationService = configurationService;
_myInfoMateDbContext = myInfoMateDbContext;
_httpClientFactory = httpClientFactory;
_blobService = blobService;
}
/// <summary>
@ -234,19 +240,9 @@ namespace ManagerService.Controllers
var resourceType = (ResourceType)Enum.Parse(typeof(ResourceType), type);
List<Resource> resources = new List<Resource>();
var instance = _myInfoMateDbContext.Instances
.Include(i => i.SubscriptionPlan)
.FirstOrDefault(i => i.Id == instanceId);
var storageQuota = instance?.SubscriptionPlan?.StorageQuotaBytes ?? 0;
if (storageQuota > 0)
{
var storageUsed = _myInfoMateDbContext.Resources
.Where(r => r.InstanceId == instanceId)
.Sum(r => (long?)r.SizeBytes) ?? 0;
var incomingBytes = Request.Form.Files.Sum(f => f.Length);
if (storageUsed + incomingBytes > storageQuota)
if (ExceedsStorageQuota(instanceId, incomingBytes))
return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 };
}
foreach (var file in Request.Form.Files)
{
@ -323,6 +319,14 @@ namespace ManagerService.Controllers
if (newResource == null)
throw new ArgumentNullException("Resource param is null");
// Le pré-vol manquait sur ce chemin : manager-app crée la ligne par JSON
// en annonçant sizeBytes, PUIS téléverse le blob. Sans contrôle ici, tout
// dépassement passait par la porte de service — seul le chemin multipart
// était gardé.
if (ResourceStorage.HasBlob(newResource.type)
&& ExceedsStorageQuota(newResource.instanceId, newResource.sizeBytes))
return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 };
// Todo add some verification ?
Resource resource = new Resource();
resource.InstanceId = newResource.instanceId;
@ -387,6 +391,19 @@ namespace ManagerService.Controllers
if (updatedResource.sizeBytes > 0)
resource.SizeBytes = updatedResource.sizeBytes;
// Angle mort laissé ouvert par C1, tranché ici : un Update peut faire passer
// Type d'un type URL à un type fichier, laissant StoragePath nul sur une ligne
// qui a désormais un blob. La crainte d'alors — « recalculer pointerait vers un
// objet qui n'a pas bougé » — ne tient pas : PathFor ne construit qu'un
// pictures/{instanceId}/{resourceId}, le type n'entre pas dans le chemin, il
// décide seulement s'il y en a un. Recalculer est donc sans risque.
ResourceStorage.Apply(resource, resource.SizeBytes);
if (!ResourceStorage.HasBlob(resource.Type))
{
resource.StoragePath = null;
resource.SizeBytes = 0;
}
_myInfoMateDbContext.SaveChanges();
//OldResource resourceModified = _resourceService.Update(updatedResource.id, resource);
@ -416,8 +433,9 @@ namespace ManagerService.Controllers
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[ProducesResponseType(typeof(string), 502)]
[HttpDelete("{id}")]
public ObjectResult Delete(string id)
public async Task<ObjectResult> Delete(string id)
{
try
{
@ -429,6 +447,17 @@ namespace ManagerService.Controllers
if (resource == null)
throw new KeyNotFoundException("Resource does not exist");
// Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait
// l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de
// ligne il devenait invisible au quota tout en restant facturé. Ici un
// échec interrompt : mieux vaut une ressource encore listée qu'un orphelin
// que plus rien ne désigne.
var storagePath = resource.StoragePath
?? ResourceStorage.PathFor(resource.Type, resource.InstanceId, resource.Id);
var outcome = await _blobService.DeleteAsync(storagePath);
if (outcome == BlobDeleteOutcome.Failed)
return new ObjectResult("Le fichier n'a pas pu être supprimé du stockage — la ressource est conservée") { StatusCode = 502 };
List<Configuration> configurations = _myInfoMateDbContext.Configurations.Where(c => c.InstanceId == resource.InstanceId).ToList();
foreach (var configuration in configurations)
@ -704,6 +733,127 @@ namespace ManagerService.Controllers
}
}
/// <summary>
/// Renseigne StoragePath et SizeBytes sur les ressources déjà en base (C2).
///
/// Ces colonnes sont nées après le contenu : les lignes créées avant portent un
/// StoragePath nul, et un SizeBytes nul pour celles créées avant que le chemin
/// multipart ne l'écrive. Un quota calculé là-dessus laisserait tout passer,
/// d'où l'ordre imposé — ce backfill précède le contrôle autoritaire (lien L10).
///
/// dryRun est à true par défaut, à l'inverse de la migration : celle-ci se joue
/// sur une base vide, celui-ci sur des lignes de production.
/// </summary>
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
[ProducesResponseType(typeof(ResourceBackfillReportDTO), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("backfill-storage")]
public async Task<ObjectResult> BackfillStorage(
[FromQuery] bool dryRun = true,
[FromQuery] string instanceId = null)
{
try
{
var report = new ResourceBackfillReportDTO { DryRun = dryRun };
var query = _myInfoMateDbContext.Resources.AsQueryable();
if (!string.IsNullOrEmpty(instanceId))
query = query.Where(r => r.InstanceId == instanceId);
var resources = await query.ToListAsync();
report.Examined = resources.Count;
// Un type URL ne doit porter ni chemin ni poids : il pointe hors du bucket.
// Le cas ne devrait pas exister, mais une ligne mal renseignée gonflerait
// le quota d'octets que personne ne stocke — on la remet dans le contrat
// de ResourceStorage plutôt que de la laisser fausser le calcul de C3.
foreach (var resource in resources.Where(r => !ResourceStorage.HasBlob(r.Type)))
{
if (resource.StoragePath == null && resource.SizeBytes == 0) continue;
resource.StoragePath = null;
resource.SizeBytes = 0;
report.UrlTypesNormalised++;
}
var blobs = resources.Where(r => ResourceStorage.HasBlob(r.Type)).ToList();
// Seules les tailles inconnues sont sondées : une valeur déjà en base a été
// écrite par la création, qui connaissait le fichier réel. La resonder
// coûterait un HEAD par ressource pour un résultat identique.
var toProbe = blobs.Where(r => r.SizeBytes == 0).ToList();
var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
var probe = await ResourceSizeProbe.ProbeAsync(
httpClient, toProbe.Select(r => (r.Id, r.Url)));
foreach (var resource in blobs)
{
var hadPath = !string.IsNullOrEmpty(resource.StoragePath);
var hadSize = resource.SizeBytes > 0;
// Le chemin est déterministe, la taille ne l'est pas : on repasse par
// le calculateur commun en lui redonnant la taille connue quand elle
// l'est, plutôt que d'écrire les colonnes à la main ici (lien L5).
var size = hadSize
? resource.SizeBytes
: (probe.TryGetSize(resource.Id, out var probed) ? probed : 0);
ResourceStorage.Apply(resource, size);
if (!hadPath) report.StoragePathFilled++;
if (!hadSize && size > 0) report.SizeBytesFilled++;
if (hadPath && hadSize) report.AlreadyComplete++;
if (!hadSize && size == 0)
{
// Deux causes distinctes, à ne pas confondre dans un même total :
// une ligne sans URL n'est pas sondable du tout (le blob n'a
// peut-être jamais été téléversé), une ligne sondée sans réponse
// exploitable pointe vers un objet absent ou un bucket muet.
if (string.IsNullOrEmpty(resource.Url))
report.Orphans.Add($"{resource.Id} ({resource.Label}) — type {resource.Type}, aucune URL : blob jamais téléversé ?");
else
report.Unsized.Add($"{resource.Id} ({resource.Label}) — HEAD sans Content-Length sur {resource.Url}");
}
}
if (!dryRun)
await _myInfoMateDbContext.SaveChangesAsync();
return new OkObjectResult(report);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Pré-vol du quota, partagé par les deux chemins de création. Passe par
/// <see cref="StorageQuota"/> pour que le chiffre opposé au client soit celui
/// que son écran de quota lui annonce.
/// </summary>
private bool ExceedsStorageQuota(string instanceId, long incomingBytes)
{
if (string.IsNullOrEmpty(instanceId)) return false;
var instance = _myInfoMateDbContext.Instances
.Include(i => i.SubscriptionPlan)
.FirstOrDefault(i => i.Id == instanceId);
if (instance == null) return false;
var quota = StorageQuota.Resolve(
instance.StorageQuotaBytes, instance.SubscriptionPlan?.StorageQuotaBytes ?? 0);
if (quota <= 0) return false;
var used = _myInfoMateDbContext.Resources
.Where(r => r.InstanceId == instanceId)
.Sum(r => (long?)r.SizeBytes) ?? 0;
return StorageQuota.Exceeds(used, incomingBytes, quota);
}
/// <summary>
/// Le filigrane est un réglage d'instance depuis le 2026-08-11. Auparavant
/// un `instanceId == "633ee379…"` en dur ne l'activait que pour Fort Saint-Héribert.
@ -714,4 +864,21 @@ namespace ManagerService.Controllers
.Select(i => i.IsImageWatermark)
.FirstOrDefault();
}
/// <summary>
/// Le backfill ne se contente pas de compter : les deux listes sont l'inventaire
/// des lignes qu'il n'a pas pu réparer. C'est la seule sortie qui dise combien
/// d'octets le quota de C3 ne verra jamais.
/// </summary>
public class ResourceBackfillReportDTO
{
public bool DryRun { get; set; }
public int Examined { get; set; }
public int StoragePathFilled { get; set; }
public int SizeBytesFilled { get; set; }
public int AlreadyComplete { get; set; }
public int UrlTypesNormalised { get; set; }
public List<string> Orphans { get; set; } = new();
public List<string> Unsized { get; set; } = new();
}
}

View File

@ -0,0 +1,86 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
namespace ManagerService.Helpers
{
/// <summary>
/// Sonde le poids d'un blob par requête HEAD sur son URL publique, par lots.
///
/// Deux appelants : <c>MigrationController</c> (écart e de la bascule) et le backfill
/// des lignes existantes. Comme <see cref="ResourceStorage"/>, c'est un calculateur
/// unique plutôt que deux implémentations qui divergeraient — ici sur la taille de lot,
/// le délai d'attente et surtout le sort réservé aux échecs.
///
/// ⚠️ Le serveur n'a aucun client de bucket : ni Firebase Admin ni Google.Cloud.Storage
/// ne sont référencés dans le projet, les blobs étant téléversés depuis manager-app
/// directement. L'URL publique est donc le seul moyen de connaître une taille.
/// </summary>
public static class ResourceSizeProbe
{
/// <summary>Trente requêtes en vol à la fois, valeur héritée de la migration.</summary>
public const int BatchSize = 30;
public sealed class Result
{
private readonly ConcurrentDictionary<string, long> _sizes = new();
private readonly ConcurrentDictionary<string, bool> _unsized = new();
public bool TryGetSize(string id, out long size) => _sizes.TryGetValue(id, out size);
/// <summary>
/// Vrai si la sonde n'a pas su donner de taille : HEAD en échec, ou réponse
/// sans <c>Content-Length</c>. Les deux cas laissent la ressource à 0 octet,
/// donc invisible au quota — l'appelant doit le signaler, pas l'avaler.
/// </summary>
public bool IsUnsized(string id) => _unsized.ContainsKey(id);
public int SizedCount => _sizes.Count;
public int UnsizedCount => _unsized.Count;
internal void Record(string id, long size) => _sizes[id] = size;
internal void RecordUnsized(string id) => _unsized[id] = true;
}
/// <summary>
/// Le <paramref name="client"/> est fourni par l'appelant, qui reste maître du
/// délai d'attente : la migration accepte 10 s par ressource, un backfill lancé
/// à la main peut vouloir plus.
/// </summary>
public static async Task<Result> ProbeAsync(HttpClient client, IEnumerable<(string Id, string Url)> targets)
{
var result = new Result();
var list = targets.Where(t => !string.IsNullOrEmpty(t.Url)).ToList();
for (int i = 0; i < list.Count; i += BatchSize)
{
var batch = list.Skip(i).Take(BatchSize);
await Task.WhenAll(batch.Select(async target =>
{
try
{
var request = new HttpRequestMessage(HttpMethod.Head, target.Url);
var response = await client.SendAsync(request);
// Un 404 ne lève pas : il répond simplement sans Content-Length.
// Le traiter comme un échec est le seul moyen de ne pas confondre
// « blob absent » et « blob de 0 octet ».
if (response.Content.Headers.ContentLength.HasValue)
result.Record(target.Id, response.Content.Headers.ContentLength.Value);
else
result.RecordUnsized(target.Id);
}
catch
{
result.RecordUnsized(target.Id);
}
}));
}
return result;
}
}
}

View File

@ -0,0 +1,29 @@
namespace ManagerService.Helpers
{
/// <summary>
/// Seule source de vérité pour « quel quota de stockage s'applique à cette instance ».
///
/// Les deux lectures divergeaient : le pré-vol du téléversement lisait le quota du
/// **plan**, l'écran de quota lisait celui de l'**instance** avec le plan en repli.
/// Une instance dont le quota a été surchargé — le mécanisme même de l'add-on —
/// affichait donc un chiffre et se faisait bloquer sur un autre.
/// </summary>
public static class StorageQuota
{
/// <summary>
/// La surcharge portée par l'instance l'emporte ; 0 veut dire « pas de surcharge »
/// et renvoie au plan. ⚠️ Au niveau du plan, 0 garde son autre sens : illimité.
/// L'asymétrie est volontaire et documentée au §1quinquies — ne pas l'harmoniser
/// sans repasser dans CheckQuota, AiController et SectionIndexingInterceptor.
/// </summary>
public static long Resolve(long instanceQuotaBytes, long planQuotaBytes) =>
instanceQuotaBytes != 0 ? instanceQuotaBytes : planQuotaBytes;
/// <summary>
/// Un quota résolu à 0 est illimité : rien ne dépasse. C'est le cas d'Enterprise,
/// et c'est aussi ce qui protège une instance sans plan d'être bloquée à zéro octet.
/// </summary>
public static bool Exceeds(long usedBytes, long incomingBytes, long quotaBytes) =>
quotaBytes > 0 && usedBytes + incomingBytes > quotaBytes;
}
}

View File

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Cloud.Storage.V1" Version="4.15.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.30" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>

View File

@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
@ -26,6 +27,8 @@ namespace ManagerService.Services
/// </summary>
private const int BatchSize = 50;
private const int MaxThrottleRetries = 3;
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _apiKey;
@ -74,7 +77,24 @@ namespace ManagerService.Services
Dimensions = Dimensions
};
var response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken);
HttpResponseMessage response;
// Une limite de débit est passagère : la remonter ferait échouer le job Hangfire,
// qui retenterait la section entière — donc ré-embedderait ses morceaux déjà faits.
// Retry-After d'abord, exponentiel s'il est absent.
for (var attempt = 0; ; attempt++)
{
response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken);
var throttled = response.StatusCode == HttpStatusCode.TooManyRequests
|| response.StatusCode == HttpStatusCode.ServiceUnavailable;
if (!throttled || attempt >= MaxThrottleRetries)
break;
var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(wait, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{

View File

@ -12,10 +12,10 @@ namespace ManagerService.Services
Task IngestSectionAsync(string sectionId);
/// <summary>
/// Réindexe toutes les sections d'une instance et rend le nombre de morceaux produits.
/// Sert au rattrapage après un passage à un plan avec IA : sans lui, le client paie un
/// guide qui ne connaît rien de son contenu, puisque rien n'a été indexé tant qu'il
/// était sur un plan sans IA.
/// Met en file un job d'indexation par section de l'instance, et rend le nombre de
/// sections mises en file. Sert au rattrapage après un passage à un plan avec IA :
/// sans lui, le client paie un guide qui ne connaît rien de son contenu, puisque rien
/// n'a été indexé tant qu'il était sur un plan sans IA.
/// </summary>
Task<int> BackfillInstanceAsync(string instanceId);
}

View File

@ -32,20 +32,20 @@ namespace ManagerService.Services
private readonly IVectorStoreService _vectorStore;
private readonly IConfiguration _configuration;
private readonly ILogger<IngestionService> _logger;
private readonly IBackgroundJobClient _jobs;
public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore,
IConfiguration configuration, ILogger<IngestionService> logger)
IConfiguration configuration, ILogger<IngestionService> logger,
IBackgroundJobClient jobs)
{
_db = db;
_vectorStore = vectorStore;
_configuration = configuration;
_logger = logger;
_jobs = jobs;
}
public Task IngestSectionAsync(string sectionId) => IngestSectionCountingAsync(sectionId);
/// <summary>Même travail, en rendant le nombre de morceaux — ce dont le backfill a besoin.</summary>
private async Task<int> IngestSectionCountingAsync(string sectionId)
public async Task IngestSectionAsync(string sectionId)
{
var stub = await _db.Sections.AsNoTracking().FirstOrDefaultAsync(s => s.Id == sectionId);
@ -53,7 +53,7 @@ namespace ManagerService.Services
if (stub == null)
{
await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section);
return 0;
return;
}
// Re-vérifié ici et pas seulement à l'enqueue : le plan a pu changer entre les deux,
@ -68,13 +68,13 @@ namespace ManagerService.Services
// de paiement réglé le lendemain ne doit pas imposer de tout ré-embedder.
// Voir DOCS/v2/rag-indexing-trigger-decision.md.
if (!hasAi)
return 0;
return;
// Une section désactivée doit disparaître des réponses du guide, pas seulement de l'app.
if (!stub.IsActive)
{
await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section);
return 0;
return;
}
var section = await LoadWithChildrenAsync(stub);
@ -84,9 +84,14 @@ namespace ManagerService.Services
section.Id, ContentSourceType.Section, chunks);
_logger.LogInformation("Section {SectionId} indexée : {ChunkCount} morceaux", sectionId, chunks.Count);
return chunks.Count;
}
/// <summary>
/// Un job par section plutôt qu'une boucle : l'unité de reprise devient la section.
/// En boucle, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par
/// Hangfire, donc ré-embedder les 279 déjà indexées — des appels facturés pour rien, et
/// autant de chances de retomber sur la même limite de débit.
/// </summary>
public async Task<int> BackfillInstanceAsync(string instanceId)
{
var sectionIds = await _db.Sections
@ -94,13 +99,12 @@ namespace ManagerService.Services
.Select(s => s.Id)
.ToListAsync();
var chunkCount = 0;
foreach (var id in sectionIds)
chunkCount += await IngestSectionCountingAsync(id);
_jobs.Enqueue<IIngestionService>(s => s.IngestSectionAsync(id));
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections, {ChunkCount} morceaux",
instanceId, sectionIds.Count, chunkCount);
return chunkCount;
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections mises en file",
instanceId, sectionIds.Count);
return sectionIds.Count;
}
/// <summary>

View File

@ -0,0 +1,98 @@
using System;
using System.Threading.Tasks;
using Google.Apis.Auth.OAuth2;
using Google.Cloud.Storage.V1;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ManagerService.Services
{
public enum BlobDeleteOutcome
{
/// <summary>Objet supprimé.</summary>
Deleted,
/// <summary>
/// Rien à supprimer : blob jamais téléversé, ou déjà supprimé. Ce n'est pas un
/// échec — c'est même l'état recherché, donc l'appelant peut continuer.
/// </summary>
AlreadyAbsent,
/// <summary>
/// Le bucket n'est pas configuré. La suppression est alors sautée, jamais
/// silencieusement réussie : un environnement mal configuré ne doit pas
/// ressembler à un environnement qui nettoie.
/// </summary>
NotConfigured,
/// <summary>Le bucket a répondu autre chose. L'appelant ne doit pas supprimer la ligne.</summary>
Failed
}
public interface IResourceBlobService
{
bool IsConfigured { get; }
Task<BlobDeleteOutcome> DeleteAsync(string storagePath);
}
/// <summary>
/// Suppression d'un blob dans le bucket, côté serveur.
///
/// Jusqu'ici seul manager-app supprimait, après avoir supprimé la ligne et en
/// avalant l'échec dans un print : un échec laissait un orphelin définitif, et
/// comme sa ligne n'existait plus, le quota ne le comptait pas — il occupait des
/// octets facturés que personne ne voyait.
///
/// Aucun secret nouveau : le credential est celui que Startup charge déjà pour les
/// notifications push (Firebase:CredentialsPath). Seul le nom du bucket s'ajoute.
/// </summary>
public class ResourceBlobService : IResourceBlobService
{
private readonly ILogger<ResourceBlobService> _logger;
private readonly string _bucket;
private readonly Lazy<StorageClient> _client;
public ResourceBlobService(IConfiguration configuration, ILogger<ResourceBlobService> logger)
{
_logger = logger;
_bucket = configuration["Firebase:StorageBucket"];
var credentialsPath = configuration["Firebase:CredentialsPath"];
// Lazy : ne pas lire le fichier de credentials au démarrage pour un service
// dont la plupart des requêtes n'ont pas besoin, et ne pas faire échouer le
// boot d'un environnement de développement qui n'a pas le fichier.
_client = new Lazy<StorageClient>(() =>
StorageClient.Create(GoogleCredential.FromFile(credentialsPath)));
IsConfigured = !string.IsNullOrEmpty(_bucket)
&& !string.IsNullOrEmpty(credentialsPath)
&& System.IO.File.Exists(credentialsPath);
}
public bool IsConfigured { get; }
public async Task<BlobDeleteOutcome> DeleteAsync(string storagePath)
{
if (string.IsNullOrEmpty(storagePath))
return BlobDeleteOutcome.AlreadyAbsent;
if (!IsConfigured)
return BlobDeleteOutcome.NotConfigured;
try
{
await _client.Value.DeleteObjectAsync(_bucket, storagePath);
return BlobDeleteOutcome.Deleted;
}
catch (Google.GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
return BlobDeleteOutcome.AlreadyAbsent;
}
catch (Exception ex)
{
_logger.LogError(ex, "Suppression du blob {StoragePath} en échec sur le bucket {Bucket}", storagePath, _bucket);
return BlobDeleteOutcome.Failed;
}
}
}
}

View File

@ -203,6 +203,10 @@ namespace ManagerService
});
}
services.AddSingleton<NotificationService>();
// Réutilise le credential Firebase ci-dessus ; seul Firebase:StorageBucket
// s'ajoute à la configuration. Non configuré, le service ne supprime rien
// et le dit — il ne fait jamais semblant d'avoir nettoyé.
services.AddSingleton<IResourceBlobService, ResourceBlobService>();
services.AddHttpClient();
services.AddScoped<AgendaSyncService>();
services.AddScoped<WeatherSyncService>();

View File

@ -42,7 +42,8 @@
"SearchTopK": 5
},
"Firebase": {
"CredentialsPath": "firebase-adminsdk.json"
"CredentialsPath": "firebase-adminsdk.json",
"StorageBucket": ""
},
"AppUrls": {
"ManagerApp": "https://manager.myinfomate.be",