package usage import ( "encoding/json" "net/http" "strconv" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/codes" ) // userIDFromContext extracts the authenticated user ID set by the auth // middleware, or 0 if absent. // // 关键:必须读 codes.CtxKeyUserID —— auth.RequireAuth 正是把 user id 存在这个键 // 下。此前本包自定义了同名常量 ctxKey("user_id"),但 Go 的 context key 按 // 「类型+值」比较,命名类型不同 → 取不到 → /v1/usage 恒 401(统计页全 0)。 func userIDFromContext(r *http.Request) int64 { id, _ := r.Context().Value(codes.CtxKeyUserID).(int64) return id } // UsageHandler serves GET /v1/usage?days=N. type UsageHandler struct { svc *Service } // NewUsageHandler creates a UsageHandler. func NewUsageHandler(svc *Service) *UsageHandler { return &UsageHandler{svc: svc} } type usageResponse struct { Points []UsagePoint `json:"points"` } // ServeHTTP implements http.Handler. func (h *UsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } userID := userIDFromContext(r) if userID == 0 { apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{ Code: "UNAUTHORIZED", MessageZH: "请先登录", MessageEn: "Authentication required", }) return } days := 7 if v := r.URL.Query().Get("days"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 90 { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } days = n } // tz_offset: client UTC offset in minutes (e.g. 480 for UTC+8), so the curve // is bucketed by the user's LOCAL calendar day. Absent/invalid → 0 (UTC). offsetMin := 0 if v := r.URL.Query().Get("tz_offset"); v != "" { if n, err := strconv.Atoi(v); err == nil { offsetMin = n } } // device (UUID, optional): when set, return that device's curve only (#10②). deviceUUID := r.URL.Query().Get("device") points, apiErr := h.svc.UsageCurve(r.Context(), userID, days, offsetMin, deviceUUID) if apiErr != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apiErr) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(usageResponse{Points: points}) } // DeviceUsageHandler serves GET /v1/usage/devices?days=N — per-device usage // totals for the authenticated account ("下分设备" breakdown). type DeviceUsageHandler struct { svc *Service } // NewDeviceUsageHandler creates a DeviceUsageHandler. func NewDeviceUsageHandler(svc *Service) *DeviceUsageHandler { return &DeviceUsageHandler{svc: svc} } type deviceUsageResponse struct { Devices []DeviceUsagePoint `json:"devices"` } // ServeHTTP implements http.Handler. func (h *DeviceUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } userID := userIDFromContext(r) if userID == 0 { apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{ Code: "UNAUTHORIZED", MessageZH: "请先登录", MessageEn: "Authentication required", }) return } days := 7 if v := r.URL.Query().Get("days"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 90 { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } days = n } devices, apiErr := h.svc.DeviceUsage(r.Context(), userID, days) if apiErr != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apiErr) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(deviceUsageResponse{Devices: devices}) } // AdsUnlockHandler serves POST /v1/ads/unlock. type AdsUnlockHandler struct { svc *Service } // NewAdsUnlockHandler creates an AdsUnlockHandler. func NewAdsUnlockHandler(svc *Service) *AdsUnlockHandler { return &AdsUnlockHandler{svc: svc} } type adsUnlockRequest struct { DeviceID string `json:"device_id"` AdToken string `json:"ad_token"` } // adsUnlockResponse reports the minutes granted by this rewarded-ad view and // the account's new remaining minutes for today, so the client can refresh its // quota without a second /me round-trip. type adsUnlockResponse struct { GrantedMinutes int `json:"granted_minutes"` MinutesRemaining int `json:"minutes_remaining"` } // ServeHTTP implements http.Handler. On success it returns 200 with the granted // minutes and new remaining quota (免费版累加式看广告加时). func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } userID := userIDFromContext(r) if userID == 0 { apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{ Code: "UNAUTHORIZED", MessageZH: "请先登录", MessageEn: "Authentication required", }) return } var req adsUnlockRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&req); err != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } granted, remaining, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken) if apiErr != nil { apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(adsUnlockResponse{GrantedMinutes: granted, MinutesRemaining: remaining}) } // adsErrorStatus maps an ads-unlock error code to an HTTP status. func adsErrorStatus(code string) int { switch code { case "AD_VERIFY_FAILED": return http.StatusForbidden case "AD_TOKEN_REPLAY": return http.StatusConflict case "BAD_REQUEST": return http.StatusBadRequest case "INTERNAL_ERROR": return http.StatusInternalServerError default: return http.StatusBadRequest } }