Escrevendo um esquema bancário simples: como devo manter meus saldos sincronizados com o histórico de transações?


57

Estou escrevendo o esquema para um banco de dados simples do banco. Aqui estão as especificações básicas:

  • O banco de dados armazenará transações contra um usuário e uma moeda.
  • Cada usuário tem um saldo por moeda; portanto, cada saldo é simplesmente a soma de todas as transações com um determinado usuário e moeda.
  • Um saldo não pode ser negativo.

O aplicativo do banco se comunicará com seu banco de dados exclusivamente por meio de procedimentos armazenados.

Espero que esse banco de dados aceite centenas de milhares de novas transações por dia, além de equilibrar as consultas em uma ordem de magnitude superior. Para atender saldos muito rapidamente, preciso pré-agregá-los. Ao mesmo tempo, preciso garantir que um saldo nunca contradiga seu histórico de transações.

Minhas opções são:

  1. Tenha uma balancestabela separada e siga um destes procedimentos:

    1. Aplique transações às tabelas transactionse balances. Use a TRANSACTIONlógica na minha camada de procedimento armazenado para garantir que os saldos e as transações estejam sempre sincronizados. (Suportado por Jack .)

    2. Aplique transações à transactionstabela e tenha um gatilho que atualize a balancestabela para mim com o valor da transação.

    3. Aplique transações à balancestabela e tenha um gatilho que adicione uma nova entrada na transactionstabela para mim com o valor da transação.

    Eu tenho que confiar em abordagens baseadas em segurança para garantir que nenhuma alteração possa ser feita fora dos procedimentos armazenados. Caso contrário, por exemplo, algum processo poderia inserir diretamente uma transação na transactionstabela e, no esquema, 1.3o saldo relevante estaria fora de sincronia.

  2. Tenha uma balancesexibição indexada que agregue as transações adequadamente. Os saldos são garantidos pelo mecanismo de armazenamento para permanecer sincronizados com suas transações, portanto, não preciso depender de abordagens baseadas em segurança para garantir isso. Por outro lado, não posso mais impor saldos não negativos, pois as visualizações - mesmo as visualizações indexadas - não podem ter CHECKrestrições. (Suportado por Denny .)

  3. Tenha apenas uma transactionstabela, mas com uma coluna adicional para armazenar o saldo efetivo logo após a transação ser executada. Assim, o último registro de transação para um usuário e moeda também contém seu saldo atual. (Sugerido abaixo por Andrew ; variante proposta por garik .)

Quando lidei com esse problema, li essas duas discussões e decidi pela opção 2. Para referência, você pode ver uma implementação básica aqui .

  • Você projetou ou gerencia um banco de dados como este com um perfil de alta carga? Qual foi a sua solução para esse problema?

  • Você acha que eu fiz a escolha certa para o design? Há algo que eu deva ter em mente?

    Por exemplo, eu sei que alterações de esquema na transactionstabela exigirão a reconstrução da balancesexibição. Mesmo que eu esteja arquivando transações para manter o banco de dados pequeno (por exemplo, movendo-as para outro lugar e substituindo-as por transações de resumo), ter que reconstruir a exibição de dezenas de milhões de transações com cada atualização de esquema provavelmente significará significativamente mais tempo de inatividade por implantação.

  • Se a exibição indexada é o caminho a seguir, como posso garantir que nenhum saldo seja negativo?


Arquivando transações:

Deixe-me detalhar um pouco as transações de arquivamento e as "transações de resumo" que mencionei acima. Primeiro, o arquivamento regular será uma necessidade em um sistema de alta carga como esse. Desejo manter a consistência entre os saldos e o histórico de transações, permitindo que as transações antigas sejam movidas para outro lugar. Para fazer isso, substituirei cada lote de transações arquivadas por um resumo de seus valores por usuário e moeda.

Então, por exemplo, esta lista de transações:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1       10.60             0
      3              1      -55.00             0
      3              1      -12.12             0

