e023fb6579
免费版此前形同虚设:ConnectNode 每次连接发固定 daily_minutes×1min TTL、
从不扣减已用,重连即崭新 10 分钟,日额度从未强制。改为账户级(全设备共享)
真卡控 + 累加式看广告加时:
- migration 000020: usage_daily 加 ad_bonus_minutes(sqlite+mysql)。
当日额度 = plans.daily_minutes + ad_bonus_minutes;剩余 = 额度 − minutes_used。
- usage.store.AddAdBonusMinutes: 事务内累加封顶(替代布尔 MarkAdUnlocked),
返回新总额+本次实际加时;GetDay/GetUsageRange 补读 ad_bonus_minutes。
- usage.service.UnlockAd: verify+nonce 后 +adBonusPerAd(10) 封顶 adDailyBonusCeiling(120),
返回 granted+remaining;TodaySummary 加 MinutesCap/AdBonusMinutes。
- usage.ads DevVerifier: 放行式占位校验(nonce 防重放仍生效),接真 AdMob 前
让看广告加时流程可端到端跑通;main.go 按 ADS_DEV_MODE/默认装配。
- ads/unlock: 204 → 200 返回 {granted_minutes, minutes_remaining}。
- ConnectNode 免费门: 读 AccountDayMinutes(used,bonus) → remaining≤0 拒 QUOTA_EXHAUSTED,
否则 TTL=remaining(凭证到点硬切断兜底)。nodes.store 加 AccountDayMinutes。
- /me: 补 ad_bonus_minutes,quota_today_min 分母改 daily+bonus,加 quota_cap_min。
- quota.CheckFreeConnect 同步累加语义(免费基础 10 分钟不再需先看广告)。
- openapi + 契约/迁移版本/集成测试同步;新增 SQLite AddAdBonusMinutes 单测。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
6.0 KiB
Go
204 lines
6.0 KiB
Go
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
|
|
}
|
|
}
|