首页接口实现
This commit is contained in:
424
web/services/home.go
Normal file
424
web/services/home.go
Normal 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
87
web/services/home_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user