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 }