250 lines
9.0 KiB
Go
250 lines
9.0 KiB
Go
package middleware
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
/*
|
||
How much of the till fleet is carrying a session token.
|
||
|
||
── Why this exists ─────────────────────────────────────────────────────────
|
||
|
||
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
|
||
switching it on is a number nobody has: how many terminals still call the POS
|
||
routes with no token. Flipping the flag blind is the one action on this surface
|
||
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
|
||
bill is not a reversible inconvenience.
|
||
|
||
So this counts, and names the outlets that are still untokened, so the flag gets
|
||
flipped on evidence rather than on hope.
|
||
|
||
── What it deliberately is not ─────────────────────────────────────────────
|
||
|
||
Not persisted. It lives in memory and resets on restart, which is honest about
|
||
what it measures: adoption since this process started, not all time. A restart
|
||
mid-observation means starting the week again, and that is a smaller cost than a
|
||
migration and a table for a number that stops mattering the day the flag is on.
|
||
|
||
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
|
||
here can change whether a request is served.
|
||
|
||
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
|
||
name ten thousand outlets and grow this without bound. Past the cap new outlets
|
||
are counted in the totals and not listed individually, which keeps the answer
|
||
useful without making it a way to spend the server's memory.
|
||
*/
|
||
|
||
// posAdoptionCap is how many distinct untokened outlets are named individually.
|
||
// The real fleet is dozens; anything beyond this is noise or somebody probing.
|
||
const posAdoptionCap = 200
|
||
|
||
type posOutletSeen struct {
|
||
Locationid int
|
||
Requests int64
|
||
FirstSeen time.Time
|
||
LastSeen time.Time
|
||
}
|
||
|
||
var posAdoption = struct {
|
||
sync.Mutex
|
||
since time.Time
|
||
tokened int64
|
||
untokened int64
|
||
// Untokened requests by the outlet they named, and by the route they hit.
|
||
outlets map[int]*posOutletSeen
|
||
paths map[string]int64
|
||
// True once the cap was reached, so the report can say it is partial
|
||
// rather than quietly under-reporting.
|
||
truncated bool
|
||
}{
|
||
since: time.Now(),
|
||
outlets: map[int]*posOutletSeen{},
|
||
paths: map[string]int64{},
|
||
}
|
||
|
||
// recordPosToken notes one request that arrived with a usable token.
|
||
func recordPosToken() {
|
||
posAdoption.Lock()
|
||
posAdoption.tokened++
|
||
posAdoption.Unlock()
|
||
}
|
||
|
||
// recordPosUntokened notes one request that arrived with none, and where it
|
||
// claimed to be. `locationid` is 0 when the route named no outlet.
|
||
func recordPosUntokened(locationid int, path string) {
|
||
now := time.Now()
|
||
|
||
posAdoption.Lock()
|
||
defer posAdoption.Unlock()
|
||
|
||
posAdoption.untokened++
|
||
posAdoption.paths[path]++
|
||
|
||
if locationid <= 0 {
|
||
return
|
||
}
|
||
if seen, ok := posAdoption.outlets[locationid]; ok {
|
||
seen.Requests++
|
||
seen.LastSeen = now
|
||
return
|
||
}
|
||
if len(posAdoption.outlets) >= posAdoptionCap {
|
||
posAdoption.truncated = true
|
||
return
|
||
}
|
||
posAdoption.outlets[locationid] = &posOutletSeen{
|
||
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
|
||
}
|
||
}
|
||
|
||
// PosAdoptionOutlet is one outlet still calling without a token.
|
||
type PosAdoptionOutlet struct {
|
||
Locationid int `json:"locationid"`
|
||
Requests int64 `json:"requests"`
|
||
FirstSeen string `json:"firstseen"`
|
||
LastSeen string `json:"lastseen"`
|
||
}
|
||
|
||
// PosAdoptionPath is one route, and how often it was reached untokened.
|
||
type PosAdoptionPath struct {
|
||
Path string `json:"path"`
|
||
Requests int64 `json:"requests"`
|
||
}
|
||
|
||
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
|
||
type PosAdoption struct {
|
||
// Whether an untokened request is currently refused.
|
||
Enforced bool `json:"enforced"`
|
||
// When counting started — process start, not all time.
|
||
Since string `json:"since"`
|
||
// Requests seen on the POS surface since then.
|
||
Tokened int64 `json:"tokened"`
|
||
Untokened int64 `json:"untokened"`
|
||
// 0–100. 100 means every request in this window carried a token, which is
|
||
// the condition for flipping the flag.
|
||
AdoptedPercent float64 `json:"adoptedpercent"`
|
||
// The outlets still calling without one, busiest first. These are the tills
|
||
// that would stop working the moment enforcement is switched on.
|
||
Outlets []PosAdoptionOutlet `json:"outlets"`
|
||
// Which routes they are reaching, busiest first.
|
||
Paths []PosAdoptionPath `json:"paths"`
|
||
// True when more outlets were seen than are listed — see posAdoptionCap.
|
||
Truncated bool `json:"truncated"`
|
||
// Plain-language reading of the above, for whoever has to make the call.
|
||
Verdict string `json:"verdict"`
|
||
}
|
||
|
||
// PosAdoptionReport is the snapshot, safe to call at any time.
|
||
func PosAdoptionReport() PosAdoption {
|
||
posAdoption.Lock()
|
||
defer posAdoption.Unlock()
|
||
|
||
report := PosAdoption{
|
||
Enforced: posAuthRequired(),
|
||
Since: posAdoption.since.Format(time.RFC3339),
|
||
Tokened: posAdoption.tokened,
|
||
Untokened: posAdoption.untokened,
|
||
Truncated: posAdoption.truncated,
|
||
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
|
||
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
|
||
}
|
||
|
||
total := posAdoption.tokened + posAdoption.untokened
|
||
if total > 0 {
|
||
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
|
||
}
|
||
|
||
for _, seen := range posAdoption.outlets {
|
||
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
|
||
Locationid: seen.Locationid,
|
||
Requests: seen.Requests,
|
||
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
|
||
LastSeen: seen.LastSeen.Format(time.RFC3339),
|
||
})
|
||
}
|
||
// Busiest first: the outlet ringing the most bills is the one that hurts
|
||
// most if enforcement switches on before it has adopted.
|
||
sort.Slice(report.Outlets, func(i, j int) bool {
|
||
return report.Outlets[i].Requests > report.Outlets[j].Requests
|
||
})
|
||
|
||
for path, count := range posAdoption.paths {
|
||
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
|
||
}
|
||
sort.Slice(report.Paths, func(i, j int) bool {
|
||
return report.Paths[i].Requests > report.Paths[j].Requests
|
||
})
|
||
|
||
report.Verdict = posAdoptionVerdict(report)
|
||
return report
|
||
}
|
||
|
||
// posAdoptionVerdict says what the numbers mean, because the number on its own
|
||
// invites the wrong reading in both directions: a clean window that is only an
|
||
// hour long proves nothing, and one stubborn outlet is not a reason to leave
|
||
// the whole surface open.
|
||
func posAdoptionVerdict(r PosAdoption) string {
|
||
switch {
|
||
case r.Enforced:
|
||
return "Enforcement is already on: an untokened request is refused."
|
||
case r.Tokened+r.Untokened == 0:
|
||
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
|
||
case r.Untokened == 0:
|
||
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
|
||
case len(r.Outlets) == 0:
|
||
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
|
||
default:
|
||
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
|
||
}
|
||
}
|
||
|
||
// posAdoptionLogEvery is how often the summary reaches the log.
|
||
//
|
||
// Long, because this is a slow-moving fact — a fleet adopts over days, not
|
||
// minutes — and a log line nobody needs every minute is a log line people learn
|
||
// to scroll past.
|
||
const posAdoptionLogEvery = 30 * time.Minute
|
||
|
||
// LogPosAdoption prints the summary on a timer until ctx is done.
|
||
//
|
||
// In the log as well as on the endpoint because the two get used by different
|
||
// people at different moments: somebody already reading Dokploy's log because a
|
||
// till is misbehaving should not have to know an endpoint exists.
|
||
//
|
||
// Outlet ids only, never names or counts of takings — a log is the one place
|
||
// this data ends up somewhere nobody chose to put it.
|
||
func LogPosAdoption(ctx context.Context) {
|
||
ticker := time.NewTicker(posAdoptionLogEvery)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
report := PosAdoptionReport()
|
||
if report.Tokened+report.Untokened == 0 {
|
||
continue // nothing happened; saying so every half hour is noise
|
||
}
|
||
if report.Untokened == 0 {
|
||
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
|
||
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
|
||
continue
|
||
}
|
||
outlets := make([]string, 0, len(report.Outlets))
|
||
for _, o := range report.Outlets {
|
||
outlets = append(outlets, strconv.Itoa(o.Locationid))
|
||
}
|
||
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
|
||
report.AdoptedPercent, report.Tokened+report.Untokened,
|
||
report.Untokened, strings.Join(outlets, ", "))
|
||
}
|
||
}
|
||
}
|