Spaces:
Runtime error
Runtime error
| package api | |
| import ( | |
| "crypto/sha256" | |
| "encoding/hex" | |
| "encoding/json" | |
| "net/http" | |
| "strconv" | |
| "time" | |
| ) | |
| // Cache TTLs. Entity reads (settings/products/customers/rate) are long-lived and | |
| // rely on explicit write-invalidation for correctness. Analytics use a short TTL | |
| // instead of precise invalidation, so dashboards stay fresh-enough without a | |
| // per-write hook (writes still best-effort drop the analytics prefix). | |
| const ( | |
| entityTTL = 5 * time.Minute | |
| analyticsTTL = 30 * time.Second | |
| ) | |
| // ---------- key builders (org-scoped) ---------- | |
| // | |
| // Every key begins with "<entity>:<orgID>:..." so DeletePrefix("<entity>:<org>") | |
| // invalidates only that org's data and one org can never read another's bytes. | |
| // orgID here is always a concrete tenant id (> 0); callers must not build keys | |
| // for cross-org / nil-org superadmin reads. | |
| func keySettings(orgID int64) string { | |
| return "settings:" + strconv.FormatInt(orgID, 10) | |
| } | |
| // keyProducts is the cache key for a paginated product list page. It encodes | |
| // every parameter that varies the response (all/q/limit/offset) so distinct | |
| // pages never collide, while keeping the "products:<org>:" prefix so a single | |
| // DeletePrefix invalidates them all. | |
| func keyProducts(orgID int64, includeInactive bool, q string, limit, offset int) string { | |
| return "products:" + strconv.FormatInt(orgID, 10) + | |
| ":all=" + strconv.FormatBool(includeInactive) + | |
| ":q=" + q + | |
| ":l=" + strconv.Itoa(limit) + | |
| ":o=" + strconv.Itoa(offset) | |
| } | |
| func keyProductsPrefix(orgID int64) string { | |
| // Trailing ':' so org 5's prefix does not also match org 50's keys. | |
| return "products:" + strconv.FormatInt(orgID, 10) + ":" | |
| } | |
| // keyCustomers is the cache key for a paginated customer list page. See | |
| // keyProducts for the encoding/prefix rationale. | |
| func keyCustomers(orgID int64, includeInactive bool, q string, limit, offset int) string { | |
| return "customers:" + strconv.FormatInt(orgID, 10) + | |
| ":all=" + strconv.FormatBool(includeInactive) + | |
| ":q=" + q + | |
| ":l=" + strconv.Itoa(limit) + | |
| ":o=" + strconv.Itoa(offset) | |
| } | |
| func keyCustomersPrefix(orgID int64) string { | |
| return "customers:" + strconv.FormatInt(orgID, 10) + ":" | |
| } | |
| // keyRate builds the resolve-rate key. customerID is "-" when no customer is | |
| // supplied (the default-rate lookup) so it never collides with a real id. | |
| func keyRate(orgID, productID int64, customerID *int64, date string) string { | |
| c := "-" | |
| if customerID != nil { | |
| c = strconv.FormatInt(*customerID, 10) | |
| } | |
| return "rate:" + strconv.FormatInt(orgID, 10) + | |
| ":p=" + strconv.FormatInt(productID, 10) + | |
| ":c=" + c + | |
| ":d=" + date | |
| } | |
| func keyRatePrefix(orgID int64) string { | |
| return "rate:" + strconv.FormatInt(orgID, 10) + ":" | |
| } | |
| // keyAnalytics builds an analytics cache key of the form | |
| // "analytics:<orgID>:<name>:<params>". params should encode every query | |
| // parameter that varies the response (e.g. "from=...:to=...") so two distinct | |
| // requests never collide. All analytics keys share the "analytics:<org>" prefix | |
| // so a single DeletePrefix invalidates every rollup for the org. | |
| func keyAnalytics(orgID int64, name, params string) string { | |
| return "analytics:" + strconv.FormatInt(orgID, 10) + ":" + name + ":" + params | |
| } | |
| func keyAnalyticsPrefix(orgID int64) string { | |
| return "analytics:" + strconv.FormatInt(orgID, 10) + ":" | |
| } | |
| // ---------- helpers ---------- | |
| // cacheGetJSON returns the cached marshaled JSON bytes for key, if present. | |
| func (s *Server) cacheGetJSON(key string) ([]byte, bool) { | |
| if s.cache == nil { | |
| return nil, false | |
| } | |
| return s.cache.Get(key) | |
| } | |
| // cacheSetJSON marshals v and stores it under key with ttl. Marshal failures are | |
| // silently ignored (caching is best-effort and must never break a response). | |
| func (s *Server) cacheSetJSON(key string, v any, ttl time.Duration) { | |
| if s.cache == nil { | |
| return | |
| } | |
| b, err := json.Marshal(v) | |
| if err != nil { | |
| return | |
| } | |
| s.cache.Set(key, b, ttl) | |
| } | |
| // writeCachedBytes writes pre-marshaled JSON bytes (a cache hit) as a 200 OK, | |
| // matching writeJSON's content type and trailing newline from json.Encoder. | |
| func writeCachedBytes(w http.ResponseWriter, body []byte) { | |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| w.WriteHeader(http.StatusOK) | |
| _, _ = w.Write(body) | |
| } | |
| // etagFor computes a weak content hash for an ETag: the first 16 hex chars of | |
| // sha256(body), wrapped in quotes per RFC 7232. | |
| func etagFor(body []byte) string { | |
| sum := sha256.Sum256(body) | |
| return `"` + hex.EncodeToString(sum[:])[:16] + `"` | |
| } | |
| // writeJSONCached marshals v, sets Cache-Control + ETag headers, and serves a | |
| // conditional response: if the request's If-None-Match equals the computed ETag | |
| // it returns 304 with no body; otherwise it stores the body under cacheKey | |
| // (best-effort) and writes it with the given status. The TTL drives both the | |
| // cache entry lifetime and the HTTP max-age. A marshal failure falls back to a | |
| // generic 500 (the value should always be JSON-serializable). | |
| func (s *Server) writeJSONCached(w http.ResponseWriter, r *http.Request, status int, v any, cacheKey string, ttl time.Duration) { | |
| body, err := json.Marshal(v) | |
| if err != nil { | |
| internalError(w, err) | |
| return | |
| } | |
| etag := etagFor(body) | |
| w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(ttl.Seconds()))) | |
| w.Header().Set("ETag", etag) | |
| if match := r.Header.Get("If-None-Match"); match != "" && match == etag { | |
| w.WriteHeader(http.StatusNotModified) | |
| return | |
| } | |
| if cacheKey != "" && s.cache != nil { | |
| s.cache.Set(cacheKey, body, ttl) | |
| } | |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| w.WriteHeader(status) | |
| _, _ = w.Write(body) | |
| } | |
| // writeCachedBytesETag serves pre-marshaled bytes from a cache hit with the same | |
| // ETag/Cache-Control semantics as writeJSONCached, including the 304 short-circuit. | |
| func writeCachedBytesETag(w http.ResponseWriter, r *http.Request, body []byte, ttl time.Duration) { | |
| etag := etagFor(body) | |
| w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(ttl.Seconds()))) | |
| w.Header().Set("ETag", etag) | |
| if match := r.Header.Get("If-None-Match"); match != "" && match == etag { | |
| w.WriteHeader(http.StatusNotModified) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| w.WriteHeader(http.StatusOK) | |
| _, _ = w.Write(body) | |
| } | |
| // invalidateAnalytics drops every cached analytics rollup for the org. It is a | |
| // best-effort companion to the short analytics TTL: mutating handlers call it so | |
| // dashboards reflect a write immediately rather than after the TTL lapses. | |
| func (s *Server) invalidateAnalytics(orgID int64) { | |
| if s.cache == nil { | |
| return | |
| } | |
| s.cache.DeletePrefix(keyAnalyticsPrefix(orgID)) | |
| } | |