é arquivado e substituído por este:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1      -56.52             1

Dessa forma, um saldo com transações arquivadas mantém um histórico de transações completo e consistente.


11
Se você escolher a opção 2 (que eu acho mais limpa), consulte pgcon.org/2008/schedule/attachments/… como implementar "visualizações materializadas" de maneira eficiente. Para a opção 1, o capítulo 11 da Matemática Aplicada de Haan e Koppelaars para Profissionais de Banco de Dados (não se preocupe com o título) seria útil para ter uma idéia de como implementar "restrições de transição" com eficiência. O primeiro link é para o PostgreSQL e o segundo para o Oracle, mas as técnicas devem funcionar para qualquer sistema de banco de dados razoável.
JP

Em teoria, você quer fazer o # 3. A maneira correta de executar um "saldo em execução" é atribuir um saldo a cada transação. Certifique-se de poder solicitar as transações definitivamente com um ID de série (preferencial) ou carimbo de data e hora. Você realmente não deve "calcular" um saldo corrente.
pbreitenbach

Respostas:


15

Não estou familiarizado com contabilidade, mas resolvi alguns problemas semelhantes em ambientes do tipo inventário. Eu armazeno os totais em execução na mesma linha da transação. Estou usando restrições, para que meus dados nunca estejam errados, mesmo sob alta simultaneidade. Escrevi a seguinte solução em 2009 :

O cálculo dos totais em execução é notoriamente lento, seja você com um cursor ou com uma junção triangular. É muito tentador desnormalizar, armazenar totais em execução em uma coluna, especialmente se você a selecionar com frequência. No entanto, como sempre, quando você desnormaliza, precisa garantir a integridade de seus dados desnormalizados. Felizmente, você pode garantir a integridade dos totais em execução com restrições - desde que todas as suas restrições sejam confiáveis, todos os totais em execução estejam corretos. Dessa forma, você pode facilmente garantir que o saldo atual (totais em execução) nunca seja negativo - a imposição de outros métodos também pode ser muito lenta. O script a seguir demonstra a técnica.

CREATE TABLE Data.Inventory(InventoryID INT NOT NULL IDENTITY,
  ItemID INT NOT NULL,
  ChangeDate DATETIME NOT NULL,
  ChangeQty INT NOT NULL,
  TotalQty INT NOT NULL,
  PreviousChangeDate DATETIME NULL,
  PreviousTotalQty INT NULL,
  CONSTRAINT PK_Inventory PRIMARY KEY(ItemID, ChangeDate),
  CONSTRAINT UNQ_Inventory UNIQUE(ItemID, ChangeDate, TotalQty),
  CONSTRAINT UNQ_Inventory_Previous_Columns 
     UNIQUE(ItemID, PreviousChangeDate, PreviousTotalQty),
  CONSTRAINT FK_Inventory_Self FOREIGN KEY(ItemID, PreviousChangeDate, PreviousTotalQty)
    REFERENCES Data.Inventory(ItemID, ChangeDate, TotalQty),
  CONSTRAINT CHK_Inventory_Valid_TotalQty CHECK(
         TotalQty >= 0 
     AND (TotalQty = COALESCE(PreviousTotalQty, 0) + ChangeQty)
  ),
  CONSTRAINT CHK_Inventory_Valid_Dates_Sequence CHECK(PreviousChangeDate < ChangeDate),
  CONSTRAINT CHK_Inventory_Valid_Previous_Columns CHECK(
        (PreviousChangeDate IS NULL AND PreviousTotalQty IS NULL)
     OR (PreviousChangeDate IS NOT NULL AND PreviousTotalQty IS NOT NULL)
  )
);

-- beginning of inventory for item 1
INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)
VALUES(1, '20090101', 10, 10, NULL, NULL);

-- cannot begin the inventory for the second time for the same item 1
INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)
VALUES(1, '20090102', 10, 10, NULL, NULL);


