Files
pangolin/server/internal/httpapi/account.go
T
wangjia e023fb6579 feat(server): 免费版账户级分钟卡控 + 累加式看广告加时(#21 后端)
免费版此前形同虚设: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>
2026-07-01 23:44:10 +08:00

225 lines
7.5 KiB
Go

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{}})
}