Files
pangolin/server/internal/usage/handler.go
T
wangjia ece8ea3b57 feat(usage): usage_daily 聚合 + 广告解锁 + free 额度校验 (tsk_1taxhtV2k3RP)
server/internal/usage 模块实现(仅字节/分钟,无目的地,符合无日志口径):

- aggregator.go:实现 #5 的 ReportUsage 回调接口(UsageReporter),
  dp_uuid→user_id 走内存+Redis 短缓存,按上报批次 id Redis 去重防重放,
  按 At 的 UTC 日期 INSERT ... ON DUPLICATE KEY UPDATE 累加,并发安全。
- store.go:usage_daily 累加/区间查询/当日查询、MarkAdUnlocked(事务+FOR
  UPDATE 幂等)、EffectivePlan(活跃订阅取最高 tier,无则回落 free)。
- ads.go:AdVerifier 接口 + AdMob SSV 实现(拉取并缓存 Google ECDSA 公钥,
  ECDSA-SHA256 验签 + custom_data/ad_unit 匹配),Unity 留接口占位。
- service.go:UsageCurve(空日补零、仅当前用户)、TodaySummary(/v1/me)、
  UnlockAd(验签→ad_token 一次性 nonce 去重→写 ad_unlocked_at,当日二次幂等)。
- quota.go:CheckFreeConnect 供 #5 connect 使用:ad_gate=true 校验当日
  ad_unlocked_at + minutes_used<daily_minutes(free=10),返回剩余分钟;
  pro/team 不受 ad_gate 限制(返回 Unlimited);带双语 code 错误。
- handler.go:GET /v1/usage?days=7、POST /v1/ads/unlock(204)。
- apierr:新增 AD_NOT_UNLOCKED/QUOTA_EXHAUSTED/AD_VERIFY_FAILED/AD_TOKEN_REPLAY。
- openapi:/ads/unlock 补 409 Conflict(重放)。

测试:ads_test.go 单测覆盖 AdMob 验签全链路(合法/伪造/custom_data 不符/
ad_unit 不允许/畸形 token)+ Unity 未实现;usage_integration_test.go
(testcontainers, build tag integration) 覆盖并发多节点累加、批次重放去重、
跨 UTC 日界分桶、未知 dp_uuid 丢弃、free 额度四态、ads 解锁伪造/重放/幂等、
曲线补零/隔离、/me 摘要。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:16:50 +08:00

135 lines
3.6 KiB
Go

package usage
import (
"encoding/json"
"net/http"
"strconv"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// ctxKey is the context key type for request-scoped values. Defined here to
// avoid an import cycle; the JWT auth middleware sets the same key.
type ctxKey string
// CtxKeyUserID is the context key carrying the authenticated int64 user ID.
const CtxKeyUserID ctxKey = "user_id"
// userIDFromContext extracts the authenticated user ID set by the auth
// middleware, or 0 if absent.
func userIDFromContext(r *http.Request) int64 {
id, _ := r.Context().Value(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
}
points, apiErr := h.svc.UsageCurve(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(usageResponse{Points: points})
}
// 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"`
}
// ServeHTTP implements http.Handler. On success it returns 204 No Content
// (matching the OpenAPI contract), including the idempotent already-unlocked
// case.
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
}
_, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken)
if apiErr != nil {
apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr)
return
}
w.WriteHeader(http.StatusNoContent)
}
// 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
}
}