Msg 2627, Level 14, State 1, Line 10

Violation of UNIQUE KEY constraint 'UNQ_Inventory_Previous_Columns'. 
Cannot insert duplicate key in object 'Data.Inventory'.

The statement has been terminated.


-- add more
DECLARE @ChangeQty INT;
SET @ChangeQty = 5;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090103', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

SET @ChangeQty = 3;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090104', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

SET @ChangeQty = -4;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090105', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

-- try to violate chronological order
SET @ChangeQty = 5;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20081231', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

Msg 547, Level 16, State 0, Line 4

The INSERT statement conflicted with the CHECK constraint 
"CHK_Inventory_Valid_Dates_Sequence". 
The conflict occurred in database "Test", table "Data.Inventory".

The statement has been terminated.

SELECT ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty
FROM Data.Inventory ORDER BY ChangeDate;

ChangeDate              ChangeQty   TotalQty    PreviousChangeDate      PreviousTotalQty
----------------------- ----------- ----------- ----------------------- -----
2009-01-01 00:00:00.000 10          10          NULL                    NULL
2009-01-03 00:00:00.000 5           15          2009-01-01 00:00:00.000 10
2009-01-04 00:00:00.000 3           18          2009-01-03 00:00:00.000 15
2009-01-05 00:00:00.000 -4          14          2009-01-04 00:00:00.000 18


-- try to change a single row, all updates must fail
UPDATE Data.Inventory SET ChangeQty = ChangeQty + 2 WHERE InventoryID = 3;
UPDATE Data.Inventory SET TotalQty = TotalQty + 2 WHERE InventoryID = 3;

-- try to delete not the last row, all deletes must fail
DELETE FROM Data.Inventory WHERE InventoryID = 1;
DELETE FROM Data.Inventory WHERE InventoryID = 3;

-- the right way to update
DECLARE @IncreaseQty INT;

SET @IncreaseQty = 2;

UPDATE Data.Inventory 
SET 
     ChangeQty = ChangeQty 
   + CASE 
        WHEN ItemID = 1 AND ChangeDate = '20090103' 
        THEN @IncreaseQty 
        ELSE 0 
     END,
  TotalQty = TotalQty + @IncreaseQty,
  PreviousTotalQty = PreviousTotalQty + 
     CASE 
        WHEN ItemID = 1 AND ChangeDate = '20090103' 
        THEN 0 
        ELSE @IncreaseQty 
     END
WHERE ItemID = 1 AND ChangeDate >= '20090103';

SELECT ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty
FROM Data.Inventory ORDER BY ChangeDate;

ChangeDate              ChangeQty   TotalQty    PreviousChangeDate      PreviousTotalQty
----------------------- ----------- ----------- ----------------------- ----------------
2009-01-01 00:00:00.000 10          10          NULL                    NULL
2009-01-03 00:00:00.000 7           17          2009-01-01 00:00:00.000 10
2009-01-04 00:00:00.000 3           20          2009-01-03 00:00:00.000 17
2009-01-05 00:00:00.000 -4          16          2009-01-04 00:00:00.000 20

14

Não permitir que os clientes tenham um saldo menor que 0 é uma regra de negócios (que mudaria rapidamente, pois as taxas para coisas como saques a descoberto são como os bancos fazem a maior parte de seu dinheiro). Você desejará lidar com isso no processamento do aplicativo quando as linhas forem inseridas no histórico de transações. Especialmente porque você pode acabar tendo alguns clientes com cheque especial, alguns recebendo taxas cobradas e outros não permitindo que valores negativos sejam inseridos.

Até agora, eu gosto de onde você está indo com isso, mas se isso é para um projeto real (não para a escola), é preciso pensar bastante nas regras de negócios, etc. Depois que você criar um sistema bancário e em execução não há muito espaço para redesenho, pois existem leis muito específicas sobre pessoas que têm acesso ao seu dinheiro.


