|
| 1 | +package api |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "log" |
| 7 | + "math/rand" |
| 8 | + "net/http" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/robusta-dev/bidder-service/cache" |
| 12 | + "github.com/robusta-dev/bidder-service/config" |
| 13 | + "github.com/robusta-dev/bidder-service/metrics" |
| 14 | + "github.com/robusta-dev/bidder-service/model" |
| 15 | +) |
| 16 | + |
| 17 | +type BidHandler struct { |
| 18 | + cfg *config.Config |
| 19 | + cache *cache.Handler |
| 20 | + metrics *metrics.Collector |
| 21 | +} |
| 22 | + |
| 23 | +func NewBidHandler(cfg *config.Config, ch *cache.Handler, mc *metrics.Collector) *BidHandler { |
| 24 | + return &BidHandler{ |
| 25 | + cfg: cfg, |
| 26 | + cache: ch, |
| 27 | + metrics: mc, |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +func (bh *BidHandler) HandleBid(w http.ResponseWriter, r *http.Request) { |
| 32 | + start := time.Now() |
| 33 | + defer func() { |
| 34 | + bh.metrics.RecordLatency("bid", time.Since(start)) |
| 35 | + }() |
| 36 | + |
| 37 | + if r.Method != http.MethodPost { |
| 38 | + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
| 39 | + return |
| 40 | + } |
| 41 | + |
| 42 | + var req model.BidRequest |
| 43 | + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 44 | + bh.metrics.RecordError("bid", "invalid_request") |
| 45 | + http.Error(w, "Invalid request body", http.StatusBadRequest) |
| 46 | + return |
| 47 | + } |
| 48 | + |
| 49 | + if err := validateBidRequest(&req); err != nil { |
| 50 | + bh.metrics.RecordError("bid", "validation_failed") |
| 51 | + http.Error(w, err.Error(), http.StatusBadRequest) |
| 52 | + return |
| 53 | + } |
| 54 | + |
| 55 | + // Check cache for campaign data |
| 56 | + cacheKey := fmt.Sprintf("campaign:%s:%s:%s", req.AdSlotID, req.UserSegment, req.GeoCountry) |
| 57 | + cachedBid, found := bh.cache.Get(cacheKey) |
| 58 | + if found { |
| 59 | + bh.metrics.RecordCacheHit("bid") |
| 60 | + resp := cachedBid.(*model.BidResponse) |
| 61 | + bh.metrics.RecordBid(resp.BidCents) |
| 62 | + w.Header().Set("Content-Type", "application/json") |
| 63 | + w.Header().Set("X-Cache", "HIT") |
| 64 | + json.NewEncoder(w).Encode(resp) |
| 65 | + return |
| 66 | + } |
| 67 | + |
| 68 | + bh.metrics.RecordCacheMiss("bid") |
| 69 | + |
| 70 | + // Compute bid |
| 71 | + resp := bh.computeBid(&req) |
| 72 | + |
| 73 | + if resp.BidCents > 0 { |
| 74 | + bh.cache.Set(cacheKey, resp) |
| 75 | + bh.metrics.RecordBid(resp.BidCents) |
| 76 | + } else { |
| 77 | + bh.metrics.RecordNoBid() |
| 78 | + } |
| 79 | + |
| 80 | + w.Header().Set("Content-Type", "application/json") |
| 81 | + w.Header().Set("X-Cache", "MISS") |
| 82 | + json.NewEncoder(w).Encode(resp) |
| 83 | +} |
| 84 | + |
| 85 | +// HandleBulkBid processes multiple bid requests in a single call |
| 86 | +func (bh *BidHandler) HandleBulkBid(w http.ResponseWriter, r *http.Request) { |
| 87 | + start := time.Now() |
| 88 | + defer func() { |
| 89 | + bh.metrics.RecordLatency("bulk_bid", time.Since(start)) |
| 90 | + }() |
| 91 | + |
| 92 | + if r.Method != http.MethodPost { |
| 93 | + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
| 94 | + return |
| 95 | + } |
| 96 | + |
| 97 | + var reqs []model.BidRequest |
| 98 | + if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil { |
| 99 | + bh.metrics.RecordError("bulk_bid", "invalid_request") |
| 100 | + http.Error(w, "Invalid request body", http.StatusBadRequest) |
| 101 | + return |
| 102 | + } |
| 103 | + |
| 104 | + if len(reqs) > 10 { |
| 105 | + http.Error(w, "Maximum 10 bids per bulk request", http.StatusBadRequest) |
| 106 | + return |
| 107 | + } |
| 108 | + |
| 109 | + responses := make([]*model.BidResponse, 0, len(reqs)) |
| 110 | + for _, req := range reqs { |
| 111 | + resp := bh.computeBid(&req) |
| 112 | + if resp.BidCents > 0 { |
| 113 | + cacheKey := fmt.Sprintf("campaign:%s:%s:%s", req.AdSlotID, req.UserSegment, req.GeoCountry) |
| 114 | + bh.cache.Set(cacheKey, resp) |
| 115 | + bh.metrics.RecordBid(resp.BidCents) |
| 116 | + } else { |
| 117 | + bh.metrics.RecordNoBid() |
| 118 | + } |
| 119 | + responses = append(responses, resp) |
| 120 | + } |
| 121 | + |
| 122 | + w.Header().Set("Content-Type", "application/json") |
| 123 | + json.NewEncoder(w).Encode(responses) |
| 124 | +} |
| 125 | + |
| 126 | +func (bh *BidHandler) computeBid(req *model.BidRequest) *model.BidResponse { |
| 127 | + baseBid := bh.calculateBaseBid(req) |
| 128 | + |
| 129 | + // Apply geo targeting modifier |
| 130 | + if bh.cfg.GeoTargeting && req.GeoCountry != "" { |
| 131 | + baseBid = applyGeoModifier(baseBid, req.GeoCountry) |
| 132 | + } |
| 133 | + |
| 134 | + // Apply device modifier |
| 135 | + baseBid = applyDeviceModifier(baseBid, req.DeviceType) |
| 136 | + |
| 137 | + if baseBid < bh.cfg.MinBidFloor { |
| 138 | + return &model.BidResponse{ |
| 139 | + BidID: generateBidID(), |
| 140 | + BidCents: 0, |
| 141 | + NoBid: true, |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + if baseBid > bh.cfg.MaxBidCents { |
| 146 | + baseBid = bh.cfg.MaxBidCents |
| 147 | + } |
| 148 | + |
| 149 | + return &model.BidResponse{ |
| 150 | + BidID: generateBidID(), |
| 151 | + BidCents: baseBid, |
| 152 | + AdMarkup: fmt.Sprintf("<ad campaign='%s' slot='%s' />", req.CampaignID, req.AdSlotID), |
| 153 | + CreativeID: fmt.Sprintf("cr_%s_%s", req.AdSlotID, req.UserSegment), |
| 154 | + NoBid: false, |
| 155 | + } |
| 156 | +} |
| 157 | + |
| 158 | +func (bh *BidHandler) calculateBaseBid(req *model.BidRequest) int { |
| 159 | + base := 100 // 100 cents = $1.00 |
| 160 | + |
| 161 | + switch req.UserSegment { |
| 162 | + case "premium": |
| 163 | + base = 250 |
| 164 | + case "standard": |
| 165 | + base = 150 |
| 166 | + case "retarget": |
| 167 | + base = 300 |
| 168 | + case "lookalike": |
| 169 | + base = 200 |
| 170 | + } |
| 171 | + |
| 172 | + // Apply slot size modifier |
| 173 | + switch req.AdSlotSize { |
| 174 | + case "728x90": |
| 175 | + base = int(float64(base) * 0.8) |
| 176 | + case "300x250": |
| 177 | + base = int(float64(base) * 1.2) |
| 178 | + case "160x600": |
| 179 | + base = int(float64(base) * 0.9) |
| 180 | + case "320x50": |
| 181 | + base = int(float64(base) * 0.7) |
| 182 | + case "970x250": |
| 183 | + base = int(float64(base) * 1.4) |
| 184 | + } |
| 185 | + |
| 186 | + return base |
| 187 | +} |
| 188 | + |
| 189 | +func applyGeoModifier(bid int, country string) int { |
| 190 | + modifiers := map[string]float64{ |
| 191 | + "US": 1.0, |
| 192 | + "UK": 0.95, |
| 193 | + "DE": 0.90, |
| 194 | + "FR": 0.88, |
| 195 | + "JP": 1.10, |
| 196 | + "AU": 0.92, |
| 197 | + "CA": 0.97, |
| 198 | + "BR": 0.70, |
| 199 | + } |
| 200 | + if m, ok := modifiers[country]; ok { |
| 201 | + return int(float64(bid) * m) |
| 202 | + } |
| 203 | + return int(float64(bid) * 0.75) |
| 204 | +} |
| 205 | + |
| 206 | +func applyDeviceModifier(bid int, device string) int { |
| 207 | + modifiers := map[string]float64{ |
| 208 | + "mobile": 1.15, |
| 209 | + "desktop": 1.0, |
| 210 | + "tablet": 0.90, |
| 211 | + "ctv": 1.30, |
| 212 | + } |
| 213 | + if m, ok := modifiers[device]; ok { |
| 214 | + return int(float64(bid) * m) |
| 215 | + } |
| 216 | + return bid |
| 217 | +} |
| 218 | + |
| 219 | +func validateBidRequest(req *model.BidRequest) error { |
| 220 | + if req.AdSlotID == "" { |
| 221 | + return fmt.Errorf("ad_slot_id is required") |
| 222 | + } |
| 223 | + if req.UserSegment == "" { |
| 224 | + return fmt.Errorf("user_segment is required") |
| 225 | + } |
| 226 | + if req.RequestID == "" { |
| 227 | + return fmt.Errorf("request_id is required") |
| 228 | + } |
| 229 | + return nil |
| 230 | +} |
| 231 | + |
| 232 | +func generateBidID() string { |
| 233 | + return fmt.Sprintf("bid_%d_%d", time.Now().UnixNano(), rand.Intn(10000)) |
| 234 | +} |
| 235 | + |
| 236 | +func init() { |
| 237 | + log.Println("Bid handler v2.4.1 initialized") |
| 238 | +} |
0 commit comments