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,82 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// Números de cartão de pagamento válidos pelo algoritmo de Luhn (apenas para testes).
/// </summary>
public sealed class CartaoCreditoGenerator : GeneratorBase
{
private sealed record Brand(string Key, string Name, int Length, Func<Random, string> Prefix, Func<string, bool> Matches);
private static readonly Brand[] Brands =
[
new("visa", "Visa", 16, _ => "4", n => n.StartsWith('4') && n.Length is 13 or 16 or 19),
new("mastercard", "Mastercard", 16,
r => r.Next(2) == 0 ? (51 + r.Next(5)).ToString() : (2221 + r.Next(500)).ToString(),
n => n.Length == 16 && (InRange(n, 2, 51, 55) || InRange(n, 4, 2221, 2720))),
new("amex", "American Express", 15, r => r.Next(2) == 0 ? "34" : "37",
n => n.Length == 15 && (n.StartsWith("34") || n.StartsWith("37"))),
];
public override string Id => "cartao-credito";
public override string Name => "Cartão de crédito";
public override string Category => Categories.Financeiro;
public override string Description => "Número de cartão válido pelo algoritmo de Luhn, com validade e CVV. Apenas para testes.";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("rede", "Rede", "visa", [.. Brands.Select(b => (b.Key, b.Name)), ("qualquer", "Qualquer")]),
Option("formato", "Formato", "espacos", ("espacos", "4111 1111 1111 1111"), ("compacto", "4111111111111111")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var key = context.Option(Options[0]);
var brand = key == "qualquer" ? context.Pick(Brands) : Brands.Single(b => b.Key == key);
var body = brand.Prefix(context.Random);
body += context.Digits(brand.Length - 1 - body.Length);
var number = body + Checksums.LuhnCheckDigit(body);
var expiry = DateTime.UtcNow.AddMonths(context.Random.Next(6, 60));
var cvv = context.Digits(brand.Key == "amex" ? 4 : 3);
var display = context.Option(Options[1]) == "compacto" ? number : Format(number);
return new GeneratedItem(display, new Dictionary<string, string>
{
["Número"] = display,
["Rede"] = brand.Name,
["Validade"] = expiry.ToString("MM/yy"),
["CVV"] = cvv,
});
}
private static string Format(string number) => number.Length == 15
? $"{number[..4]} {number[4..10]} {number[10..]}"
: string.Join(' ', number.Chunk(4).Select(c => new string(c)));
private static bool InRange(string n, int digits, int min, int max) =>
int.TryParse(n.AsSpan(0, digits), out var p) && p >= min && p <= max;
public override ValidationResult Validate(string value)
{
var number = Checksums.Clean(value);
if (number.Length is < 12 or > 19 || !Checksums.AllDigits(number))
return ValidationResult.WrongFormat("Um número de cartão tem entre 12 e 19 dígitos.");
var brand = Brands.FirstOrDefault(b => b.Matches(number));
var details = new List<string>
{
$"Rede: {brand?.Name ?? "desconhecida"}",
"Controlo: algoritmo de Luhn",
};
if (!Checksums.LuhnValid(number))
{
var expected = Checksums.LuhnCheckDigit(number[..^1]);
return ValidationResult.BadChecksum($"Falha no algoritmo de Luhn: último dígito devia ser {expected}.", details);
}
return ValidationResult.Valid(Format(number), $"Número de cartão válido{(brand is null ? "" : $" ({brand.Name})")}.", details);
}
}

View File

