Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend

- GeradoresService: cada tipo passa a ser um IGenerator (NIF, NIPC, NISS, CC,
  passaporte, IBAN, NIB, cartões, referência MB, pessoa, telefone, código postal,
  email, matrícula, EAN, UUID) registado em GeneratorRegistry; remove DAL/EF
- GeradoresWS: minimal APIs em /api/v1 com OpenAPI + Scalar e health check
- UnitTest: testes de ida e volta, valores conhecidos, detecção e API
- geradoresfe: UI construída a partir dos metadados da API, validador livre,
  exportação CSV/JSON/TXT, nginx com API_UPSTREAM configurável
- Docker, docker-compose e Jenkinsfile actualizados

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:51:39 +01:00
parent 0f764b1239
commit d7ea8a9b9e
119 changed files with 7764 additions and 2502 deletions

View File

@@ -0,0 +1,44 @@
using System.Text.RegularExpressions;
using GeradoresService.Core;
using GeradoresService.Data;
namespace GeradoresService.Generators.Contactos;
/// <summary>Código postal português (NNNN-NNN). Apenas formato.</summary>
public sealed partial class CodigoPostalGenerator : GeneratorBase
{
public override string Id => "codigo-postal";
public override string Name => "Código postal";
public override string Category => Categories.Contactos;
public override string Description => "Código postal português (CP4-CP3) com localidade.";
public override bool HasChecksum => false;
public override GeneratedItem Generate(GeneratorContext context)
{
var (cp, locality, district) = Random(context);
return new GeneratedItem(cp, new Dictionary<string, string>
{
["Código postal"] = cp,
["Localidade"] = locality,
["Distrito"] = district,
});
}
public static (string Cp, string Locality, string District) Random(GeneratorContext context)
{
var l = context.Pick(PtData.Localities);
return ($"{l.Cp4}-{1 + context.Random.Next(999):D3}", l.Locality, l.District);
}
public override ValidationResult Validate(string value)
{
var m = Pattern().Match(value.Trim());
if (!m.Success)
return ValidationResult.WrongFormat("Formato esperado: NNNN-NNN (ex.: 1000-001).");
var cp = $"{m.Groups[1].Value}-{m.Groups[2].Value}";
return ValidationResult.Valid(cp, "Formato de código postal válido.");
}
[GeneratedRegex(@"^([1-9]\d{3})[-\s]?(\d{3})$")]
private static partial Regex Pattern();
}

View File

@@ -0,0 +1,56 @@
using System.Globalization;
using System.Net.Mail;
using System.Text;
using GeradoresService.Core;
using GeradoresService.Data;
namespace GeradoresService.Generators.Contactos;
public sealed class EmailGenerator : GeneratorBase
{
public override string Id => "email";
public override string Name => "Email";
public override string Category => Categories.Contactos;
public override string Description => "Endereço de email fictício. Por omissão usa domínios reservados para testes (example.com).";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("dominio", "Domínio", "teste",
("teste", "Reservado para testes (example.com)"),
("realista", "Realista (gmail.com, sapo.pt…)")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var first = context.Random.Next(2) == 0 ? context.Pick(PtData.MaleNames) : context.Pick(PtData.FemaleNames);
return new GeneratedItem(Build(first, context.Pick(PtData.Surnames), context, context.Option(Options[0]) == "realista"));
}
public static string Build(string firstName, string surname, GeneratorContext context, bool realistic = false)
{
var domains = realistic ? PtData.RealisticEmailDomains : PtData.SafeEmailDomains;
var separator = context.Pick([".", "_", ""]);
var suffix = context.Random.Next(3) == 0 ? context.Random.Next(1, 100).ToString() : "";
return $"{Slug(firstName)}{separator}{Slug(surname)}{suffix}@{context.Pick(domains)}";
}
private static string Slug(string s)
{
var sb = new StringBuilder();
foreach (var c in s.Normalize(NormalizationForm.FormD))
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark && char.IsLetterOrDigit(c))
sb.Append(char.ToLowerInvariant(c));
return sb.ToString();
}
public override ValidationResult Validate(string value)
{
var v = value.Trim();
var at = v.LastIndexOf('@');
if (at <= 0 || at == v.Length - 1 || !v[(at + 1)..].Contains('.') || v.Any(char.IsWhiteSpace)
|| !MailAddress.TryCreate(v, out var address) || address.Address != v)
return ValidationResult.WrongFormat("Não é um endereço de email válido.");
return ValidationResult.Valid(v.ToLowerInvariant(), "Formato de email válido.");
}
}

View File

