首页接口实现

This commit is contained in:
2026-07-21 16:57:46 +08:00
parent 83d38956ca
commit 5103c08c2b
5 changed files with 918 additions and 177 deletions

File diff suppressed because it is too large Load Diff

56
web/handlers/home.go Normal file
View File

@@ -0,0 +1,56 @@
package handlers
import (
"platform/web/auth"
g "platform/web/globals"
s "platform/web/services"
"github.com/gofiber/fiber/v2"
)
// HomeOverview 后台首页概览统计
func HomeOverview(c *fiber.Ctx) error {
if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil {
return err
}
resp, err := s.Home.Overview()
if err != nil {
return err
}
return c.JSON(resp)
}
// HomeTrend 后台首页每日趋势统计
func HomeTrend(c *fiber.Ctx) error {
if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil {
return err
}
var req s.HomeTrendReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
resp, err := s.Home.Trend(req)
if err != nil {
return err
}
return c.JSON(resp)
}
// HomeExtractable 后台首页全局可提取统计
func HomeExtractable(c *fiber.Ctx) error {
if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil {
return err
}
resp, err := s.Home.Extractable()
if err != nil {
return err
}
return c.JSON(resp)
}

View File

@@ -170,6 +170,12 @@ func userRouter(api fiber.Router) {
func adminRouter(api fiber.Router) { func adminRouter(api fiber.Router) {
api = api.Group("/admin") api = api.Group("/admin")
// home 后台首页
var home = api.Group("/home")
home.Post("/overview", handlers.HomeOverview)
home.Post("/trend", handlers.HomeTrend)
home.Post("/extractable", handlers.HomeExtractable)
// admin 管理员 // admin 管理员
var admin = api.Group("/admin") var admin = api.Group("/admin")
admin.Post("/all", handlers.AllAdminByAdmin) admin.Post("/all", handlers.AllAdminByAdmin)

424
web/services/home.go Normal file
View File

@@ -0,0 +1,424 @@
package services
import (
"fmt"
"platform/web/core"
g "platform/web/globals"
m "platform/web/models"
"time"
"github.com/shopspring/decimal"
)
const homeMaxTrendDays = 366
var Home = &homeService{}
type homeService struct{}
type HomeOverviewResp struct {
PendingCustomerCount int64 `json:"pending_customer_count"`
ActiveChannelCount int64 `json:"active_channel_count"`
TodayTradeAmount string `json:"today_trade_amount"`
TodayExtractCount int64 `json:"today_extract_count"`
TodayNewCustomerCount int64 `json:"today_new_customer_count"`
}
type HomeTrendReq struct {
DateStart string `json:"date_start" validate:"required"`
DateEnd string `json:"date_end" validate:"required"`
}
type HomeTrendResp []HomeTrendItem
type HomeTrendItem struct {
Date string `json:"date"`
ActiveChannelCount int64 `json:"active_channel_count"`
TradeAmount string `json:"trade_amount"`
NewCustomerCount int64 `json:"new_customer_count"`
ExtractCount int64 `json:"extract_count"`
}
type HomeExtractableResp struct {
Short int64 `json:"short"`
Long int64 `json:"long"`
}
type homeRange struct {
StartLocal time.Time
EndLocal time.Time
StartUTC time.Time
EndUTC time.Time
Dates []string
}
type homeCountRow struct {
Date string
Count int64
}
type homeAmountRow struct {
Date string
Amount string
}
func (s *homeService) Overview() (*HomeOverviewResp, error) {
loc := homeLocation()
startLocal := startOfLocalDay(time.Now().In(loc), loc)
rng := homeRange{
StartLocal: startLocal,
EndLocal: startLocal,
StartUTC: startLocal.UTC(),
EndUTC: startLocal.AddDate(0, 0, 1).UTC(),
Dates: []string{homeDateKey(startLocal, loc)},
}
pendingCustomerCount, err := s.pendingCustomerCount()
if err != nil {
return nil, err
}
activeChannelCount, err := s.activeChannelCount(time.Now().UTC())
if err != nil {
return nil, err
}
tradeAmount, err := s.tradeAmount(rng.StartUTC, rng.EndUTC)
if err != nil {
return nil, err
}
extractCount, err := s.extractCount(rng.StartUTC, rng.EndUTC)
if err != nil {
return nil, err
}
newCustomerCount, err := s.newCustomerCount(rng.StartUTC, rng.EndUTC)
if err != nil {
return nil, err
}
return &HomeOverviewResp{
PendingCustomerCount: pendingCustomerCount,
ActiveChannelCount: activeChannelCount,
TodayTradeAmount: tradeAmount,
TodayExtractCount: extractCount,
TodayNewCustomerCount: newCustomerCount,
}, nil
}
func (s *homeService) Trend(req HomeTrendReq) (HomeTrendResp, error) {
rng, err := newHomeRange(req.DateStart, req.DateEnd)
if err != nil {
return nil, err
}
items := make(HomeTrendResp, 0, len(rng.Dates))
byDate := make(map[string]int, len(rng.Dates))
for _, date := range rng.Dates {
byDate[date] = len(items)
items = append(items, HomeTrendItem{
Date: date,
TradeAmount: "0.00",
})
}
activeChannels, err := s.dailyActiveChannelCounts(rng)
if err != nil {
return nil, err
}
for _, row := range activeChannels {
if index, ok := byDate[row.Date]; ok {
items[index].ActiveChannelCount = row.Count
}
}
trades, err := s.dailyTradeAmounts(rng)
if err != nil {
return nil, err
}
for _, row := range trades {
if index, ok := byDate[row.Date]; ok {
items[index].TradeAmount = normalizeDecimalString(row.Amount)
}
}
newCustomers, err := s.dailyNewCustomerCounts(rng)
if err != nil {
return nil, err
}
for _, row := range newCustomers {
if index, ok := byDate[row.Date]; ok {
items[index].NewCustomerCount = row.Count
}
}
extracts, err := s.dailyExtractCounts(rng)
if err != nil {
return nil, err
}
for _, row := range extracts {
if index, ok := byDate[row.Date]; ok {
items[index].ExtractCount = row.Count
}
}
return items, nil
}
func (s *homeService) Extractable() (*HomeExtractableResp, error) {
loc := homeLocation()
now := time.Now().In(loc)
var resources []*m.Resource
err := g.DB.
Preload("Short").
Preload("Long").
Where("active = ?", true).
Find(&resources).Error
if err != nil {
return nil, err
}
resp := &HomeExtractableResp{}
for _, resource := range resources {
switch resource.Type {
case m.ResourceTypeShort:
resp.Short += calcShortExtractable(resource.Short, now, loc)
case m.ResourceTypeLong:
resp.Long += calcLongExtractable(resource.Long, now, loc)
}
}
return resp, nil
}
func (s *homeService) pendingCustomerCount() (int64, error) {
var count int64
err := g.DB.Model(&m.User{}).Where("admin_id IS NULL").Count(&count).Error
return count, err
}
func (s *homeService) activeChannelCount(now time.Time) (int64, error) {
var count int64
err := g.DB.Model(&m.Channel{}).Where("expired_at > ?", now.UTC()).Count(&count).Error
return count, err
}
func (s *homeService) tradeAmount(startUTC, endUTC time.Time) (string, error) {
var amount string
err := g.DB.Raw(`
SELECT COALESCE(SUM(payment), 0)::text
FROM "trade"
WHERE deleted_at IS NULL
AND status = ?
AND completed_at >= ?
AND completed_at < ?
`, int(m.TradeStatusSuccess), startUTC, endUTC).Scan(&amount).Error
return normalizeDecimalString(amount), err
}
func (s *homeService) extractCount(startUTC, endUTC time.Time) (int64, error) {
var count int64
err := g.DB.Raw(`
SELECT COALESCE(SUM(count), 0)
FROM logs_user_usage
WHERE time >= ?
AND time < ?
`, startUTC, endUTC).Scan(&count).Error
return count, err
}
func (s *homeService) newCustomerCount(startUTC, endUTC time.Time) (int64, error) {
var count int64
err := g.DB.Model(&m.User{}).
Where("created_at >= ? AND created_at < ?", startUTC, endUTC).
Count(&count).Error
return count, err
}
func (s *homeService) dailyActiveChannelCounts(rng homeRange) ([]homeCountRow, error) {
var rows []homeCountRow
err := g.DB.Raw(`
WITH days AS (
SELECT generate_series(?::timestamptz, ?::timestamptz - interval '1 day', interval '1 day') AS day_start
)
SELECT
to_char(days.day_start AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COUNT(channel.id) AS count
FROM days
LEFT JOIN channel
ON channel.deleted_at IS NULL
AND channel.created_at < days.day_start + interval '1 day'
AND channel.expired_at >= days.day_start
GROUP BY days.day_start
ORDER BY days.day_start
`, rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func (s *homeService) dailyTradeAmounts(rng homeRange) ([]homeAmountRow, error) {
var rows []homeAmountRow
err := g.DB.Raw(`
SELECT
to_char(completed_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COALESCE(SUM(payment), 0)::text AS amount
FROM "trade"
WHERE deleted_at IS NULL
AND status = ?
AND completed_at >= ?
AND completed_at < ?
GROUP BY date
ORDER BY date
`, int(m.TradeStatusSuccess), rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func (s *homeService) dailyNewCustomerCounts(rng homeRange) ([]homeCountRow, error) {
var rows []homeCountRow
err := g.DB.Raw(`
SELECT
to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM "user"
WHERE deleted_at IS NULL
AND created_at >= ?
AND created_at < ?
GROUP BY date
ORDER BY date
`, rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func (s *homeService) dailyExtractCounts(rng homeRange) ([]homeCountRow, error) {
var rows []homeCountRow
err := g.DB.Raw(`
SELECT
to_char(time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COALESCE(SUM(count), 0) AS count
FROM logs_user_usage
WHERE time >= ?
AND time < ?
GROUP BY date
ORDER BY date
`, rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func newHomeRange(dateStart, dateEnd string) (homeRange, error) {
loc := homeLocation()
startLocal, err := time.ParseInLocation(time.DateOnly, dateStart, loc)
if err != nil {
return homeRange{}, core.NewBizErr("date_start 必须是 YYYY-MM-DD 格式", err)
}
endLocal, err := time.ParseInLocation(time.DateOnly, dateEnd, loc)
if err != nil {
return homeRange{}, core.NewBizErr("date_end 必须是 YYYY-MM-DD 格式", err)
}
if endLocal.Before(startLocal) {
return homeRange{}, core.NewBizErr("date_end 不能早于 date_start")
}
days := int(endLocal.Sub(startLocal).Hours()/24) + 1
if days > homeMaxTrendDays {
return homeRange{}, core.NewBizErr(fmt.Sprintf("日期范围不能超过 %d 天", homeMaxTrendDays))
}
dates := make([]string, 0, days)
for day := startLocal; !day.After(endLocal); day = day.AddDate(0, 0, 1) {
dates = append(dates, homeDateKey(day, loc))
}
return homeRange{
StartLocal: startLocal,
EndLocal: endLocal,
StartUTC: startLocal.UTC(),
EndUTC: endLocal.AddDate(0, 0, 1).UTC(),
Dates: dates,
}, nil
}
func calcShortExtractable(resource *m.ResourceShort, now time.Time, loc *time.Location) int64 {
if resource == nil {
return 0
}
return calcExtractable(resource.Type, resource.Quota, resource.Used, resource.Daily, resource.LastAt, resource.ExpireAt, now, loc)
}
func calcLongExtractable(resource *m.ResourceLong, now time.Time, loc *time.Location) int64 {
if resource == nil {
return 0
}
return calcExtractable(resource.Type, resource.Quota, resource.Used, resource.Daily, resource.LastAt, resource.ExpireAt, now, loc)
}
func calcExtractable(mode m.ResourceMode, quota, used, daily int32, lastAt, expireAt *time.Time, now time.Time, loc *time.Location) int64 {
switch mode {
case m.ResourceModeQuota:
return maxInt64(int64(quota)-int64(used), 0)
case m.ResourceModeTime:
if expireAt == nil || !expireAt.After(now) {
return 0
}
todayRemaining := int64(quota)
if lastAt != nil && sameLocalDate(*lastAt, now, loc) {
todayRemaining = maxInt64(int64(quota)-int64(daily), 0)
}
todayStart := startOfLocalDay(now, loc)
expireStart := startOfLocalDay(expireAt.In(loc), loc)
futureDays := int64(expireStart.Sub(todayStart).Hours() / 24)
if futureDays < 0 {
futureDays = 0
}
return todayRemaining + futureDays*int64(quota)
}
return 0
}
func homeLocation() *time.Location {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("Asia/Shanghai", 8*60*60)
}
return loc
}
func startOfLocalDay(t time.Time, loc *time.Location) time.Time {
y, m, d := t.In(loc).Date()
return time.Date(y, m, d, 0, 0, 0, 0, loc)
}
func sameLocalDate(a, b time.Time, loc *time.Location) bool {
ay, am, ad := a.In(loc).Date()
by, bm, bd := b.In(loc).Date()
return ay == by && am == bm && ad == bd
}
func homeDateKey(t time.Time, loc *time.Location) string {
return t.In(loc).Format(time.DateOnly)
}
func normalizeDecimalString(s string) string {
if s == "" {
return "0.00"
}
d, err := decimal.NewFromString(s)
if err != nil {
return "0.00"
}
return d.StringFixed(2)
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}

87
web/services/home_test.go Normal file
View File

@@ -0,0 +1,87 @@
package services
import (
m "platform/web/models"
"testing"
"time"
)
func TestNewHomeRange(t *testing.T) {
rng, err := newHomeRange("2026-07-20", "2026-07-21")
if err != nil {
t.Fatalf("newHomeRange returned error: %v", err)
}
if got, want := len(rng.Dates), 2; got != want {
t.Fatalf("len(Dates) = %d, want %d", got, want)
}
if got, want := rng.Dates[0], "2026-07-20"; got != want {
t.Fatalf("Dates[0] = %s, want %s", got, want)
}
if got, want := rng.Dates[1], "2026-07-21"; got != want {
t.Fatalf("Dates[1] = %s, want %s", got, want)
}
if got, want := rng.StartUTC.Format(time.RFC3339), "2026-07-19T16:00:00Z"; got != want {
t.Fatalf("StartUTC = %s, want %s", got, want)
}
if got, want := rng.EndUTC.Format(time.RFC3339), "2026-07-21T16:00:00Z"; got != want {
t.Fatalf("EndUTC = %s, want %s", got, want)
}
}
func TestNewHomeRangeRejectsInvalidRange(t *testing.T) {
_, err := newHomeRange("2026-07-21", "2026-07-20")
if err == nil {
t.Fatal("newHomeRange expected error")
}
}
func TestCalcExtractableQuota(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeQuota, 100, 30, 0, nil, nil, now, loc)
if want := int64(70); got != want {
t.Fatalf("calcExtractable quota = %d, want %d", got, want)
}
got = calcExtractable(m.ResourceModeQuota, 100, 130, 0, nil, nil, now, loc)
if want := int64(0); got != want {
t.Fatalf("calcExtractable exhausted quota = %d, want %d", got, want)
}
}
func TestCalcExtractableTimeCountsTodayAndExpiryDate(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
lastAt := time.Date(2026, 7, 21, 9, 0, 0, 0, loc)
expireAt := time.Date(2026, 7, 23, 12, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeTime, 100, 0, 30, &lastAt, &expireAt, now, loc)
if want := int64(270); got != want {
t.Fatalf("calcExtractable time = %d, want %d", got, want)
}
}
func TestCalcExtractableTimeResetsDailyWhenLastAtIsNotToday(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
lastAt := time.Date(2026, 7, 20, 23, 59, 59, 0, loc)
expireAt := time.Date(2026, 7, 21, 23, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeTime, 100, 0, 80, &lastAt, &expireAt, now, loc)
if want := int64(100); got != want {
t.Fatalf("calcExtractable time reset = %d, want %d", got, want)
}
}
func TestCalcExtractableTimeExpired(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
expireAt := time.Date(2026, 7, 21, 9, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeTime, 100, 0, 0, nil, &expireAt, now, loc)
if want := int64(0); got != want {
t.Fatalf("calcExtractable expired time = %d, want %d", got, want)
}
}