- 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
149 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|