11
Eu posso ver por que a restrição de saldos deve realmente ser uma regra de negócios. O banco de dados está apenas fornecendo um serviço de transação, e cabe ao usuário decidir o que fazer com ele.
Nick Chammas

O que você acha dos comentários de Jack de que o uso das duas tabelas oferece aos desenvolvedores mais flexibilidade para alterar ou implementar a lógica de negócios? Além disso, você tem alguma experiência direta com visualizações indexadas que valida ou desafia essas preocupações ?
Nick Chammas

11
Eu não diria que ter duas tabelas oferece flexibilidade de movimento está implementando a lógica de negócios. Dá mais flexibilidade ao arquivamento de dados. No entanto, como banco (pelo menos nos EUA), você tem leis que determinam a quantidade de dados que você precisa manter. Você deseja testar a aparência do desempenho com a visualização na parte superior e levar em consideração que, se você tiver uma visualização indexada, não poderá alterar o esquema das tabelas subjacentes. Apenas mais uma coisa para pensar.
precisa saber é o seguinte

Todos os itens mencionados no artigo são preocupações válidas ao usar uma exibição indexada.
precisa saber é o seguinte

11
Para esclarecer: Na IMO, uma API transacional oferece mais flexibilidade na implementação da lógica de negócios (sem duas tabelas). Nesse caso, eu também seria a favor de duas tabelas (pelo menos considerando as informações que temos até agora) por causa dos trade-offs propostos com a abordagem de exibição indexada (por exemplo, não é possível usar o DRI para garantir o equilíbrio> 0 negócio )
Jack Douglas

13

Uma abordagem ligeiramente diferente (semelhante à sua segunda opção) a considerar é ter apenas a tabela de transações, com uma definição de:

CREATE TABLE Transaction (
      UserID              INT
    , CurrencyID          INT 
    , TransactionDate     DATETIME  
    , OpeningBalance      MONEY
    , TransactionAmount   MONEY
);

Você também pode querer um ID / pedido de transação, para poder lidar com duas transações com a mesma data e melhorar sua consulta de recuperação.

Para obter o saldo atual, tudo o que você precisa é o último registro.

Métodos para obter o último registro :

/* For a single User/Currency */
Select TOP 1 *
FROM dbo.Transaction
WHERE UserID = 3 and CurrencyID = 1
ORDER By TransactionDate desc

/* For multiple records ie: to put into a view (which you might want to index) */
SELECT
    C.*
FROM
    (SELECT 
        *, 
        ROW_NUMBER() OVER (
           PARTITION BY UserID, CurrencyID 
           ORDER BY TransactionDate DESC
        ) AS rnBalance 
    FROM Transaction) C
WHERE
    C.rnBalance = 1
ORDER BY
    C.UserID, C.CurrencyID

Contras:

  • Ao inserir uma transação fora de sequência (por exemplo: para corrigir um problema / saldo inicial incorreto), pode ser necessário enviar atualizações em cascata para todas as transações subsequentes.
  • As transações para o usuário / moeda precisariam ser serializadas para manter um saldo preciso.

    -- Example of getting the current balance and locking the 
    -- last record for that User/Currency.
    -- This lock will be freed after the Stored Procedure completes.
    SELECT TOP 1 @OldBalance = OpeningBalance + TransactionAmount  
    FROM dbo.Transaction with (rowlock, xlock)   
    WHERE UserID = 3 and CurrencyID = 1  
    ORDER By TransactionDate DESC;
    

Prós:

  • Você não precisa mais manter duas tabelas separadas ...
  • Você pode validar facilmente o saldo e, quando o saldo fica fora de sincronia, é possível identificar exatamente quando saiu do controle, à medida que o histórico de transações se torna auto-documentado.

Edit: Algumas consultas de amostra sobre recuperação do saldo atual e para destacar o golpe (Obrigado @ Jack Douglas)