@@ -0,0 +1,75 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// IBAN português: PT + 2 dígitos de controlo ISO 7064 (MOD 97-10) + NIB.
/// A validação aceita IBAN de qualquer país (verifica comprimento conhecido e MOD 97).
/// </summary>
public sealed class IbanGenerator : NibGenerator
{
private static readonly Dictionary<string, int> Lengths = new()
{
["PT"] = 25, ["ES"] = 24, ["FR"] = 27, ["DE"] = 22, ["IT"] = 27, ["GB"] = 22, ["IE"] = 22,
["NL"] = 18, ["BE"] = 16, ["LU"] = 20, ["CH"] = 21, ["AT"] = 20, ["DK"] = 18, ["FI"] = 18,
["SE"] = 24, ["NO"] = 15, ["PL"] = 28, ["CZ"] = 24, ["GR"] = 27, ["HR"] = 21, ["RO"] = 24,
["MT"] = 31, ["CY"] = 28, ["SK"] = 24, ["SI"] = 19, ["HU"] = 28, ["BG"] = 22, ["EE"] = 20,
["LT"] = 20, ["LV"] = 21, ["IS"] = 26, ["LI"] = 21, ["MC"] = 27, ["SM"] = 27, ["BR"] = 29,
};
public override string Id => "iban";
public override string Name => "IBAN";
public override string Description => "IBAN português (PT50 + NIB). Valida IBAN de qualquer país.";
public override IReadOnlyList<GeneratorOption> Options =>
[
.. base.Options,
Option("formato", "Formato", "espacos", ("espacos", "PT50 0000 0000 …"), ("compacto", "PT500000…")),
];
public override GeneratedItem Generate(GeneratorContext context)
{
var iban = FromBban("PT", BuildNib(context));
return new GeneratedItem(context.Option(Options[1]) == "compacto" ? iban : Format(iban));
}
public static string FromBban(string country, string bban)
{
var check = 98 - Checksums.Mod97(Checksums.LettersToDigits(bban + country) + "00");
return $"{country}{check:D2}{bban}";
}
public static string Format(string iban) =>
string.Join(' ', iban.Chunk(4).Select(c => new string(c)));
public override ValidationResult Validate(string value)
{
var iban = Checksums.Clean(value).ToUpperInvariant();
if (iban.Length < 15 || iban.Length > 34
|| !char.IsAsciiLetter(iban[0]) || !char.IsAsciiLetter(iban[1])
|| !char.IsAsciiDigit(iban[2]) || !char.IsAsciiDigit(iban[3])
|| !iban.All(char.IsAsciiLetterOrDigit))
return ValidationResult.WrongFormat("Um IBAN tem código de país (2 letras), 2 dígitos de controlo e o BBAN.");
var country = iban[..2];
if (Lengths.TryGetValue(country, out var expectedLength) && iban.Length != expectedLength)
return ValidationResult.WrongFormat($"Um IBAN {country} tem {expectedLength} caracteres (tem {iban.Length}).");
var rearranged = Checksums.LettersToDigits(iban[4..] + iban[..4]);
if (Checksums.Mod97(rearranged) != 1)
{
var expected = FromBban(country, iban[4..])[2..4];
return ValidationResult.BadChecksum(
$"Dígitos de controlo do IBAN inválidos: esperado {expected}, encontrado {iban[2..4]}.",
["Controlo: ISO 7064 MOD 97-10"]);
}
if (country == "PT")
{
var nibResult = ValidateNib(iban[4..], "IBAN");
return nibResult with { Normalized = nibResult.IsValid ? Format(iban) : null };
}
return ValidationResult.Valid(Format(iban), $"IBAN válido ({country}).", ["Controlo: ISO 7064 MOD 97-10"]);
}
}

View File

