2b4350cfc4
main.go 挂载 notices.Store/Handler:注入 rewardSvc/payWebhook 到账通知钩子, /v1/notices 路由由 accountAPI 空壳换成真实 List/MarkRead,删除 account.go 的 TODO 空壳方法。补齐 pangolin-nodectl 到编译/部署产物链 (compile-backend.sh + deploy-server.sh),release-client.sh 在 version.yaml 推送成功后追加一步用 nodectl 插入 version 类型站内公告 (失败不阻断发版)。
217 lines
7.1 KiB
Go
217 lines
7.1 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})
|
|
}
|