3
A SELECT TOP (1) ... ORDER BY TransactionDate DESCimplementação será muito complicada, de forma que o SQL Server não varre constantemente a tabela de transações. Alex Kuznetsov postou uma solução aqui para um problema de design semelhante que complementa perfeitamente esta resposta.
Nick Chammas

2
+1 Estou usando uma abordagem semelhante. BTW, precisamos ter muito cuidado e garantir que nosso código funcione corretamente sob carga de trabalho simultânea.
AK

12

Depois de ler essas duas discussões, decidi pela opção 2

Depois de ler essas discussões também, não sei por que você decidiu a solução DRI sobre as outras opções mais sensíveis que você descreve:

Aplique transações às tabelas de transações e saldos. Use a lógica TRANSACTION na minha camada de procedimento armazenado para garantir que os saldos e as transações estejam sempre sincronizados.

Esse tipo de solução possui imensos benefícios práticos se você tiver o luxo de restringir todo o acesso aos dados por meio de sua API transacional. Você perde o benefício muito importante do DRI, que é a integridade garantida pelo banco de dados, mas em qualquer modelo de complexidade suficiente, existem algumas regras de negócios que não podem ser impostas pelo DRI .

Aconselho o uso do DRI, sempre que possível, para impor regras de negócios sem dobrar muito o modelo para tornar isso possível:

Mesmo se eu estiver arquivando transações (por exemplo, movendo-as para outro lugar e substituindo-as por transações de resumo)

Assim que você começar a considerar poluir seu modelo assim, acho que você está se mudando para a área em que o benefício do DRI é superado pelas dificuldades que você está apresentando. Considere, por exemplo, que um bug no seu processo de arquivamento poderia, em teoria, fazer com que sua regra de ouro (que os saldos sempre sejam iguais à soma das transações) se rompa silenciosamente com uma solução DRI .

Aqui está um resumo das vantagens da abordagem transacional como as vejo:

  • Deveríamos estar fazendo isso de qualquer maneira, se possível. Qualquer que seja a solução que você escolher para esse problema específico, ela oferece mais flexibilidade de design e controle sobre seus dados. Todo o acesso se torna "transacional" em termos de lógica de negócios, e não apenas em termos de lógica de banco de dados.
  • Você pode manter seu modelo limpo
  • Você pode "impor" uma variedade e complexidade muito mais ampla de regras de negócios (observando que o conceito de "impor" é mais flexível que o DRI)
  • Você ainda pode usar o DRI, sempre que possível, para fornecer ao modelo uma integridade subjacente mais robusta - e isso pode funcionar como uma verificação de sua lógica transacional
  • A maioria dos problemas de desempenho que são preocupantes, você desaparece
  • A introdução de novos requisitos pode ser muito mais fácil - por exemplo: regras complexas para transações em disputa podem afastar você de uma abordagem de DRI pura mais adiante, o que significa muito esforço desperdiçado.
  • Particionar ou arquivar dados históricos se torna muito menos arriscado e doloroso

--editar

Para permitir o arquivamento sem adicionar complexidade ou risco, você pode optar por manter as linhas de resumo em uma tabela de resumo separada, gerada continuamente (emprestado de @Andrew e @Garik)

Por exemplo, se os resumos forem mensais:

  • sempre que houver uma transação (por meio de sua API), haverá uma atualização ou inserção correspondente na tabela de resumo
  • a tabela de resumo nunca é arquivada, mas as transações de arquivamento tornam-se tão simples quanto excluir (ou soltar partição?)
  • cada linha na tabela de resumo inclui 'saldo inicial' e 'valor'
  • restrições de verificação como 'saldo inicial' + 'valor'> 0 e 'saldo inicial'> 0 podem ser aplicadas à tabela de resumo
  • as linhas de resumo podem ser inseridas em um lote mensal para facilitar o bloqueio da última linha de resumo (sempre haveria uma linha para o mês atual)

