Files
pangolin/server/internal/usage/handler.go
T
wangjia f2499a64e1
ci-pangolin / Lint — shellcheck (push) Successful in 7s
ci-pangolin / OpenAPI Sync Check (push) Successful in 16s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 4s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m8s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
fix(usage): 统计按客户端本地日聚合 —— 修「29号却显示28号数据」时区 bug
根因:服务端用量全按 UTC 预切天,UTC+8 用户在中国 0-8 点时 UTC 还是前一天,
「今天」就显示前一天数据。

改法(用户拍板:小时桶+查询带时区偏移):
- migration 000017 `usage_hourly`(UTC 纪元小时整数桶,稀疏存储,空小时不落行)。
- ReportUsage 同时累加 usage_hourly(按 windowEnd 的 UTC 小时);usage_daily 保留作配额/今日。
- `UsageCurve(days, offsetMin)`:读 UTC 小时桶,按客户端时区偏移**重新聚合成本地日**曲线。
- `/v1/usage?tz_offset=分钟`;客户端 `usage()` 带 `DateTime.now().timeZoneOffset.inMinutes`。
- 客户端早已「取曲线最后一点当今日」,故只改服务端聚合 + 带偏移即可。

测试:store 时区分桶证明(UTC 18:00 在 UTC vs UTC+8 落不同本地日,锁住 bug 修复)+
migration v17 + usage_hourly 表;集成测试改喂 hourly;全量 go/flutter test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:02:04 +08:00

192 lines
5.3 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
}
}
points, apiErr := h.svc.UsageCurve(r.Context(), userID, days, offsetMin)
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"`
}
// 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
}
}