Spaces:
Runtime error
Runtime error
File size: 6,631 Bytes
affd5ec f1dd8f4 affd5ec f1dd8f4 affd5ec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | 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))
}
|