package httpapi import ( "context" "database/sql" "encoding/json" "net/http" "time" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/auth" ) // AccountAPI serves /v1/me and supporting endpoints. type AccountAPI struct { db *sql.DB } // NewAccountAPI creates an AccountAPI. func NewAccountAPI(db *sql.DB) *AccountAPI { return &AccountAPI{db: db} } // ─── GET /v1/me ────────────────────────────────────────────────────────────── type meResponse struct { UUID string `json:"uuid"` Email string `json:"email"` DpUUID string `json:"dp_uuid"` Plan string `json:"plan"` // "free" | "pro" | "team" // expires_at: RFC3339 UTC, null = no active subscription. ExpiresAt *string `json:"expires_at"` // Web user-center fields. DevicesUsed int `json:"devices_used"` DevicesMax int `json:"devices_max"` QuotaTodayMin *int `json:"quota_today_min"` // 剩余分钟; null = unlimited (pro/team) QuotaCapMin *int `json:"quota_cap_min"` // 当日额度 = daily + 看广告 bonus; null = unlimited DataTodayGB float64 `json:"data_today_gb"` WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest TOTPEnabled bool `json:"totp_enabled"` } const bytesPerGB = 1024.0 * 1024.0 * 1024.0 // GetMe handles GET /v1/me. func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { ctx := r.Context() uid, ok := auth.UserIDFromContext(ctx) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } var ( uuid string email string dpUUID string totpEnabled bool ) if err := a.db.QueryRowContext(ctx, `SELECT uuid, email, dp_uuid, totp_enabled FROM users WHERE id = ? AND status = 'active'`, uid, ).Scan(&uuid, &email, &dpUUID, &totpEnabled); err == sql.ErrNoRows { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) return } else if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } // Best active subscription → plan code + limits + expiry. Falls back to free. var ( planCode string maxDevices int dailyMinutes sql.NullInt64 expiresAt sql.NullTime ) err := a.db.QueryRowContext(ctx, ` SELECT p.code, p.max_devices, p.daily_minutes, s.expires_at FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.user_id = ? AND s.expires_at > ? ORDER BY s.expires_at DESC LIMIT 1 `, uid, time.Now().UTC()).Scan(&planCode, &maxDevices, &dailyMinutes, &expiresAt) if err == sql.ErrNoRows { planCode = "free" if err := a.db.QueryRowContext(ctx, `SELECT max_devices, daily_minutes FROM plans WHERE code = 'free'`, ).Scan(&maxDevices, &dailyMinutes); err != nil && err != sql.ErrNoRows { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } } else if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } var devicesUsed int _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices WHERE user_id = ?`, uid).Scan(&devicesUsed) // Today's usage (UTC). Missing row → zero. // 日期在 Go 端算好传 ?,不用 MySQL 专属 UTC_DATE()(否则 SQLite 报错→恒 0)。 var todayBytes uint64 var todayMinutes int var todayAdBonus int today := time.Now().UTC().Format("2006-01-02") _ = a.db.QueryRowContext(ctx, ` SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0), COALESCE(ad_bonus_minutes, 0) FROM usage_daily WHERE user_id = ? AND date = ? `, uid, today).Scan(&todayBytes, &todayMinutes, &todayAdBonus) resp := meResponse{ UUID: uuid, Email: email, DpUUID: dpUUID, Plan: planCode, DevicesUsed: devicesUsed, DevicesMax: maxDevices, DataTodayGB: float64(todayBytes) / bytesPerGB, WeeklyGB: a.weeklyGB(ctx, uid), TOTPEnabled: totpEnabled, } if expiresAt.Valid { s := expiresAt.Time.UTC().Format(time.RFC3339) resp.ExpiresAt = &s } // quota_today_min: null when the plan is unlimited, else remaining minutes. // 额度 = plan.daily_minutes + 当日看广告累加的 ad_bonus_minutes(账户共享)。 if dailyMinutes.Valid { cap := int(dailyMinutes.Int64) + todayAdBonus rem := cap - todayMinutes if rem < 0 { rem = 0 } resp.QuotaCapMin = &cap resp.QuotaTodayMin = &rem } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(resp) } // weeklyGB returns total GB per day for the last 7 UTC days (oldest→newest), // filling days with no usage row as 0. func (a *AccountAPI) weeklyGB(ctx context.Context, uid int64) []float64 { out := make([]float64, 7) // 起始日 Go 端算好传 ?(避免 MySQL 专属 UTC_DATE()/INTERVAL,SQLite 不支持)。 weekFrom := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02") rows, err := a.db.QueryContext(ctx, ` SELECT date, COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0) FROM usage_daily WHERE user_id = ? AND date >= ? `, uid, weekFrom) if err != nil { return out } defer rows.Close() byDay := make(map[string]uint64, 7) for rows.Next() { var d time.Time var b uint64 if err := rows.Scan(&d, &b); err != nil { return out } byDay[d.UTC().Format("2006-01-02")] = b } now := time.Now().UTC() for i := 0; i < 7; i++ { day := now.AddDate(0, 0, -(6 - i)).Format("2006-01-02") out[i] = float64(byDay[day]) / bytesPerGB } return out } // ─── GET /v1/plans ─────────────────────────────────────────────────────────── type planResponse struct { Code string `json:"code"` DailyMinutes *int64 `json:"daily_minutes"` // null = unlimited AdGate bool `json:"ad_gate"` PriceCents int `json:"price_cents"` // 价格(分);0 = 免费 Currency string `json:"currency"` // CNY / USD … Period string `json:"period"` // month / year } // ListPlans handles GET /v1/plans. // 套餐名为 UI 文案(客户端按 code 取 l10n),plans 表不含名称列,故不查 name_*。 func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT code, daily_minutes, ad_gate, price_cents, currency, period FROM plans ORDER BY id`) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } defer rows.Close() var plans []planResponse for rows.Next() { var p planResponse var dm sql.NullInt64 if err := rows.Scan(&p.Code, &dm, &p.AdGate, &p.PriceCents, &p.Currency, &p.Period); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } if dm.Valid { p.DailyMinutes = &dm.Int64 } plans = append(plans, p) } if err := rows.Err(); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"plans": plans}) } // ─── GET /v1/notices ───────────────────────────────────────────────────────── // ListNotices handles GET /v1/notices. Returns an empty list for the MVP. func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}}) }