@@ -0,0 +1,69 @@
using GeradoresService.Core;
using GeradoresService.Data;
using GeradoresService.Generators.Financeiro;
using GeradoresService.Generators.Identificacao;
namespace GeradoresService.Generators.Contactos;
/// <summary>Pessoa fictícia completa, com todos os identificadores válidos.</summary>
public sealed class PessoaGenerator : GeneratorBase
{
public override string Id => "pessoa";
public override string Name => "Pessoa fictícia";
public override string Category => Categories.Contactos;
public override string Description => "Nome, data de nascimento, NIF, NISS, CC, contactos, morada e IBAN — tudo consistente e válido.";
public override bool CanValidate => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("genero", "Género", "qualquer", ("qualquer", "Qualquer"), ("f", "Feminino"), ("m", "Masculino")),
Option("idade", "Idade", "adulto", ("adulto", "Adulto (18–80)"), ("menor", "Menor (0–17)"), ("qualquer", "Qualquer (0–90)")),
];
public override ValidationResult Validate(string value) =>
ValidationResult.WrongFormat("Este gerador não tem validação.");
public override GeneratedItem Generate(GeneratorContext context)
{
var genero = context.Option(Options[0]);
if (genero == "qualquer")
genero = context.Random.Next(2) == 0 ? "f" : "m";
var names = genero == "f" ? PtData.FemaleNames : PtData.MaleNames;
var first = context.Pick(names);
var middle = context.Random.Next(3) == 0 ? " " + context.Pick(names.Where(n => n != first).ToArray()) : "";
var surname1 = context.Pick(PtData.Surnames);
var surname2 = context.Pick(PtData.Surnames.Where(s => s != surname1).ToArray());
var fullName = $"{first}{middle} {surname1} {surname2}";
var (minAge, maxAge) = context.Option(Options[1]) switch
{
"menor" => (0, 17),
"qualquer" => (0, 90),
_ => (18, 80),
};
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var birth = today.AddYears(-maxAge - 1).AddDays(1 + context.Random.Next((maxAge - minAge + 1) * 365));
var (cp, locality, _) = CodigoPostalGenerator.Random(context);
var street = $"{context.Pick(PtData.StreetTypes)} {context.Pick(PtData.StreetNames)}, {1 + context.Random.Next(300)}";
var nibContext = new GeneratorContext(context.Random, new Dictionary<string, string?>());
var fields = new Dictionary<string, string>
{
["Nome"] = fullName,
["Género"] = genero == "f" ? "Feminino" : "Masculino",
["Data de nascimento"] = birth.ToString("yyyy-MM-dd"),
["NIF"] = NifGenerator.Build(context.Pick(["1", "2", "3"]), context),
["NISS"] = new NissGenerator().Generate(nibContext).Value,
["Cartão de Cidadão"] = new CartaoCidadaoGenerator().Generate(nibContext).Value,
["Telemóvel"] = new TelefoneGenerator().Generate(nibContext).Value,
["Email"] = EmailGenerator.Build(first, surname2, context),
["Morada"] = street,
["Código postal"] = cp,
["Localidade"] = locality,
["IBAN"] = new IbanGenerator().Generate(nibContext).Value,
};
return new GeneratedItem(fullName, fields);
}
}

View File

@@ -0,0 +1,67 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Contactos;
/// <summary>Números de telefone portugueses (Plano Nacional de Numeração). Apenas formato.</summary>
public sealed class TelefoneGenerator : GeneratorBase
{
private static readonly string[] MobilePrefixes = ["91", "92", "93", "96"];
public override string Id => "telefone";
public override string Name => "Telefone";
public override string Category => Categories.Contactos;
public override string Description => "Telemóvel ou telefone fixo português.";
public override bool HasChecksum => false;
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("tipo", "Tipo", "movel", ("movel", "Telemóvel"), ("fixo", "Fixo"), ("qualquer", "Qualquer")),
Option("formato", "Formato", "nacional",
("nacional", "912 345 678"), ("internacional", "+351 912 345 678"), ("compacto", "912345678")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var tipo = context.Option(Options[0]);
if (tipo == "qualquer")
tipo = context.Random.Next(2) == 0 ? "movel" : "fixo";
var number = tipo == "movel"
? context.Pick(MobilePrefixes) + context.Digits(7)
: "2" + (1 + context.Random.Next(9)) + context.Digits(7);
return new GeneratedItem(Format(number, context.Option(Options[1])));
}
private static string Format(string n, string formato) => formato switch
{
"compacto" => n,
"internacional" => $"+351 {n[..3]} {n[3..6]} {n[6..]}",
_ => $"{n[..3]} {n[3..6]} {n[6..]}",
};
public override ValidationResult Validate(string value)
{
var n = Checksums.Clean(value).Replace("(", "").Replace(")", "");
if (n.StartsWith("+351")) n = n[4..];
else if (n.StartsWith("00351")) n = n[5..];
if (n.Length != 9 || !Checksums.AllDigits(n))
return ValidationResult.WrongFormat("Um número português tem 9 dígitos (opcionalmente com +351).");
var tipo = n switch
{
['9', '1' or '2' or '3' or '6', ..] => "telemóvel",
['2', ..] => "fixo",
['3', '0', ..] => "VoIP nómada",
['8', '0', '0', ..] => "número verde (gratuito)",
['8', '0', '8', ..] => "número azul (custo partilhado)",
['7', '0', '7', ..] or ['7', '6', '0', ..] => "número de valor acrescentado",
_ => null,
};
return tipo is null
? ValidationResult.WrongFormat("O prefixo não corresponde a um número português conhecido.")
: ValidationResult.Valid(Format(n, "internacional"), $"Número português válido ({tipo}).", [$"Tipo: {tipo}"]);
}
}