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
+2 -2
View File
@@ -39,8 +39,8 @@ Legenda ozbiljnosti: 🔴 visok · 🟡 srednji · 🟢 nizak
- [ ] Svih **20** `BeginTx(...)` poziva u `internal/db/sqlite/*.go` ima `defer tx.Rollback()` odmah posle provere greške (provereno u: `artikal.go`, `rezervni_kodovi.go`, `servisni_potrazivani_delovi.go`, `nabavka.go`, `prodaja.go`, `nivelacija.go`, `servisni_radovi.go`, `servisni_delovi.go`, `servis.go`) — **nema propusta**, bez akcije.
- [ ] 🟡 **`ServisRepo.Kreiraj` (`internal/db/sqlite/servis.go:142`) nema idempotency zaštitu** kakva je upravo dodata za `ProdajaRepo.Kreiraj` (migracija 106, `idempotency_key`). Broj naloga (`sledeciBrojServisa`) se generiše unutar iste tx (ispravno, sprečava koliziju brojeva), ali DVA nezavisna HTTP POST zahteva (mrežni retry, "Nazad" pa resubmit, dva otvorena taba) i dalje mogu napraviti DVA validna servisna naloga sa istim podacima ali različitim brojevima — isti arhitekturni gep koji je upravo zatvoren za prodaju. Handler: `internal/handler/servis.go` `SacuvajNalog` (red ~218).
**Preporuka**: primeniti isti obrazac (idempotency_key kolona + parcijalni UNIQUE indeks + skriveno polje u `servis_forma.html`) ako se želi potpuna zaštita, konzistentno sa prodajom.
- [x] 🟡 **`ServisRepo.Kreiraj` nema idempotency zaštitu — ISPRAVLJENO.**
Primenjen isti obrazac kao za prodaju (migracija 106): nova migracija `migrations/107_servis_idempotency_key.sql` (kolona `idempotency_key` + parcijalni `UNIQUE` indeks na `servisni_nalozi`), `model.ServisniNalog.IdempotencyKey` polje, `ServisRepo.Kreiraj` (`internal/db/sqlite/servis.go`) proverava postojeći ključ pre insert-a i vraća postojeći ID ako je nalog već kreiran istim ključem, `parseFormuNaloga` (`internal/handler/servis.go`) čita `idempotency_key` iz forme, `servis_forma.html` dobija skriveno polje + JS UUID generator (samo za novi nalog, ne za izmenu). Dodat `internal/db/sqlite/servis_idempotency_test.go` (2 testa: sa ključem vraća isti ID, bez ključa prave se dva odvojena naloga kao ranije) — oba prolaze.
- [ ] 🟢 `NabavkaRepo.Kreiraj` (`internal/db/sqlite/nabavka.go:178`) nema interni sekvencijalni broj (koristi `broj_racuna` dobavljača, korisnički unet) — nema race-a oko generisanja broja, ali isti opšti rizik "dva odvojena POST-a = dve nabavke" postoji arhitekturno kao i svuda gde nema idempotency ključa. Niži prioritet jer nema poznatu žalbu/simptom.
+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.
+10
View File
@@ -0,0 +1,10 @@
-- Idempotency ključ za servisni nalog — isti obrazac kao za prodaju
-- (migracija 106_prodaja_idempotency_key.sql): frontend generiše UUID po otvaranju
-- forme i šalje ga kao skriveno polje. Ako isti POST stigne na server dva puta
-- (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva otvorena taba),
-- drugi zahtev se prepoznaje po već postojećem ključu i vraća VEĆ kreirani nalog
-- umesto da napravi drugi. NULL dozvoljen i ne ulazi u UNIQUE proveru (stari
-- zapisi, ili budući pozivaoci koji ne šalju ključ).
ALTER TABLE servisni_nalozi ADD COLUMN idempotency_key TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_servisni_nalozi_idempotency_key
ON servisni_nalozi(idempotency_key) WHERE idempotency_key IS NOT NULL;
+11
View File
@@ -35,6 +35,17 @@
<input type="hidden" name="broj_naloga" value="{{.Nalog.BrojNaloga}}">
<!-- CSRF token — bez njega middleware odbija POST sa 403 -->
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if not .Izmena}}
<!-- idempotency_key: generiše se jednom po otvaranju forme (samo za NOVI nalog —
izmena ide kroz Izmeni, ne kroz Kreiraj, pa joj ključ nije potreban); server ga
koristi da prepozna dupli POST i vrati postojeći nalog umesto da napravi drugi -->
<input type="hidden" name="idempotency_key" id="idempotency-key-servis">
<script>
document.getElementById('idempotency-key-servis').value =
(window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID()
: (Date.now().toString(36) + Math.random().toString(36).slice(2));
</script>
{{end}}
<!-- ===================== KARTICA: UREĐAJ ===================== -->
<div class="kartica forma-kartica animiraj" style="margin-bottom:20px;">