using System.Text.Json; using System.Text.Json.Nodes; using FileService.Infrastructure; using FileService.Infrastructure.Storage; using IM.InitCommon; using IM.InitCommon.Management; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace FileService.WebApi.Controllers; [ApiController, Route("internal/management/storage")] public class ManagementController(FileDbContext db, IOptionsSnapshot current, IConfiguration config) : ControllerBase { [HttpGet("summary")] public async Task Summary(string? provider, string? type, DateTimeOffset? from, DateTimeOffset? to) { var files = db.Files.AsNoTracking().AsQueryable(); var tasks = db.Tasks.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(provider)) { files = files.Where(x => x.StorageLocation.StorageProvider == provider); tasks = tasks.Where(x => x.StorageLocation.StorageProvider == provider); } if (!string.IsNullOrWhiteSpace(type)) { files = files.Where(x => x.ContentType.Value == type); tasks = tasks.Where(x => x.ContentType.Value == type); } if (from.HasValue) { files = files.Where(x => x.CreationTime >= from); tasks = tasks.Where(x => x.CreationTime >= from); } if (to.HasValue) { files = files.Where(x => x.CreationTime < to); tasks = tasks.Where(x => x.CreationTime < to); } var totals = await files.GroupBy(x => new { provider = x.StorageLocation.StorageProvider, type = x.ContentType.Value }).Select(g => new { g.Key.provider, g.Key.type, count = g.Count(), bytes = g.Sum(x => x.FileSize) }).ToListAsync(); var states = await tasks.GroupBy(x => x.State).Select(g => new { state = g.Key.ToString(), count = g.Count() }).ToListAsync(); var capacities = current.Value.Providers.Select(x => { long? total = null, available = null; string status = "未提供"; if (x.Value.ProviderType == StorageProviderType.Local) try { var drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(x.Value.LocalRootPath!))!); total = drive.TotalSize; available = drive.AvailableFreeSpace; status = "可用"; } catch { status = "不可用"; } return new { provider = x.Key, total, available, status }; }).ToArray(); return new { totals, tasks = states, capacities, checkedAt = DateTime.UtcNow }; } [HttpPost("validate")] public async Task Validate(InfrastructureEnvelope input) { var next = Parse(input); foreach (var (code, old) in current.Value.Providers) { var referenced = await db.Files.IgnoreQueryFilters().AnyAsync(x => x.StorageLocation.StorageProvider == code) || await db.Tasks.IgnoreQueryFilters().AnyAsync(x => x.StorageLocation.StorageProvider == code); if (!referenced) continue; if (!next.Providers.TryGetValue(code, out var p) || !p.Enabled || p.ProviderType != old.ProviderType || p.Bucket != old.Bucket || p.PublicBucket != old.PublicBucket || p.Endpoint != old.Endpoint || p.Region != old.Region || p.LocalRootPath != old.LocalRootPath || p.PublicBaseUrl != old.PublicBaseUrl) throw new IM.DomainCommons.DomainException("已有文件或上传任务引用该提供商,不能移除或更改定位参数"); } return new { valid = true }; } [HttpPost("test")] public async Task Test(InfrastructureEnvelope input, CancellationToken ct) { await Validate(input); var next = Parse(input); foreach (var (code, provider) in next.Providers.Where(x => x.Value.Enabled)) { if (provider.ProviderType == StorageProviderType.Local) { var path = LocalStorageAdapter.SafePath(provider.LocalRootPath!, "im-admin-connectivity", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.GetDirectoryName(path)!); try { await System.IO.File.WriteAllTextAsync(path, "IM connection test", ct); await System.IO.File.ReadAllTextAsync(path, ct); } finally { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } } else { using var adapter = new S3StorageAdapter(code, provider); await adapter.Test(ct); } } return new { tested = true }; } StorageOptions Parse(InfrastructureEnvelope input) { var next = input.Value.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new IM.DomainCommons.DomainException("配置格式错误"); if (next.Providers is null || !next.Providers.TryGetValue(next.DefaultProviderCode, out var chosen) || !chosen.Enabled) throw new IM.DomainCommons.DomainException("默认提供商无效"); var secrets = JsonNode.Parse(string.IsNullOrEmpty(input.Secret) ? "{}" : input.Secret)!; foreach (var (code, p) in next.Providers) { if (p.ProviderCode != code || string.IsNullOrWhiteSpace(p.Bucket) || p.Bucket.IndexOfAny(['/', '\\']) >= 0 || p.PublicBucket?.IndexOfAny(['/', '\\']) >= 0 || p.Bucket is "." or ".." || p.PublicBucket is "." or "..") throw new IM.DomainCommons.DomainException("提供商或存储桶名称无效"); if (p.DefaultPartSizeBytes < p.MinPartSizeBytes || p.MinPartSizeBytes < 1 || p.MaxPartCount < 1 || p.MaxObjectSizeBytes < 1) throw new IM.DomainCommons.DomainException("分片或容量限制无效"); if (p.ProviderType == StorageProviderType.Local) { if (code != "Local" || string.IsNullOrWhiteSpace(p.LocalRootPath)) throw new IM.DomainCommons.DomainException("本地提供商编码必须为 Local"); var root = Path.GetFullPath(p.LocalRootPath); var allowed = config.GetSection("Management:AllowedStorageRoots").Get() ?? []; if (!allowed.Any(x => { var path = Path.GetFullPath(x).TrimEnd(Path.DirectorySeparatorChar); return root == path || root.StartsWith(path + Path.DirectorySeparatorChar, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); })) throw new IM.DomainCommons.DomainException("本地目录未被部署允许"); LocalStorageAdapter.SafePath(root, "im-admin-connectivity", "check"); } else { if (p.ProviderType is not StorageProviderType.AwsS3 and not StorageProviderType.Minio || !Uri.TryCreate(p.Endpoint, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(uri.UserInfo)) throw new IM.DomainCommons.DomainException("不支持的存储端点"); var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get() ?? []; if (!allowed.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) throw new IM.DomainCommons.DomainException("存储主机未被部署允许"); p.AccessKeyId = secrets[code]?["accessKeyId"]?.GetValue(); p.AccessKeySecret = secrets[code]?["accessKeySecret"]?.GetValue(); if (string.IsNullOrWhiteSpace(p.AccessKeyId) || string.IsNullOrWhiteSpace(p.AccessKeySecret)) throw new IM.DomainCommons.DomainException("缺少存储凭据"); } } return next; } }