425 lines
11 KiB
Go
425 lines
11 KiB
Go
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
|
|
}
|