From b70995f8e96f74507324dded13f1523ad1067030 Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Wed, 27 May 2026 02:06:31 +0800 Subject: [PATCH] fix: aggregate dashboard trends by database dialect --- src/model/data/order_data_stats.go | 117 +++++++++++++-------- src/model/data/order_data_test.go | 50 +++++++++ src/route/admin_router_test.go | 159 +++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 44 deletions(-) diff --git a/src/model/data/order_data_stats.go b/src/model/data/order_data_stats.go index cf75d39..2d265ec 100644 --- a/src/model/data/order_data_stats.go +++ b/src/model/data/order_data_stats.go @@ -1,6 +1,7 @@ package data import ( + "fmt" "time" "github.com/GMWalletApp/epusdt/model/dao" @@ -28,25 +29,40 @@ type AddressDailyStat struct { ActualAmount float64 `json:"actual_amount"` } +func statsBucketExpr(column string, hourly bool) (string, error) { + dialect := "sqlite" + if dao.Mdb != nil && dao.Mdb.Dialector != nil { + dialect = dao.Mdb.Dialector.Name() + } + return statsBucketExprForDialect(dialect, column, hourly) +} + +func statsBucketExprForDialect(dialect, column string, hourly bool) (string, error) { + switch dialect { + case "sqlite", "": + // modernc SQLite stores time.Time as Go's time.String() by default. + // Prefix buckets work for that format and SQLite datetime text. + if hourly { + return fmt.Sprintf("replace(substr(%s, 1, 13), 'T', ' ') || ':00'", column), nil + } + return fmt.Sprintf("substr(%s, 1, 10)", column), nil + case "postgres": + if hourly { + return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD HH24:00')", column), nil + } + return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD')", column), nil + default: + return "", fmt.Errorf("unsupported database dialect for dashboard stats: %s", dialect) + } +} + // DailyOrderStats returns per-day counts/amounts within [start, end], // zero-filling any days in the range that have no orders. // Only orders in StatusPaySuccess contribute to Amount sums — that's // what the user means by "流水". Pending orders count toward OrderCount // to keep成交率 computable. func DailyOrderStats(start, end time.Time) ([]DailyStat, error) { - var rows []DailyStat - err := dao.Mdb.Model(&mdb.Orders{}). - Select(`DATE(created_at) AS day, - COUNT(*) AS order_count, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount, - SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, - mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess). - Where("created_at >= ?", start). - Where("created_at <= ?", end). - Group("DATE(created_at)"). - Order("day ASC"). - Scan(&rows).Error + rows, err := queryOrderStats(start, end, false) if err != nil { return nil, err } @@ -56,25 +72,34 @@ func DailyOrderStats(start, end time.Time) ([]DailyStat, error) { // HourlyOrderStats returns per-hour counts/amounts within [start, end], // zero-filling any hours that have no orders. func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) { - var rows []DailyStat - err := dao.Mdb.Model(&mdb.Orders{}). - Select(`strftime('%Y-%m-%d %H:00', created_at) AS day, - COUNT(*) AS order_count, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount, - SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, - mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess). - Where("created_at >= ?", start). - Where("created_at <= ?", end). - Group("strftime('%Y-%m-%d %H:00', created_at)"). - Order("day ASC"). - Scan(&rows).Error + rows, err := queryOrderStats(start, end, true) if err != nil { return nil, err } return fillHourlyStats(start, end, rows), nil } +func queryOrderStats(start, end time.Time, hourly bool) ([]DailyStat, error) { + var rows []DailyStat + bucket, err := statsBucketExpr("created_at", hourly) + if err != nil { + return nil, err + } + err = dao.Mdb.Model(&mdb.Orders{}). + Select(fmt.Sprintf(`%s AS day, + COUNT(*) AS order_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount, + SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, bucket), + mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess). + Where("created_at >= ?", start). + Where("created_at <= ?", end). + Group(bucket). + Order("day ASC"). + Scan(&rows).Error + return rows, err +} + // fillDailyStats generates a zero-filled slice covering every calendar // day in [start, end]. Days present in rows overwrite the zero entries. func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat { @@ -122,15 +147,7 @@ func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat { // DailyAssetByAddress groups paid actual_amount by (day, receive_address) // for the per-address stacked chart, zero-filling missing days. func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) { - var rows []AddressDailyStat - err := dao.Mdb.Model(&mdb.Orders{}). - Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount"). - Where("status = ?", mdb.StatusPaySuccess). - Where("created_at >= ?", start). - Where("created_at <= ?", end). - Group("DATE(created_at), receive_address"). - Order("day ASC"). - Scan(&rows).Error + rows, err := queryAssetByAddress(start, end, false) if err != nil { return nil, err } @@ -140,21 +157,33 @@ func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) { // HourlyAssetByAddress groups paid actual_amount by (hour, receive_address) // for the per-address stacked chart, zero-filling missing hours. func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) { - var rows []AddressDailyStat - err := dao.Mdb.Model(&mdb.Orders{}). - Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount"). - Where("status = ?", mdb.StatusPaySuccess). - Where("created_at >= ?", start). - Where("created_at <= ?", end). - Group("strftime('%Y-%m-%d %H:00', created_at), receive_address"). - Order("day ASC"). - Scan(&rows).Error + rows, err := queryAssetByAddress(start, end, true) if err != nil { return nil, err } return fillAddressHourlyStats(start, end, rows), nil } +func queryAssetByAddress(start, end time.Time, hourly bool) ([]AddressDailyStat, error) { + var rows []AddressDailyStat + bucket, err := statsBucketExpr("created_at", hourly) + if err != nil { + return nil, err + } + err = dao.Mdb.Model(&mdb.Orders{}). + Select(fmt.Sprintf(`%s AS day, + receive_address AS address, + SUM(actual_amount) AS actual_amount`, bucket)). + Where("status = ?", mdb.StatusPaySuccess). + Where("created_at >= ?", start). + Where("created_at <= ?", end). + Group(bucket). + Group("receive_address"). + Order("day ASC, address ASC"). + Scan(&rows).Error + return rows, err +} + // fillAddressDailyStats zero-fills missing day×address combinations. // All addresses that appear in rows are represented for every day in range. func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat { diff --git a/src/model/data/order_data_test.go b/src/model/data/order_data_test.go index 5d0c732..3b9c4ea 100644 --- a/src/model/data/order_data_test.go +++ b/src/model/data/order_data_test.go @@ -42,6 +42,56 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) { } } +func TestStatsBucketExprForDialect(t *testing.T) { + tests := []struct { + name string + dialect string + hourly bool + want string + }{ + { + name: "sqlite daily", + dialect: "sqlite", + want: "substr(created_at, 1, 10)", + }, + { + name: "sqlite hourly", + dialect: "sqlite", + hourly: true, + want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'", + }, + { + name: "postgres daily", + dialect: "postgres", + want: "TO_CHAR(created_at, 'YYYY-MM-DD')", + }, + { + name: "postgres hourly", + dialect: "postgres", + hourly: true, + want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly) + if err != nil { + t.Fatalf("statsBucketExprForDialect error: %v", err) + } + if got != tt.want { + t.Fatalf("bucket expr = %q, want %q", got, tt.want) + } + }) + } +} + +func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) { + if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil { + t.Fatal("expected unsupported mysql dialect error") + } +} + func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/route/admin_router_test.go b/src/route/admin_router_test.go index f099ac2..abe1a8f 100644 --- a/src/route/admin_router_test.go +++ b/src/route/admin_router_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/GMWalletApp/epusdt/config" "github.com/GMWalletApp/epusdt/model/dao" @@ -874,6 +875,164 @@ func TestAdminDashboard_AllRoutes(t *testing.T) { } } +func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) { + e, token := setupAdminTestEnv(t) + yesterday := time.Now().AddDate(0, 0, -1) + createdAt := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 10, 0, 0, 0, time.Local) + updatedAt := createdAt.Add(5 * time.Minute) + + order := &mdb.Orders{ + TradeId: "trade-dashboard-yesterday", + OrderId: "order-dashboard-yesterday", + Amount: 100, + Currency: "CNY", + ActualAmount: 12.5, + ReceiveAddress: "TTestDashboardAddress001", + Token: "USDT", + Network: mdb.NetworkTron, + Status: mdb.StatusPaySuccess, + } + if err := dao.Mdb.Create(order).Error; err != nil { + t.Fatalf("create historical order: %v", err) + } + if err := dao.Mdb.Model(order).Updates(map[string]interface{}{ + "created_at": createdAt, + "updated_at": updatedAt, + }).Error; err != nil { + t.Fatalf("age historical order: %v", err) + } + + todayResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats", token)) + todayData, _ := todayResp["data"].(map[string]interface{}) + if got, _ := todayData["order_count"].(float64); got != 0 { + t.Fatalf("default order-stats order_count = %v, want 0 for today's range", got) + } + + statsResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats?range=7d", token)) + statsData, _ := statsResp["data"].(map[string]interface{}) + if got, _ := statsData["order_count"].(float64); got != 1 { + t.Fatalf("7d order-stats order_count = %v, want 1", got) + } + if got, _ := statsData["success_count"].(float64); got != 1 { + t.Fatalf("7d order-stats success_count = %v, want 1", got) + } + + overviewResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/overview?range=7d", token)) + overviewData, _ := overviewResp["data"].(map[string]interface{}) + if got, _ := overviewData["order_count"].(float64); got != 1 { + t.Fatalf("7d overview order_count = %v, want 1", got) + } + if got, _ := overviewData["volume"].(float64); got != 12.5 { + t.Fatalf("7d overview volume = %v, want 12.5", got) + } + + for _, path := range []string{ + "/admin/api/v1/dashboard/asset-trend?range=7d", + "/admin/api/v1/dashboard/revenue-trend?range=7d", + "/admin/api/v1/dashboard/asset-trend?range=30d", + "/admin/api/v1/dashboard/revenue-trend?range=30d", + } { + resp := assertOK(t, doGetAdmin(e, path, token)) + rows, _ := resp["data"].([]interface{}) + day := createdAt.Format("2006-01-02") + found := false + for _, row := range rows { + item, _ := row.(map[string]interface{}) + if item["day"] != day { + continue + } + found = true + if got, _ := item["order_count"].(float64); got != 1 { + t.Fatalf("%s order_count for %s = %v, want 1", path, day, got) + } + if got, _ := item["success_count"].(float64); got != 1 { + t.Fatalf("%s success_count for %s = %v, want 1", path, day, got) + } + if got, _ := item["actual_amount"].(float64); got != 12.5 { + t.Fatalf("%s actual_amount for %s = %v, want 12.5", path, day, got) + } + } + if !found { + t.Fatalf("%s missing historical bucket %s: %#v", path, day, rows) + } + } + + addressResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/asset-trend?range=7d&group_by=address", token)) + addressRows, _ := addressResp["data"].([]interface{}) + day := createdAt.Format("2006-01-02") + foundAddress := false + for _, row := range addressRows { + item, _ := row.(map[string]interface{}) + if item["day"] != day || item["address"] != order.ReceiveAddress { + continue + } + foundAddress = true + if got, _ := item["actual_amount"].(float64); got != 12.5 { + t.Fatalf("address trend actual_amount for %s = %v, want 12.5", day, got) + } + } + if !foundAddress { + t.Fatalf("address trend missing historical bucket %s/%s: %#v", day, order.ReceiveAddress, addressRows) + } +} + +func TestAdminDashboard_TodayTrendUsesHourlyBuckets(t *testing.T) { + e, token := setupAdminTestEnv(t) + now := time.Now() + createdAt := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local) + updatedAt := createdAt.Add(2 * time.Minute) + + order := &mdb.Orders{ + TradeId: "trade-dashboard-today", + OrderId: "order-dashboard-today", + Amount: 80, + Currency: "CNY", + ActualAmount: 9.75, + ReceiveAddress: "TTestDashboardAddressHourly", + Token: "USDT", + Network: mdb.NetworkTron, + Status: mdb.StatusPaySuccess, + } + if err := dao.Mdb.Create(order).Error; err != nil { + t.Fatalf("create today order: %v", err) + } + if err := dao.Mdb.Model(order).Updates(map[string]interface{}{ + "created_at": createdAt, + "updated_at": updatedAt, + }).Error; err != nil { + t.Fatalf("set today order time: %v", err) + } + + for _, path := range []string{ + "/admin/api/v1/dashboard/asset-trend?range=today", + "/admin/api/v1/dashboard/revenue-trend?range=today", + } { + resp := assertOK(t, doGetAdmin(e, path, token)) + rows, _ := resp["data"].([]interface{}) + hour := createdAt.Format("2006-01-02 15:00") + found := false + for _, row := range rows { + item, _ := row.(map[string]interface{}) + if item["day"] != hour { + continue + } + found = true + if got, _ := item["order_count"].(float64); got != 1 { + t.Fatalf("%s order_count for %s = %v, want 1", path, hour, got) + } + if got, _ := item["success_count"].(float64); got != 1 { + t.Fatalf("%s success_count for %s = %v, want 1", path, hour, got) + } + if got, _ := item["actual_amount"].(float64); got != 9.75 { + t.Fatalf("%s actual_amount for %s = %v, want 9.75", path, hour, got) + } + } + if !found { + t.Fatalf("%s missing hourly bucket %s: %#v", path, hour, rows) + } + } +} + // ─── Settings ──────────────────────────────────────────────────────────────── // TestAdminSettings_ListAndUpsert verifies listing and upserting settings.