@@ -0,0 +1,75 @@
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// NIB: banco (4) + balcão (4) + conta (11) + controlo (2). Controlo = 98 - (NIB19 × 100 mod 97).
/// </summary>
public class NibGenerator : GeneratorBase
{
public static readonly IReadOnlyList<(string Code, string Name)> Banks =
[
("0007", "Novo Banco"),
("0010", "BPI"),
("0018", "Santander Totta"),
("0023", "Banco ActivoBank"),
("0033", "Millennium BCP"),
("0035", "Caixa Geral de Depósitos"),
("0036", "Banco Montepio"),
("0045", "Crédito Agrícola"),
("0079", "Banco BIC"),
("0193", "Banco CTT"),
("0269", "Bankinter"),
];
public override string Id => "nib";
public override string Name => "NIB";
public override string Category => Categories.Financeiro;
public override string Description => "Número de Identificação Bancária (21 dígitos).";
public override IReadOnlyList<GeneratorOption> Options =>
[
Option("banco", "Banco", "qualquer",
[("qualquer", "Qualquer"), .. Banks.Select(b => (b.Code, $"{b.Code} — {b.Name}"))]),
];
public override GeneratedItem Generate(GeneratorContext context) => new(BuildNib(context));
protected string BuildNib(GeneratorContext context)
{
var bank = context.Option(Options[0]);
if (bank == "qualquer")
bank = context.Pick(Banks).Code;
var body = bank + context.Digits(4) + context.Digits(11);
return body + NibCheck(body);
}
public static string NibCheck(string first19) =>
(98 - Checksums.Mod97(first19 + "00")).ToString("D2");
public static string? BankName(string nib) =>
Banks.FirstOrDefault(b => nib.StartsWith(b.Code)).Name;
public override ValidationResult Validate(string value)
{
var nib = Checksums.Clean(value);
if (nib.Length != 21 || !Checksums.AllDigits(nib))
return ValidationResult.WrongFormat("Um NIB tem 21 dígitos.");
return ValidateNib(nib, "NIB");
}
public static ValidationResult ValidateNib(string nib, string label)
{
var expected = NibCheck(nib[..19]);
var details = new List<string>
{
$"Banco: {nib[..4]}{(BankName(nib) is { } n ? $" ({n})" : "")}",
$"Balcão: {nib[4..8]}",
$"Conta: {nib[8..19]}",
$"Dígitos de controlo esperados: {expected} (módulo 97)",
};
return nib[19..] == expected
? ValidationResult.Valid(nib, $"{label} válido.", details)
: ValidationResult.BadChecksum($"Dígitos de controlo do NIB inválidos: esperado {expected}, encontrado {nib[19..]}.", details);
}
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using System.Text.RegularExpressions;
using GeradoresService.Core;
namespace GeradoresService.Generators.Financeiro;
/// <summary>
/// Referência Multibanco: entidade (5) + referência (9) + montante.
/// Os 2 últimos dígitos da referência são controlo: 98 - (Σ dígito × peso mod 97),
/// calculado sobre entidade(5) + referência(7) + montante em cêntimos(8).
/// </summary>
public sealed partial class ReferenciaMultibancoGenerator : GeneratorBase
{
private static readonly int[] Weights = [51, 73, 17, 89, 38, 62, 45, 53, 15, 50, 5, 49, 34, 81, 76, 27, 90, 9, 30, 3];
public override string Id => "multibanco";
public override string Name => "Referência Multibanco";
public override string Category => Categories.Financeiro;
public override string Description => "Entidade, referência e montante com dígitos de controlo válidos.";
public override GeneratedItem Generate(GeneratorContext context)
{
var entity = (10000 + context.Random.Next(90000)).ToString();
var cents = 100 + context.Random.Next(50000);
var reference = context.Digits(7);
reference += CheckDigits(entity, reference, cents);
var amount = (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
return new GeneratedItem($"{entity} {FormatReference(reference)} {amount}", new Dictionary<string, string>
{
["Entidade"] = entity,
["Referência"] = FormatReference(reference),
["Montante"] = $"{amount} €",
});
}
public static string CheckDigits(string entity, string reference7, long cents)
{
var chk = entity + reference7 + cents.ToString("D8");
var sum = 0;
for (var i = 0; i < 20; i++)
sum += (chk[i] - '0') * Weights[i];
return (98 - sum % 97).ToString("D2");
}
private static string FormatReference(string r) => $"{r[..3]} {r[3..6]} {r[6..]}";
public override ValidationResult Validate(string value)
{
var m = Pattern().Match(value.Trim());
if (!m.Success)
return ValidationResult.WrongFormat("Formato esperado: entidade (5 dígitos), referência (9 dígitos) e montante, ex.: 12345 123 456 789 10.00");
var entity = m.Groups["ent"].Value;
var reference = Checksums.Clean(m.Groups["ref"].Value);
var amount = decimal.Parse(m.Groups["val"].Value.Replace(',', '.'), CultureInfo.InvariantCulture);
var cents = (long)Math.Round(amount * 100);
if (cents > 99_999_999)
return ValidationResult.WrongFormat("Montante demasiado elevado.");
var expected = CheckDigits(entity, reference[..7], cents);
var details = new List<string>
{
$"Entidade: {entity}",
$"Montante: {amount.ToString("0.00", CultureInfo.InvariantCulture)} €",
$"Dígitos de controlo esperados: {expected}",
};
return reference[7..] == expected
? ValidationResult.Valid($"{entity} {FormatReference(reference)} {amount.ToString("0.00", CultureInfo.InvariantCulture)}", "Referência Multibanco válida.", details)
: ValidationResult.BadChecksum($"Dígitos de controlo inválidos: esperado {expected}, encontrado {reference[7..]}.", details);
}
[GeneratedRegex(@"^(?<ent>\d{5})[\s|;]+(?<ref>\d{3}\s?\d{3}\s?\d{3})[\s|;]+(?<val>\d{1,6}(?:[.,]\d{1,2})?)\s*€?$")]
private static partial Regex Pattern();
}