- mail/internal/dedup/hash.go: HashAndBuffer, SHA-256 auf Klartext VOR Verschluesselung (ARC-02), liefert erneut lesbaren Reader zurueck. - mail/internal/dedup/store.go: Store (Postgres, tenant_slug fest im Primaerschluessel gebunden), Register: Duplikat referenziert Original statt redundant zu speichern. - Alle 3 Pflichtpruefungen real bestanden (siehe mail/docs/ARC-03-PRUEFPROTOKOLL.md): Duplikat aus zwei Quellen erkannt, zwei Mandanten mit identischem Inhalt nicht verknuepft, knapp unterschiedliche Nachricht korrekt nicht erkannt. - Kein Umbau: internal/storage, internal/crypto, internal/encstorage unveraendert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
76 lines
2.5 KiB
Go
76 lines
2.5 KiB
Go
package dedup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Store verwaltet bekannte Inhalts-Hashes je Mandant. tenant_slug ist
|
|
// fester Bestandteil des Primärschlüssels (Akzeptanzkriterium 3:
|
|
// mandantenübergreifend korrekt getrennt) — auch wenn Store einen mit
|
|
// anderen Mandanten geteilten Pool erhält, kann ein Hash-Treffer nie
|
|
// über Mandantengrenzen hinweg entstehen.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
tenantSlug string
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool, tenantSlug string) *Store {
|
|
return &Store{pool: pool, tenantSlug: tenantSlug}
|
|
}
|
|
|
|
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
|
|
// gleiches Muster wie mail/internal/example (kein zentraler
|
|
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
|
|
func (s *Store) EnsureSchema(ctx context.Context) error {
|
|
if _, err := s.pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS mail_content_hashes (
|
|
tenant_slug TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL,
|
|
object_key TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (tenant_slug, content_hash)
|
|
)
|
|
`); err != nil {
|
|
return fmt.Errorf("dedup: schema anlegen: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Register trägt contentHash für den Mandanten als neu bekannt ein
|
|
// (Akzeptanzkriterium 1) und referenziert das Original, statt es
|
|
// redundant zu speichern (Akzeptanzkriterium 2): existiert derselbe
|
|
// Hash für DIESEN Mandanten bereits mit einem ANDEREN object_key,
|
|
// liefert Register isDuplicate=true und den object_key des Originals —
|
|
// der Aufrufer legt den neuen Inhalt dann NICHT ab.
|
|
func (s *Store) Register(ctx context.Context, contentHash, objectKey string) (isDuplicate bool, existingKey string, err error) {
|
|
if _, err := s.pool.Exec(ctx, `
|
|
INSERT INTO mail_content_hashes (tenant_slug, content_hash, object_key)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (tenant_slug, content_hash) DO NOTHING
|
|
`, s.tenantSlug, contentHash, objectKey); err != nil {
|
|
return false, "", fmt.Errorf("dedup: hash eintragen: %w", err)
|
|
}
|
|
|
|
var storedKey string
|
|
err = s.pool.QueryRow(ctx, `
|
|
SELECT object_key FROM mail_content_hashes
|
|
WHERE tenant_slug = $1 AND content_hash = $2
|
|
`, s.tenantSlug, contentHash).Scan(&storedKey)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, "", fmt.Errorf("dedup: gerade eingetragenen hash nicht wiedergefunden")
|
|
}
|
|
return false, "", fmt.Errorf("dedup: eintrag lesen: %w", err)
|
|
}
|
|
|
|
if storedKey != objectKey {
|
|
return true, storedKey, nil
|
|
}
|
|
return false, "", nil
|
|
}
|