Files
x_gift_bot/internal/site/stats.go
T
mizorewww a31de399ad feat: add admin statistics dashboard and polling resume
Add GET /api/admin/stats aggregating code counts, redemption and
success rates, per-plan totals, a 30-day activity series, and review
stage distribution. Render a collapsible stats panel with summary
cards and an accessible SVG bar chart on the admin page, show total
count and pages in pagination, and let users resume status polling
after the 5-minute timeout without re-redeeming.
2026-10-03 11:38:34 +08:00

176 lines
4.7 KiB
Go

package site
import (
"database/sql"
"math"
"net/http"
"time"
)
type statsMonthsRow struct {
Months int `json:"months"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
}
type statsDailyRow struct {
Date string `json:"date"`
Created int `json:"created"`
Redeemed int `json:"redeemed"`
Succeeded int `json:"succeeded"`
}
type statsStageRow struct {
Progress int `json:"progress"`
Count int `json:"count"`
}
func rate(part, whole int) float64 {
if whole == 0 {
return 0
}
return math.Round(float64(part)/float64(whole)*10000) / 10000
}
func (s *server) stats(w http.ResponseWriter, r *http.Request) {
tx, err := s.db.BeginTx(r.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
message(w, 503, "无法读取统计。")
return
}
defer tx.Rollback()
codes := map[string]int{"total": 0, "active": 0, "processing": 0, "review": 0, "succeeded": 0, "revoked": 0, "redeemed": 0, "unfiled": 0}
rows, err := tx.Query("SELECT status,COUNT(*) FROM codes GROUP BY status")
if err != nil {
message(w, 503, "无法读取统计。")
return
}
for rows.Next() {
var state string
var count int
if err = rows.Scan(&state, &count); err != nil {
rows.Close()
message(w, 503, "无法读取统计。")
return
}
codes[state] = count
codes["total"] += count
}
err = rows.Err()
rows.Close()
if err != nil {
message(w, 503, "无法读取统计。")
return
}
var redeemed, unfiled int
if err = tx.QueryRow("SELECT COUNT(*) FROM codes WHERE username<>''").Scan(&redeemed); err != nil {
message(w, 503, "无法读取统计。")
return
}
if err = tx.QueryRow("SELECT COUNT(*) FROM codes WHERE folder_id IS NULL").Scan(&unfiled); err != nil {
message(w, 503, "无法读取统计。")
return
}
codes["redeemed"] = redeemed
codes["unfiled"] = unfiled
months := []statsMonthsRow{}
rows, err = tx.Query("SELECT months,COUNT(*),SUM(status='succeeded') FROM codes GROUP BY months ORDER BY months")
if err != nil {
message(w, 503, "无法读取统计。")
return
}
for rows.Next() {
var m statsMonthsRow
if err = rows.Scan(&m.Months, &m.Total, &m.Succeeded); err != nil {
rows.Close()
message(w, 503, "无法读取统计。")
return
}
months = append(months, m)
}
err = rows.Err()
rows.Close()
if err != nil {
message(w, 503, "无法读取统计。")
return
}
now := time.Now()
year, month, day := now.Date()
today := time.Date(year, month, day, 0, 0, 0, 0, now.Location())
since := today.AddDate(0, 0, -29).Unix()
createdByDay := map[string]int{}
redeemedByDay := map[string]int{}
succeededByDay := map[string]int{}
dailyQueries := []struct {
sql string
into map[string]int
}{
{"SELECT date(created,'unixepoch','localtime'),COUNT(*) FROM codes WHERE created>=? GROUP BY 1", createdByDay},
{"SELECT date(updated,'unixepoch','localtime'),COUNT(*) FROM codes WHERE username<>'' AND updated>=? GROUP BY 1", redeemedByDay},
{"SELECT date(updated,'unixepoch','localtime'),COUNT(*) FROM codes WHERE status='succeeded' AND updated>=? GROUP BY 1", succeededByDay},
}
for _, q := range dailyQueries {
rows, err = tx.Query(q.sql, since)
if err != nil {
message(w, 503, "无法读取统计。")
return
}
for rows.Next() {
var date string
var count int
if err = rows.Scan(&date, &count); err != nil {
rows.Close()
message(w, 503, "无法读取统计。")
return
}
q.into[date] = count
}
err = rows.Err()
rows.Close()
if err != nil {
message(w, 503, "无法读取统计。")
return
}
}
daily := []statsDailyRow{}
for i := 29; i >= 0; i-- {
date := today.AddDate(0, 0, -i).Format("2006-01-02")
daily = append(daily, statsDailyRow{Date: date, Created: createdByDay[date], Redeemed: redeemedByDay[date], Succeeded: succeededByDay[date]})
}
stages := []statsStageRow{}
rows, err = tx.Query("SELECT progress,COUNT(*) FROM codes WHERE status IN ('review','processing') GROUP BY progress ORDER BY progress")
if err != nil {
message(w, 503, "无法读取统计。")
return
}
for rows.Next() {
var stage statsStageRow
if err = rows.Scan(&stage.Progress, &stage.Count); err != nil {
rows.Close()
message(w, 503, "无法读取统计。")
return
}
stages = append(stages, stage)
}
err = rows.Err()
rows.Close()
if err != nil {
message(w, 503, "无法读取统计。")
return
}
if err = tx.Commit(); err != nil {
message(w, 503, "无法读取一致的统计,请刷新重试。")
return
}
reply(w, 200, map[string]any{
"codes": codes,
"rates": map[string]float64{
"redeemed": rate(codes["redeemed"], codes["total"]),
"success": rate(codes["succeeded"], codes["redeemed"]),
},
"months": months,
"daily": daily,
"review_stages": stages,
})
}