Servis: idempotency zaštita od duplog POST-a pri kreiranju naloga (isti obrazac kao prodaja)

This commit is contained in:
2026-07-31 03:03:48 +02:00
parent b9e7043ec1
commit 3998119744
7 changed files with 115 additions and 5 deletions
+19 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
@@ -145,6 +146,21 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
}
defer tx.Rollback()
// idempotency zaštita: isti obrazac kao ProdajaRepo.Kreiraj — ako pozivalac pošalje
// ključ i nalog sa tim ključem već postoji, to je dupliran POST — vraćamo postojeći ID.
if n.IdempotencyKey != "" {
var postojeciID int64
err := tx.QueryRowContext(ctx,
"SELECT id FROM servisni_nalozi WHERE idempotency_key = ?", n.IdempotencyKey,
).Scan(&postojeciID)
if err == nil {
return postojeciID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: provera idempotency key: %w", err)
}
}
brojNaloga, err := sledeciBrojServisa(ctx, tx)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: broj naloga: %w", err)
@@ -155,15 +171,15 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
INSERT INTO servisni_nalozi
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ostecenja, pin_uredjaja, pribor, datum_prijema, javni_token, idempotency_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.TrazeneNadogradnje, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullInt(n.GarancijaDana), nullTime(n.DatumZavrsetka), nullTime(n.PredvidjenDatum),
nullString(n.Ostecenja), nullString(n.PinUredjaja), nullString(n.Pribor),
n.DatumPrijema, token,
n.DatumPrijema, token, nullString(n.IdempotencyKey),
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
@@ -0,0 +1,65 @@
package sqlite
import (
"context"
"testing"
"ntech/internal/model"
)
// TestServisKreiraj_IdempotencyKey: dva poziva Kreiraj sa istim IdempotencyKey
// (simulira dupliran POST — dupli klik, "Nazad" pa ponovni submit, mrežni retry)
// vraćaju ISTI nalogID i ne prave drugi nalog — isti obrazac kao za prodaju
// (v. TestProdajaKreiraj_IdempotencyKey u prodaja_kreiraj_test.go).
func TestServisKreiraj_IdempotencyKey(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
id1, err := repo.Kreiraj(ctx, &model.ServisniNalog{
Uredjaj: "Laptop", OpisKvara: "ne pali", Status: "Primljeno",
IdempotencyKey: "test-servis-kljuc-123",
})
if err != nil {
t.Fatalf("prvi Kreiraj: %v", err)
}
// drugi poziv sa ISTIM ključem (nov nalog, kao pri ponovljenom POST-u)
id2, err := repo.Kreiraj(ctx, &model.ServisniNalog{
Uredjaj: "Laptop", OpisKvara: "ne pali", Status: "Primljeno",
IdempotencyKey: "test-servis-kljuc-123",
})
if err != nil {
t.Fatalf("drugi Kreiraj (dupliran POST): %v", err)
}
if id1 != id2 {
t.Errorf("drugi poziv sa istim idempotency ključem vratio drugačiji ID: %d != %d — napravljen dupli nalog", id1, id2)
}
var brNaloga int
baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM servisni_nalozi WHERE idempotency_key = ?", "test-servis-kljuc-123").Scan(&brNaloga)
if brNaloga != 1 {
t.Errorf("broj naloga sa ovim idempotency ključem = %d, očekivano 1", brNaloga)
}
}
// TestServisKreiraj_BezIdempotencyKljuca: prazan ključ (pozivalac ga ne koristi) —
// dva odvojena poziva prave DVA odvojena naloga, kao i pre uvođenja zaštite.
func TestServisKreiraj_BezIdempotencyKljuca(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
id1, err := repo.Kreiraj(ctx, &model.ServisniNalog{Uredjaj: "PC", OpisKvara: "kvar", Status: "Primljeno"})
if err != nil {
t.Fatalf("prvi Kreiraj: %v", err)
}
id2, err := repo.Kreiraj(ctx, &model.ServisniNalog{Uredjaj: "PC", OpisKvara: "kvar", Status: "Primljeno"})
if err != nil {
t.Fatalf("drugi Kreiraj: %v", err)
}
if id1 == id2 {
t.Errorf("bez idempotency ključa očekivana dva različita naloga, dobijen isti ID %d", id1)
}
}
+3
View File
@@ -1324,6 +1324,9 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
PinUredjaja: strings.TrimSpace(r.FormValue("pin_uredjaja")),
Pribor: strings.TrimSpace(r.FormValue("pribor")),
DatumPrijema: time.Now(),
// idempotency_key: UUID koji frontend generiše po otvaranju forme (skriveno polje);
// koristi ga samo ServisRepo.Kreiraj (zaštita od duplog POST-a), Izmeni ga ignoriše
IdempotencyKey: strings.TrimSpace(r.FormValue("idempotency_key")),
}
// datum prijema — korisnik može da unese drugi datum (npr. retroaktivno)
+5
View File
@@ -62,6 +62,11 @@ type ServisniNalog struct {
Naplaceno float64 // iznos koji je naplaćen pri preuzimanju
Stornirano bool
RazlogStorniranja string
// IdempotencyKey je UUID koji frontend generiše po otvaranju forme (skriveno polje).
// Ako isti ključ već postoji u bazi, Kreiraj ne pravi novi nalog nego vraća postojeći —
// štiti od duplog POST-a (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva taba).
// Prazan string znači da pozivalac ne koristi zaštitu (npr. testovi, budući pozivaoci).
IdempotencyKey string
}
// ServisniLog je jedan zapis u istoriji događaja servisnog naloga.