Em relação à sua edição: você propõe ter esta tabela de resumo ao lado da tabela de saldos principal? A tabela de saldos se torna efetivamente uma tabela de resumo que possui apenas os registros do mês atual (já que ambos armazenam o mesmo tipo de dados)? Se entendi corretamente, por que não substituir a tabela de saldos pela partição apropriada na tabela de resumo?
Nick Chammas

Desculpe, você está certo, isso não está claro - eu quis dizer dispensar a tabela de saldos, pois sempre seria uma consulta importante na tabela de resumo para obter o saldo atual (o que não é verdade na sugestão AFAIK de Andrews). A vantagem é que o cálculo dos saldos em épocas anteriores se torna mais fácil e existe uma trilha de auditoria mais clara para os saldos, se eles derem errado.
Jack Douglas

6

Usuario.

A ideia principal é armazenar registros de saldo e transação na mesma tabela. Aconteceu historicamente, pensei. Portanto, nesse caso, podemos obter equilíbrio localizando o último registro de resumo.

 id   user_id    currency_id      amount    is_summary (or record_type)
----------------------------------------------------
  1       3              1       10.60             0
  2       3              1       10.60             1    -- summary after transaction 1
  3       3              1      -55.00             0
  4       3              1      -44.40             1    -- summary after transactions 1 and 3
  5       3              1      -12.12             0
  6       3              1      -56.52             1    -- summary after transactions 1, 3 and 5 

Uma variante melhor é o número decrescente de registros de resumo. Podemos ter um registro de saldo no final (e / ou início) do dia. Como você sabe, todo banco precisa operational dayabrir e depois fechá-lo para fazer algumas operações resumidas para este dia. Permite calcular facilmente os juros usando o registro do saldo diário, por exemplo:

user_id    currency_id      amount    is_summary    oper_date
--------------------------------------------------------------
      3              1       10.60             0    01/01/2011 
      3              1      -55.00             0    01/01/2011
      3              1      -44.40             1    01/01/2011 -- summary at the end of day (01/01/2011)
      3              1      -12.12             0    01/02/2011
      3              1      -56.52             1    01/02/2011 -- summary at the end of day (01/02/2011)

Sorte.


4

Com base nos seus requisitos, a opção 1 parece a melhor. Embora eu tivesse meu design para permitir apenas inserções na tabela de transações. E tenha o gatilho na tabela de transações, para atualizar a tabela de saldo em tempo real. Você pode usar permissões de banco de dados para controlar o acesso a essas tabelas.

Nesta abordagem, o saldo em tempo real é garantido em sincronia com a tabela de transações. E não importa se procedimentos armazenados ou psql ou jdbc são usados. Você pode verificar seu saldo negativo, se necessário. O desempenho não será um problema. Para obter o equilíbrio em tempo real, é uma consulta única.

O arquivamento não afetará essa abordagem. Você pode ter uma tabela de resumo semanal, mensal e anual também se necessário para itens como relatórios.


3

No Oracle, você pode fazer isso usando apenas a tabela de transações com uma Visualização Materializada rápida e atualizável, que faz a agregação para formar o saldo. Você define o gatilho na visão materializada. Se a Visualização Materializada for definida com 'ON COMMIT', ela evita efetivamente a adição / modificação de dados nas tabelas base. O gatilho detecta os [no] dados válidos e gera uma exceção, onde reverte a transação. Um bom exemplo está aqui http://www.sqlsnippets.com/en/topic-12896.html

Eu não sei sqlserver, mas talvez tenha uma opção semelhante?


2
As visualizações materializadas no Oracle são semelhantes à "exibição indexada" do SQL Server, mas são atualizadas automaticamente, e não de maneira explicitamente gerenciada, como o comportamento 'ON COMMIT' do Oracle. Consulte social.msdn.microsoft.com/Forums/fi-FI/transactsql/thread/… e techembassy.blogspot.com/2007/01/…
GregW
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.