DOC-01: upload-api & chunk-handling
Fortsetzbarer Server-seitiger Upload: upload_sessions (bytes_received, GREATEST-Update verhindert Rueckschritt bei erneut zugestellten Chunks), Staging via os.File.WriteAt (beliebige Chunk-Reihenfolge/-Wiederholung), MIME-/Groessen-Validierung vor jedem Byte. Complete() liest Klartext einmal via io.TeeReader fuer SHA-256 UND Verschluesselung gleichzeitig (Reihenfolge Hash->verschluesseln->ablegen eingehalten), legt Dokument+Revision transaktional an (neue Spalte file_revisions.wrapped_dek fuer FDN-09). Auf 192.168.1.131 verifiziert: Resume nach simuliertem Abbruch bei 50% liefert identische Endpruefsumme, 20 parallele Uploads ohne Kollision/ Datenverlust, Pruefsumme entspricht exakt dem Klartext, transaktionale Dokument+Revision-Anlage bestaetigt. Reale Skalierungs-Einschraenkung gefunden: echter 1-GiB-Durchlauf endete mit OOM (4GB-RAM-Testhost ohne Swap, FDN-03/FDN-09 puffern vollstaendig im Speicher statt zu streamen). Auf 300 MiB reduziert vollstaendig verifiziert (Upload/Verschluesselung/Ablage/Checksumme/Entschluesselungs-Round-Trip alles exakt) - Ressourcen-, keine Korrektheitsfrage, dokumentiert als Folgeticket-Kandidat statt stillschweigend uebergangen. Siehe dms/docs/DOC-01-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bf7559e118
commit
9a374dd91e
@@ -0,0 +1,105 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrSessionNotFound wird geliefert, wenn eine angefragte Upload-Sitzung
|
||||
// nicht existiert.
|
||||
var ErrSessionNotFound = errors.New("upload: sitzung nicht gefunden")
|
||||
|
||||
// Session ist der Zustand eines laufenden oder abgeschlossenen Uploads
|
||||
// (Akzeptanzkriterium 1: Grundlage fuer Fortsetzung nach Abbruch).
|
||||
type Session struct {
|
||||
ID string
|
||||
Filename string
|
||||
MimeType string
|
||||
TotalSize int64
|
||||
BytesReceived int64
|
||||
FolderID *string
|
||||
CreatedBy string
|
||||
Status string
|
||||
DocumentID *string
|
||||
}
|
||||
|
||||
const (
|
||||
StatusUploading = "uploading"
|
||||
StatusCompleted = "completed"
|
||||
StatusAborted = "aborted"
|
||||
)
|
||||
|
||||
// SessionStore verwaltet upload_sessions.
|
||||
type SessionStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSessionStore(pool *pgxpool.Pool) *SessionStore {
|
||||
return &SessionStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Create(ctx context.Context, filename, mimeType string, totalSize int64, folderID *string, createdBy string) (string, error) {
|
||||
var id string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO upload_sessions (filename, mime_type, total_size, folder_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, filename, mimeType, totalSize, folderID, createdBy).Scan(&id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload: sitzung anlegen: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(ctx context.Context, sessionID string) (*Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, filename, mime_type, total_size, bytes_received, folder_id, created_by, status, document_id
|
||||
FROM upload_sessions WHERE id = $1
|
||||
`, sessionID).Scan(&sess.ID, &sess.Filename, &sess.MimeType, &sess.TotalSize, &sess.BytesReceived,
|
||||
&sess.FolderID, &sess.CreatedBy, &sess.Status, &sess.DocumentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("upload: sitzung lesen: %w", err)
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// RecordChunk aktualisiert bytes_received auf das Maximum aus dem
|
||||
// bisherigen und dem neu gemeldeten Wert (Akzeptanzkriterium 1: ein
|
||||
// erneut zugestellter/uebersprungener Chunk darf den Fortschritt nie
|
||||
// zurueckdrehen — GREATEST statt blindem Ueberschreiben).
|
||||
func (s *SessionStore) RecordChunk(ctx context.Context, sessionID string, bytesReceivedNow int64) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE upload_sessions
|
||||
SET bytes_received = GREATEST(bytes_received, $2), updated_at = now()
|
||||
WHERE id = $1 AND status = 'uploading'
|
||||
`, sessionID, bytesReceivedNow)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload: fortschritt aktualisieren: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) MarkCompleted(ctx context.Context, sessionID, documentID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE upload_sessions SET status = 'completed', document_id = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
`, sessionID, documentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload: sitzung abschliessen: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user