Gestion des fichiers utilisateur et pièces jointes (demande/devis)
Espace de stockage personnel géré depuis PostIt, avec attachement par
référence (sans duplication) des fichiers à la demande du client
(NominativeServiceCommand) et au devis du fournisseur (Estimate).
Backend:
- UploadedFile.OwnerId (FK ApplicationUser, nullable) ; les téléversements
vers api/v1/fs créent/mettent à jour une ligne propriétaire.
- Tables de jointure EstimateAttachedFile et QueryAttachedFile (PK composite),
migration UserFileAttachments.
- EstimateApiController: GET/POST/DELETE estimate/{id}/attachments
(fournisseur uniquement, fichier devant appartenir au caller).
- FrontOfficeApiController: GET/POST/DELETE front/query/{id}/attachments
(client uniquement).
- FileSystemApiController: GET fs/file/{fileId} sert les octets depuis le
host Blogs (autorisation propriétaire ou partie au devis/demande via DB
partagée) — le host API ne gère que les métadonnées, contrainte des
conteneurs docker à disques distincts.
Client .NET:
- UserFilesApiClient (list/upload/delete/download) pointant vers BlogsApiUrl.
- DTOs UserDirectoryDto/FileEntryDto/AttachmentDto/FileReceivedDto/UploadFile.
- Méthodes d'attachement sur EstimateApiClient et FrontOfficeApiClient.
PostIt (Avalonia):
- Page « Mes fichiers » (naviguer/téléverser/télécharger/supprimer), servant
aussi de sélecteur pour l'attachement.
- Section pièces jointes dans l'édition du devis (fournisseur) et le détail
de la demande (client), avec ajout depuis My Files, détachement,
téléchargement.
- Enregistrement DI + mapping ViewLocator + bouton d'accès.
Tests:
- EstimateEditionPageViewModelTests mis à jour (nouveau ctor fsClient,
historique d'appels du stub pour les assertions post-envoi).
Nommage HTTP/méthodes en anglais, documentation en français.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
eb58fc8862
commit
70ab05522b
31 changed files with 6515 additions and 20 deletions
|
|
@ -8,10 +8,14 @@ namespace Yavsc.Blogs.Controllers
|
|||
{
|
||||
using Yavsc.Attributes.Validation;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Exceptions;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Abstract.Helpers;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
using Yavsc.ViewModels.UserFiles;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
[Authorize,Route(APIPrefix + "/fs")]
|
||||
|
|
@ -34,21 +38,55 @@ namespace Yavsc.Blogs.Controllers
|
|||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
// GET: api/v1/fs
|
||||
[HttpGet()]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return GetDir(null);
|
||||
}
|
||||
|
||||
// GET: api/v1/fs/{subdir}
|
||||
// Liste les fichiers du sous-répertoire personnel de l'utilisateur,
|
||||
// en enrichissant chaque entrée de l'identifiant de métadonnées
|
||||
// UploadedFile.Id quand une ligne existe (cf. POST ci-dessous).
|
||||
[HttpGet("{*subdir}")]
|
||||
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
|
||||
{
|
||||
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
||||
// _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}");
|
||||
var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir);
|
||||
var uid = User.GetUserId();
|
||||
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
|
||||
EnrichWithFileIds(files, uid, subdir);
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
// Reporte l'UploadedFile.Id (quand il existe) sur chaque entrée
|
||||
// retournée par la liste, pour que le client lourd puisse attacher
|
||||
// le fichier à un devis/demande par référence.
|
||||
void EnrichWithFileIds(UserDirectoryInfo dir, string ownerId, string subdir)
|
||||
{
|
||||
if (dir?.Files == null || dir.Files.Length == 0) return;
|
||||
var relPaths = dir.Files
|
||||
.Select(f => RelPath(subdir, f.Name))
|
||||
.ToHashSet();
|
||||
var byPath = dbContext.UploadedFiles
|
||||
.Where(u => u.OwnerId == ownerId && relPaths.Contains(u.Path))
|
||||
.ToDictionary(u => u.Path, u => u.Id);
|
||||
foreach (var f in dir.Files)
|
||||
{
|
||||
var rel = RelPath(subdir, f.Name);
|
||||
if (byPath.TryGetValue(rel, out var id)) f.Id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Chemin relatif au racine de l'espace perso de l'utilisateur,
|
||||
// tel que stocké dans UploadedFile.Path (sans segment utilisateur).
|
||||
static string RelPath(string subdir, string fileName)
|
||||
{
|
||||
// Normalize with '/' for cross-platform consistency in DB.
|
||||
if (string.IsNullOrWhiteSpace(subdir)) return fileName;
|
||||
return $"{subdir.TrimStart('/')}/{fileName}";
|
||||
}
|
||||
|
||||
[HttpPost("{*subdir}")]
|
||||
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
|
||||
{
|
||||
|
|
@ -78,7 +116,38 @@ namespace Yavsc.Blogs.Controllers
|
|||
foreach (var f in Request.Form.Files)
|
||||
{
|
||||
var item = user.ReceiveUserFile(destDir, f);
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
if (!item.QuotaOffense)
|
||||
{
|
||||
// Persiste une ligne de métadonnées UploadedFile,
|
||||
// propriétaire = l'utilisateur courant, chemin relatif
|
||||
// à son espace perso. Upsert sur (OwnerId, Path) pour
|
||||
// le re-téléversement (même fichier écrasé sur disque).
|
||||
var rel = RelPath(subdir, item.FileName);
|
||||
var existing = dbContext.UploadedFiles
|
||||
.FirstOrDefault(u => u.OwnerId == uid && u.Path == rel);
|
||||
if (existing == null)
|
||||
{
|
||||
var uploaded = new UploadedFile
|
||||
{
|
||||
OwnerId = uid,
|
||||
Path = rel,
|
||||
ContentType = f.ContentType,
|
||||
Length = f.Length
|
||||
};
|
||||
dbContext.UploadedFiles.Add(uploaded);
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
item.FileId = uploaded.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ContentType = f.ContentType;
|
||||
existing.Length = f.Length;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
item.FileId = existing.Id;
|
||||
}
|
||||
item.Length = f.Length;
|
||||
item.ContentType = f.ContentType;
|
||||
}
|
||||
received.Add(item);
|
||||
_logger.LogInformation($"Received '{item.FileName}'.");
|
||||
if (item.QuotaOffense)
|
||||
|
|
@ -88,6 +157,62 @@ namespace Yavsc.Blogs.Controllers
|
|||
return Ok(received);
|
||||
}
|
||||
|
||||
// GET: api/v1/fs/file/{fileId}
|
||||
// Télécharge une pièce jointe référencée par son UploadedFile.Id.
|
||||
// Autorise : le propriétaire du fichier, ou toute partie (client /
|
||||
// fournisseur) d'un devis ou d'une demande auquel ce fichier est
|
||||
// rattaché via EstimateAttachedFile / QueryAttachedFile. Les octets
|
||||
// sont lus depuis l'espace perso du propriétaire, sur le disque de
|
||||
// ce host Blogs (le host API ne possède pas ces octets).
|
||||
[HttpGet("file/{fileId:long}")]
|
||||
public IActionResult GetFile(long fileId)
|
||||
{
|
||||
var file = dbContext.UploadedFiles.FirstOrDefault(u => u.Id == fileId);
|
||||
if (file == null) return NotFound(new { error = "file not found" });
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!CanReadFile(file, uid))
|
||||
return Forbid();
|
||||
|
||||
var ownerUserName = dbContext.Users
|
||||
.Where(u => u.Id == file.OwnerId)
|
||||
.Select(u => u.UserName)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(ownerUserName))
|
||||
return NotFound(new { error = "owner not found" });
|
||||
|
||||
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||
var fi = new FileInfo(physicalPath);
|
||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||
|
||||
var contentType = string.IsNullOrWhiteSpace(file.ContentType)
|
||||
? "application/octet-stream" : file.ContentType;
|
||||
var stream = fi.OpenRead();
|
||||
return File(stream, contentType, fi.Name);
|
||||
}
|
||||
|
||||
// Le propriétaire peut toujours lire son fichier ; sinon, il faut
|
||||
// que le fichier soit rattaché à un devis ou une demande dont
|
||||
// l'appelant est partie (client ou fournisseur). Les admin sont
|
||||
// autorisés comme pour le reste du workflow.
|
||||
bool CanReadFile(UploadedFile file, string uid)
|
||||
{
|
||||
if (User.IsInRole(Constants.AdminGroupName)) return true;
|
||||
if (file.OwnerId == uid) return true;
|
||||
|
||||
if (dbContext.EstimateAttachedFiles.Any(a =>
|
||||
a.FileId == file.Id &&
|
||||
(a.Estimate.ClientId == uid || a.Estimate.OwnerId == uid)))
|
||||
return true;
|
||||
|
||||
if (dbContext.QueryAttachedFiles.Any(a =>
|
||||
a.FileId == file.Id &&
|
||||
(a.Command.ClientId == uid || a.Command.PerformerId == uid)))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Route("/api/fsc/addquota/{uname}/{len}")]
|
||||
[Authorize("AdministratorOnly")]
|
||||
public IActionResult AddQuota(string uname, int len)
|
||||
|
|
|
|||
Loading…
Reference in a new issue