Files
nexarch/dms/internal/retentiondestroy/retentiondestroy.go
T
sysops 9843275e9c DOC-16: ret-05-client-aufbewahrungsklasse-registrieren-vernichtungs-rueckruf
- dms/internal/retentionclient: HTTP-Client fuer Archive RET-05/RET-09
- dms/internal/retentiondestroy: RegisterOrRequeue (FDN-04-Requeue bei
  Fehlschlag, kein Absturz/stilles Verwerfen), DestroyCallbackHandler
  (physische Loeschung aller Datei-Revisionen ueber storage.Service,
  markiert Dokument als vernichtet, 2xx erst danach)
- dms/cmd/app: mountet destroy-callback, registriert beim Start
- dms/cmd/worker: verarbeitet Requeue-Jobs (Retry)
- nur dms_document registriert (DOC-01/FDN-02 kennen keinen separaten
  Anhang-Typ, kein Umbau angrenzender Bereiche)
- real getestet: registrierung gegen echten fake-RET-05-Server, echte
  Loeschung inkl. Storage-Byte-Nachweis, echter Requeue-Eintrag bei
  Fehlschlag, erfolgreicher Retry
- live auf 131 gegen echten RET-09-Dienst (Port 8095) verifiziert,
  echter curl-Vernichtungs-Rueckruf mit real geloeschter Datei
- reale Core-API-06-Deploy-Luecke dokumentiert (nicht verschwiegen):
  /internal/resync/usage auf 131 aktuell nicht gemountet

Pruefungen siehe dms/docs/DOC-16-PRUEFPROTOKOLL.md
2026-08-30 09:37:12 +02:00

149 lines
5.6 KiB
Go

// Package retentiondestroy implementiert DOC-16: DMS als Konsument des
// Archive-RET-05-Vertrags. Registriert den Objekttyp "dms_document" bei
// Archive (RET-09, real laufender Dienst) und empfängt/verarbeitet den
// Vernichtungs-Rückruf. Kennt KEINE eigene Fristenberechnung — reiner
// Adapter zwischen DMS und dem bereits feststehenden RET-05-Vertrag.
package retentiondestroy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
// ModuleName ist der bei Archive RET-05 registrierte Modulname.
const ModuleName = "dms"
// ObjectTypeDocument ist der einzige aktuell in DOC-01/FDN-02 modellierte
// Objekttyp — DMS führt (noch) keinen separaten "Anhang"-Entitätstyp,
// daher wird ausschließlich "dms_document" registriert (kleinste Lösung,
// kein Umbau von DOC-01/FDN-02).
const ObjectTypeDocument = "dms_document"
// JobTypeRegister ist der FDN-04-Jobtyp für Registrierungs-Retries.
const JobTypeRegister = "doc16_retention_register"
// RegisterConfig ist der Registrierungs-Auftrag, sowohl für den
// Erstversuch als auch als Requeue-Payload.
type RegisterConfig struct {
ObjectType string `json:"object_type"`
RetentionClass string `json:"retention_class"`
CallbackURL string `json:"callback_url"`
}
// RegisterOrRequeue versucht die Registrierung real gegen Archive RET-05
// (RET-09). Schlägt sie fehl (Archive nicht erreichbar/Nicht-2xx), wird
// EIN Requeue-Job über FDN-04 eingereiht, statt abzustürzen oder den
// Fehler zu verschlucken (Akzeptanzkriterium 3) — der ursprüngliche
// Fehler wird IMMER zurückgegeben, damit der Aufrufer ihn protokolliert.
func RegisterOrRequeue(ctx context.Context, client *retentionclient.Client, queue *jobqueue.Queue, cfg RegisterConfig) (queued bool, err error) {
_, regErr := client.Register(ctx, ModuleName, cfg.ObjectType, cfg.RetentionClass, cfg.CallbackURL)
if regErr == nil {
return false, nil
}
if _, qerr := queue.Enqueue(ctx, JobTypeRegister, cfg, jobqueue.EnqueueOptions{
IdempotencyKey: "doc16-register-" + cfg.ObjectType,
}); qerr != nil {
return false, fmt.Errorf("retentiondestroy: registrierung fehlgeschlagen (%v) UND requeue fehlgeschlagen: %w", regErr, qerr)
}
return true, regErr
}
// ProcessRegisterJob verarbeitet EINEN dequeuten Registrierungs-Retry —
// vom Worker (FDN-04-Konsument) aufgerufen.
func ProcessRegisterJob(ctx context.Context, client *retentionclient.Client, payload json.RawMessage) error {
var cfg RegisterConfig
if err := json.Unmarshal(payload, &cfg); err != nil {
return fmt.Errorf("retentiondestroy: job-payload lesen: %w", err)
}
_, err := client.Register(ctx, ModuleName, cfg.ObjectType, cfg.RetentionClass, cfg.CallbackURL)
return err
}
type destroyRequest struct {
ObjectType string `json:"object_type"`
ObjectReference string `json:"object_reference"`
DestroyedAt string `json:"destroyed_at"`
}
// DestroyCallbackHandler ist der RET-05-Rückruf-Empfänger
// (Akzeptanzkriterium 2): löscht physisch alle Datei-Revisionen des
// referenzierten Dokuments über storage.Service (meldet die
// Größenänderung an Core, Service.Delete-Vertrag) und markiert das
// Dokument als vernichtet (deleted_at). Antwortet erst NACH
// erfolgreicher Löschung mit 2xx.
func DestroyCallbackHandler(pool *pgxpool.Pool, storageSvc *storage.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req destroyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.ObjectType != ObjectTypeDocument || req.ObjectReference == "" {
http.Error(w, "unbekannter object_type oder fehlende object_reference", http.StatusBadRequest)
return
}
destroyedAt, err := time.Parse(time.RFC3339, req.DestroyedAt)
if err != nil {
http.Error(w, "ungültiges destroyed_at: "+err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
rows, err := pool.Query(ctx, `SELECT storage_key, size_bytes FROM file_revisions WHERE document_id = $1`, req.ObjectReference)
if err != nil {
http.Error(w, "revisionen lesen: "+err.Error(), http.StatusInternalServerError)
return
}
type revision struct {
key string
size int64
}
var revisions []revision
for rows.Next() {
var rv revision
if err := rows.Scan(&rv.key, &rv.size); err != nil {
rows.Close()
http.Error(w, "revisions-zeile lesen: "+err.Error(), http.StatusInternalServerError)
return
}
revisions = append(revisions, rv)
}
rowsErr := rows.Err()
rows.Close()
if rowsErr != nil {
http.Error(w, "revisionen lesen: "+rowsErr.Error(), http.StatusInternalServerError)
return
}
for _, rv := range revisions {
if err := storageSvc.Delete(ctx, rv.key, rv.size); err != nil {
http.Error(w, "objekt-storage löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
}
tag, err := pool.Exec(ctx, `UPDATE documents SET deleted_at = $2 WHERE id = $1`, req.ObjectReference, destroyedAt)
if err != nil {
http.Error(w, "dokument als vernichtet markieren: "+err.Error(), http.StatusInternalServerError)
return
}
if tag.RowsAffected() == 0 {
// Dokument existiert nicht (mehr) — aus Sicht des Rückrufs
// bereits erledigt, kein Fehler (verhindert Retry-Schleifen
// bei Archive fuer laengst geloeschte Objekte).
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
}
}