diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index ca13fa8..91f2989 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -53,9 +53,9 @@ type TenantListFilter struct { CreatedAtFrom *string `query:"created_at_from"` // CreatedAtTo 创建时间结束(RFC3339)。 CreatedAtTo *string `query:"created_at_to"` - // Asc 升序字段(id/name/code/status/expired_at/created_at)。 + // Asc 升序字段(id/name/code/status/expired_at/created_at/user_count/income_amount_paid_sum)。 Asc *string `query:"asc"` - // Desc 降序字段(id/name/code/status/expired_at/created_at)。 + // Desc 降序字段(id/name/code/status/expired_at/created_at/user_count/income_amount_paid_sum)。 Desc *string `query:"desc"` } diff --git a/backend/app/http/super/v1/dto/super_finance.go b/backend/app/http/super/v1/dto/super_finance.go index 3406ab8..eb5b4f5 100644 --- a/backend/app/http/super/v1/dto/super_finance.go +++ b/backend/app/http/super/v1/dto/super_finance.go @@ -91,9 +91,9 @@ type SuperBalanceAnomalyFilter struct { Username *string `query:"username"` // Issue 异常类型(negative_balance/negative_frozen)。 Issue *string `query:"issue"` - // Asc 升序字段(id/balance/balance_frozen)。 + // Asc 升序字段(id/user_id/created_at/balance/balance_frozen)。 Asc *string `query:"asc"` - // Desc 降序字段(id/balance/balance_frozen)。 + // Desc 降序字段(id/user_id/created_at/balance/balance_frozen)。 Desc *string `query:"desc"` } @@ -138,9 +138,9 @@ type SuperOrderAnomalyFilter struct { CreatedAtFrom *string `query:"created_at_from"` // CreatedAtTo 创建时间结束(RFC3339)。 CreatedAtTo *string `query:"created_at_to"` - // Asc 升序字段(id/created_at/amount_paid)。 + // Asc 升序字段(id/order_id/created_at/amount_paid)。 Asc *string `query:"asc"` - // Desc 降序字段(id/created_at/amount_paid)。 + // Desc 降序字段(id/order_id/created_at/amount_paid)。 Desc *string `query:"desc"` } diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 86acedb..37211c2 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "path/filepath" "sort" "strconv" @@ -762,8 +763,16 @@ func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFil } orderApplied := false + aggOrderField := "" + aggOrderDesc := false if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { switch strings.TrimSpace(*filter.Desc) { + case "user_count": + aggOrderField = "user_count" + aggOrderDesc = true + case "income_amount_paid_sum": + aggOrderField = "income_amount_paid_sum" + aggOrderDesc = true case "id": q = q.Order(tbl.ID.Desc()) case "name": @@ -784,6 +793,12 @@ func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFil orderApplied = true } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { switch strings.TrimSpace(*filter.Asc) { + case "user_count": + aggOrderField = "user_count" + aggOrderDesc = false + case "income_amount_paid_sum": + aggOrderField = "income_amount_paid_sum" + aggOrderDesc = false case "id": q = q.Order(tbl.ID) case "name": @@ -813,9 +828,51 @@ func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFil return nil, errorx.ErrDatabaseError.WithCause(err) } - list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() - if err != nil { - return nil, errorx.ErrDatabaseError.WithCause(err) + var list []*models.Tenant + if aggOrderField != "" { + // 按用户数/累计收入排序需要补充聚合子查询,避免排序字段缺失。 + tenantTable := tbl.TableName() + db := q.UnderlyingDB() + var orderExpr string + switch aggOrderField { + case "user_count": + userCountQuery := models.TenantUserQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.TenantUser{}). + Select("tenant_id, count(*) as user_count"). + Group("tenant_id") + db = db.Joins("LEFT JOIN (?) AS tenant_user_counts ON tenant_user_counts.tenant_id = "+tenantTable+".id", userCountQuery) + orderExpr = "coalesce(tenant_user_counts.user_count, 0)" + case "income_amount_paid_sum": + incomeQuery := models.OrderQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.Order{}). + Select("tenant_id, coalesce(sum(amount_paid), 0) as income_amount_paid_sum"). + Where("status IN ?", []consts.OrderStatus{ + consts.OrderStatusPaid, + consts.OrderStatusRefunding, + consts.OrderStatusRefunded, + }). + Group("tenant_id") + db = db.Joins("LEFT JOIN (?) AS tenant_income_sums ON tenant_income_sums.tenant_id = "+tenantTable+".id", incomeQuery) + orderExpr = "coalesce(tenant_income_sums.income_amount_paid_sum, 0)" + } + if orderExpr != "" { + if aggOrderDesc { + orderExpr += " desc" + } else { + orderExpr += " asc" + } + db = db.Order(orderExpr) + } + if err := db.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find(&list).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + } else { + list, err = q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } } data, err := s.buildTenantItems(ctx, list) @@ -956,6 +1013,256 @@ func (s *super) TenantHealth(ctx context.Context, filter *super_dto.TenantListFi }, nil } +func (s *super) HealthOverview(ctx context.Context, filter *super_dto.SuperHealthOverviewFilter) (*super_dto.SuperHealthOverviewResponse, error) { + if filter == nil { + filter = &super_dto.SuperHealthOverviewFilter{} + } + + tenantID := int64(0) + if filter.TenantID != nil { + tenantID = *filter.TenantID + } + + // 统一处理统计区间与上传超时阈值,保证指标口径一致。 + startAt, endAt, err := s.normalizeHealthRange(filter) + if err != nil { + return nil, err + } + + uploadStuckHours := int64(24) + if filter.UploadStuckHours != nil && *filter.UploadStuckHours > 0 { + uploadStuckHours = *filter.UploadStuckHours + } + stuckBefore := time.Now().Add(-time.Duration(uploadStuckHours) * time.Hour) + + // 汇总订单失败率。 + orderTbl, orderQuery := models.OrderQuery.QueryContext(ctx) + if tenantID > 0 { + orderQuery = orderQuery.Where(orderTbl.TenantID.Eq(tenantID)) + } + if !startAt.IsZero() { + orderQuery = orderQuery.Where(orderTbl.CreatedAt.Gte(startAt)) + } + if !endAt.IsZero() { + orderQuery = orderQuery.Where(orderTbl.CreatedAt.Lte(endAt)) + } + orderTotal, err := orderQuery.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + failedTbl, failedQuery := models.OrderQuery.QueryContext(ctx) + failedQuery = failedQuery.Where(failedTbl.Status.Eq(consts.OrderStatusFailed)) + if tenantID > 0 { + failedQuery = failedQuery.Where(failedTbl.TenantID.Eq(tenantID)) + } + if !startAt.IsZero() { + failedQuery = failedQuery.Where(failedTbl.CreatedAt.Gte(startAt)) + } + if !endAt.IsZero() { + failedQuery = failedQuery.Where(failedTbl.CreatedAt.Lte(endAt)) + } + orderFailed, err := failedQuery.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + orderFailedRate := 0.0 + if orderTotal > 0 { + orderFailedRate = float64(orderFailed) / float64(orderTotal) + } + + // 汇总上传失败率与超时处理。 + assetTbl, assetQuery := models.MediaAssetQuery.QueryContext(ctx) + if tenantID > 0 { + assetQuery = assetQuery.Where(assetTbl.TenantID.Eq(tenantID)) + } + if !startAt.IsZero() { + assetQuery = assetQuery.Where(assetTbl.CreatedAt.Gte(startAt)) + } + if !endAt.IsZero() { + assetQuery = assetQuery.Where(assetTbl.CreatedAt.Lte(endAt)) + } + uploadTotal, err := assetQuery.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + failedAssetTbl, failedAssetQuery := models.MediaAssetQuery.QueryContext(ctx) + failedAssetQuery = failedAssetQuery.Where(failedAssetTbl.Status.Eq(consts.MediaAssetStatusFailed)) + if tenantID > 0 { + failedAssetQuery = failedAssetQuery.Where(failedAssetTbl.TenantID.Eq(tenantID)) + } + if !startAt.IsZero() { + failedAssetQuery = failedAssetQuery.Where(failedAssetTbl.CreatedAt.Gte(startAt)) + } + if !endAt.IsZero() { + failedAssetQuery = failedAssetQuery.Where(failedAssetTbl.CreatedAt.Lte(endAt)) + } + uploadFailed, err := failedAssetQuery.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + uploadFailedRate := 0.0 + if uploadTotal > 0 { + uploadFailedRate = float64(uploadFailed) / float64(uploadTotal) + } + + processingStuckTbl, processingStuckQuery := models.MediaAssetQuery.QueryContext(ctx) + processingStuckQuery = processingStuckQuery.Where( + processingStuckTbl.Status.Eq(consts.MediaAssetStatusProcessing), + processingStuckTbl.UpdatedAt.Lt(stuckBefore), + ) + if tenantID > 0 { + processingStuckQuery = processingStuckQuery.Where(processingStuckTbl.TenantID.Eq(tenantID)) + } + processingStuck, err := processingStuckQuery.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + uploadedStuckTbl, uploadedStuckQuery := models.MediaAssetQuery.QueryContext(ctx) + uploadedStuckQuery = uploadedStuckQuery.Where( + uploadedStuckTbl.Status.Eq(consts.MediaAssetStatusUploaded), + uploadedStuckTbl.UpdatedAt.Lt(stuckBefore), + ) + if tenantID > 0 { + uploadedStuckQuery = uploadedStuckQuery.Where(uploadedStuckTbl.TenantID.Eq(tenantID)) + } + uploadedStuck, err := uploadedStuckQuery.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // 汇总存储用量。 + var usageTenant *int64 + if tenantID > 0 { + usageTenant = &tenantID + } + usage, err := s.AssetUsage(ctx, &super_dto.SuperAssetUsageFilter{ + TenantID: usageTenant, + }) + if err != nil { + return nil, err + } + + // 计算租户健康风险分布。 + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + if tenantID > 0 { + tenantQuery = tenantQuery.Where(tenantTbl.ID.Eq(tenantID)) + } + tenantList, err := tenantQuery.Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + healthItems, err := s.buildTenantHealthItems(ctx, tenantList) + if err != nil { + return nil, err + } + + tenantTotal := int64(len(healthItems)) + tenantWarningCount := int64(0) + tenantRiskCount := int64(0) + tenantAlerts := make([]super_dto.SuperHealthAlertItem, 0) + + for _, item := range healthItems { + switch item.HealthLevel { + case "warning": + tenantWarningCount++ + case "risk": + tenantRiskCount++ + } + if item.HealthLevel != "healthy" && len(tenantAlerts) < 20 { + detail := strings.Join(item.Alerts, ",") + if strings.TrimSpace(detail) == "" { + detail = "租户健康指标触发预警" + } + tenantAlerts = append(tenantAlerts, super_dto.SuperHealthAlertItem{ + Level: item.HealthLevel, + Kind: "tenant_health", + TenantID: item.TenantID, + TenantCode: item.Code, + TenantName: item.Name, + Title: "租户健康预警", + Detail: detail, + UpdatedAt: s.formatTime(time.Now()), + }) + } + } + + // 构建告警列表(错误率/上传异常/租户健康)。 + alerts := make([]super_dto.SuperHealthAlertItem, 0, len(tenantAlerts)+4) + appendAlert := func(level, kind, title, detail string, count int64) { + if level == "" { + return + } + alerts = append(alerts, super_dto.SuperHealthAlertItem{ + Level: level, + Kind: kind, + Title: title, + Detail: detail, + Count: count, + UpdatedAt: s.formatTime(time.Now()), + }) + } + + switch { + case orderFailedRate >= 0.2: + appendAlert("risk", "order_error_rate", "订单失败率偏高", + fmt.Sprintf("失败率 %.2f%%(%d/%d)", orderFailedRate*100, orderFailed, orderTotal), orderFailed) + case orderFailedRate >= 0.1: + appendAlert("warning", "order_error_rate", "订单失败率偏高", + fmt.Sprintf("失败率 %.2f%%(%d/%d)", orderFailedRate*100, orderFailed, orderTotal), orderFailed) + } + + switch { + case uploadFailedRate >= 0.2: + appendAlert("risk", "upload_error_rate", "上传失败率偏高", + fmt.Sprintf("失败率 %.2f%%(%d/%d)", uploadFailedRate*100, uploadFailed, uploadTotal), uploadFailed) + case uploadFailedRate >= 0.1: + appendAlert("warning", "upload_error_rate", "上传失败率偏高", + fmt.Sprintf("失败率 %.2f%%(%d/%d)", uploadFailedRate*100, uploadFailed, uploadTotal), uploadFailed) + } + + switch { + case processingStuck >= 20: + appendAlert("risk", "upload_stuck", "上传处理超时", + fmt.Sprintf("processing 超时 %d 条(> %d 小时)", processingStuck, uploadStuckHours), processingStuck) + case processingStuck > 0: + appendAlert("warning", "upload_stuck", "上传处理超时", + fmt.Sprintf("processing 超时 %d 条(> %d 小时)", processingStuck, uploadStuckHours), processingStuck) + } + + if uploadedStuck > 0 { + appendAlert("warning", "upload_stuck", "上传入库滞留", + fmt.Sprintf("uploaded 超时 %d 条(> %d 小时)", uploadedStuck, uploadStuckHours), uploadedStuck) + } + + alerts = append(alerts, tenantAlerts...) + + return &super_dto.SuperHealthOverviewResponse{ + TenantID: tenantID, + StartAt: s.formatTime(startAt), + EndAt: s.formatTime(endAt), + UploadStuckHours: uploadStuckHours, + TenantTotal: tenantTotal, + TenantWarningCount: tenantWarningCount, + TenantRiskCount: tenantRiskCount, + OrderTotal: orderTotal, + OrderFailed: orderFailed, + OrderFailedRate: orderFailedRate, + UploadTotal: uploadTotal, + UploadFailed: uploadFailed, + UploadFailedRate: uploadFailedRate, + UploadProcessingStuck: processingStuck, + UploadUploadedStuck: uploadedStuck, + StorageTotalCount: usage.TotalCount, + StorageTotalSize: usage.TotalSize, + Alerts: alerts, + }, nil +} + func (s *super) ListCreatorApplications(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.TenantListFilter{} @@ -6664,6 +6971,10 @@ func (s *super) ListBalanceAnomalies(ctx context.Context, filter *super_dto.Supe switch strings.TrimSpace(*filter.Desc) { case "id": q = q.Order(tbl.ID.Desc()) + case "user_id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) case "balance": q = q.Order(tbl.Balance.Desc()) case "balance_frozen": @@ -6674,6 +6985,10 @@ func (s *super) ListBalanceAnomalies(ctx context.Context, filter *super_dto.Supe switch strings.TrimSpace(*filter.Asc) { case "id": q = q.Order(tbl.ID) + case "user_id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) case "balance": q = q.Order(tbl.Balance) case "balance_frozen": @@ -6816,6 +7131,8 @@ func (s *super) ListOrderAnomalies(ctx context.Context, filter *super_dto.SuperO switch strings.TrimSpace(*filter.Desc) { case "id": q = q.Order(tbl.ID.Desc()) + case "order_id": + q = q.Order(tbl.ID.Desc()) case "created_at": q = q.Order(tbl.CreatedAt.Desc()) case "amount_paid": @@ -6826,6 +7143,8 @@ func (s *super) ListOrderAnomalies(ctx context.Context, filter *super_dto.SuperO switch strings.TrimSpace(*filter.Asc) { case "id": q = q.Order(tbl.ID) + case "order_id": + q = q.Order(tbl.ID) case "created_at": q = q.Order(tbl.CreatedAt) case "amount_paid": @@ -8115,6 +8434,36 @@ func (s *super) reportOrderSeries( return result, nil } +func (s *super) normalizeHealthRange(filter *super_dto.SuperHealthOverviewFilter) (time.Time, time.Time, error) { + now := time.Now() + endAt := now + if filter != nil && filter.EndAt != nil && strings.TrimSpace(*filter.EndAt) != "" { + parsed, err := s.parseFilterTime(filter.EndAt) + if err != nil { + return time.Time{}, time.Time{}, err + } + if parsed != nil { + endAt = *parsed + } + } + + startAt := endAt.AddDate(0, 0, -7) + if filter != nil && filter.StartAt != nil && strings.TrimSpace(*filter.StartAt) != "" { + parsed, err := s.parseFilterTime(filter.StartAt) + if err != nil { + return time.Time{}, time.Time{}, err + } + if parsed != nil { + startAt = *parsed + } + } + + if startAt.After(endAt) { + return time.Time{}, time.Time{}, errorx.ErrBadRequest.WithMsg("结束时间不能早于开始时间") + } + return startAt, endAt, nil +} + func (s *super) normalizeReportRange(filter *super_dto.SuperReportOverviewFilter) (reportRange, error) { granularity := "day" if filter != nil && filter.Granularity != nil && strings.TrimSpace(*filter.Granularity) != "" { diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 2342148..181dd4c 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -157,6 +157,116 @@ func (s *SuperTestSuite) Test_CreateTenant() { }) } +func (s *SuperTestSuite) Test_ListTenantsSortAggregates() { + Convey("ListTenants sort by aggregates", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameTenant, models.TableNameTenantUser, models.TableNameOrder) + + owner1 := &models.User{Username: "tenant_sort_owner_1"} + owner2 := &models.User{Username: "tenant_sort_owner_2"} + owner3 := &models.User{Username: "tenant_sort_owner_3"} + member := &models.User{Username: "tenant_sort_member"} + models.UserQuery.WithContext(ctx).Create(owner1, owner2, owner3, member) + + tenant1 := &models.Tenant{UserID: owner1.ID, Code: "t-sort-1", Name: "Tenant Sort 1", Status: consts.TenantStatusVerified} + tenant2 := &models.Tenant{UserID: owner2.ID, Code: "t-sort-2", Name: "Tenant Sort 2", Status: consts.TenantStatusVerified} + tenant3 := &models.Tenant{UserID: owner3.ID, Code: "t-sort-3", Name: "Tenant Sort 3", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(tenant1, tenant2, tenant3) + + models.TenantUserQuery.WithContext(ctx).Create( + &models.TenantUser{TenantID: tenant1.ID, UserID: owner1.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}}, + &models.TenantUser{TenantID: tenant2.ID, UserID: owner2.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}}, + &models.TenantUser{TenantID: tenant2.ID, UserID: member.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}}, + &models.TenantUser{TenantID: tenant3.ID, UserID: owner3.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}}, + &models.TenantUser{TenantID: tenant3.ID, UserID: owner1.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}}, + &models.TenantUser{TenantID: tenant3.ID, UserID: member.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}}, + ) + + models.OrderQuery.WithContext(ctx).Create( + &models.Order{ + TenantID: tenant1.ID, + UserID: owner1.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + AmountOriginal: 300, + AmountDiscount: 0, + AmountPaid: 300, + IdempotencyKey: "tenant-sort-income-1", + }, + &models.Order{ + TenantID: tenant2.ID, + UserID: owner2.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + AmountOriginal: 100, + AmountDiscount: 0, + AmountPaid: 100, + IdempotencyKey: "tenant-sort-income-2", + }, + &models.Order{ + TenantID: tenant3.ID, + UserID: owner3.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + AmountOriginal: 200, + AmountDiscount: 0, + AmountPaid: 200, + IdempotencyKey: "tenant-sort-income-3", + }, + ) + + Convey("should sort by user_count asc", func() { + filter := &super_dto.TenantListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Asc: lo.ToPtr("user_count"), + } + res, err := Super.ListTenants(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.TenantItem) + So(items[0].ID, ShouldEqual, tenant1.ID) + So(items[1].ID, ShouldEqual, tenant2.ID) + So(items[2].ID, ShouldEqual, tenant3.ID) + }) + + Convey("should sort by user_count desc", func() { + filter := &super_dto.TenantListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Desc: lo.ToPtr("user_count"), + } + res, err := Super.ListTenants(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.TenantItem) + So(items[0].ID, ShouldEqual, tenant3.ID) + So(items[2].ID, ShouldEqual, tenant1.ID) + }) + + Convey("should sort by income_amount_paid_sum asc", func() { + filter := &super_dto.TenantListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Asc: lo.ToPtr("income_amount_paid_sum"), + } + res, err := Super.ListTenants(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.TenantItem) + So(items[0].ID, ShouldEqual, tenant2.ID) + So(items[1].ID, ShouldEqual, tenant3.ID) + So(items[2].ID, ShouldEqual, tenant1.ID) + }) + + Convey("should sort by income_amount_paid_sum desc", func() { + filter := &super_dto.TenantListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Desc: lo.ToPtr("income_amount_paid_sum"), + } + res, err := Super.ListTenants(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.TenantItem) + So(items[0].ID, ShouldEqual, tenant1.ID) + So(items[2].ID, ShouldEqual, tenant2.ID) + }) + }) +} + func (s *SuperTestSuite) Test_WithdrawalApproval() { Convey("Withdrawal Approval", s.T(), func() { ctx := s.T().Context() @@ -546,6 +656,92 @@ func (s *SuperTestSuite) Test_FinanceAnomalies() { }) } +func (s *SuperTestSuite) Test_FinanceAnomalySorting() { + Convey("Finance Anomaly Sorting", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameOrder, models.TableNameTenant, models.TableNameUser) + + now := time.Now().Truncate(time.Second) + userOld := &models.User{Username: "balance_old", Balance: -100, CreatedAt: now.Add(-2 * time.Hour)} + userNew := &models.User{Username: "balance_new", Balance: -200, CreatedAt: now.Add(-1 * time.Hour)} + models.UserQuery.WithContext(ctx).Create(userOld, userNew) + + orderOwner := &models.User{Username: "order_anomaly_owner"} + models.UserQuery.WithContext(ctx).Create(orderOwner) + + tenant := &models.Tenant{UserID: orderOwner.ID, Code: "t-anomaly-sort", Name: "Anomaly Sort Tenant", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(tenant) + + order1 := &models.Order{ + TenantID: tenant.ID, + UserID: orderOwner.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + AmountOriginal: 100, + AmountDiscount: 0, + AmountPaid: 100, + IdempotencyKey: "anomaly-sort-1", + } + order2 := &models.Order{ + TenantID: tenant.ID, + UserID: orderOwner.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + AmountOriginal: 200, + AmountDiscount: 0, + AmountPaid: 200, + IdempotencyKey: "anomaly-sort-2", + } + models.OrderQuery.WithContext(ctx).Create(order1, order2) + + Convey("should sort balance anomalies by created_at asc", func() { + filter := &super_dto.SuperBalanceAnomalyFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Asc: lo.ToPtr("created_at"), + } + res, err := Super.ListBalanceAnomalies(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.SuperBalanceAnomalyItem) + So(items[0].UserID, ShouldEqual, userOld.ID) + So(items[1].UserID, ShouldEqual, userNew.ID) + }) + + Convey("should sort balance anomalies by user_id desc", func() { + filter := &super_dto.SuperBalanceAnomalyFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Desc: lo.ToPtr("user_id"), + } + res, err := Super.ListBalanceAnomalies(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.SuperBalanceAnomalyItem) + So(items[0].UserID, ShouldEqual, userNew.ID) + }) + + Convey("should sort order anomalies by order_id asc", func() { + filter := &super_dto.SuperOrderAnomalyFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Asc: lo.ToPtr("order_id"), + } + res, err := Super.ListOrderAnomalies(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.SuperOrderAnomalyItem) + So(items[0].OrderID, ShouldEqual, order1.ID) + So(items[1].OrderID, ShouldEqual, order2.ID) + }) + + Convey("should sort order anomalies by order_id desc", func() { + filter := &super_dto.SuperOrderAnomalyFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Desc: lo.ToPtr("order_id"), + } + res, err := Super.ListOrderAnomalies(ctx, filter) + So(err, ShouldBeNil) + items := res.Items.([]super_dto.SuperOrderAnomalyItem) + So(items[0].OrderID, ShouldEqual, order2.ID) + }) + }) +} + func (s *SuperTestSuite) Test_TenantHealth() { Convey("TenantHealth", s.T(), func() { ctx := s.T().Context() @@ -597,7 +793,7 @@ func (s *SuperTestSuite) Test_TenantHealth() { }, ) - now := time.Now() + now := time.Now().Truncate(time.Second) models.OrderQuery.WithContext(ctx).Create( &models.Order{ TenantID: tenant1.ID, @@ -648,6 +844,115 @@ func (s *SuperTestSuite) Test_TenantHealth() { }) } +func (s *SuperTestSuite) Test_HealthOverview() { + Convey("HealthOverview", s.T(), func() { + ctx := s.T().Context() + database.Truncate( + ctx, + s.DB, + models.TableNameUser, + models.TableNameTenant, + models.TableNameTenantUser, + models.TableNameContent, + models.TableNameOrder, + models.TableNameMediaAsset, + ) + + owner := &models.User{Username: "health_overview_owner"} + models.UserQuery.WithContext(ctx).Create(owner) + + tenant := &models.Tenant{ + UserID: owner.ID, + Name: "Health Overview", + Code: "health_overview", + Status: consts.TenantStatusVerified, + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + models.TenantUserQuery.WithContext(ctx).Create(&models.TenantUser{ + TenantID: tenant.ID, + UserID: owner.ID, + }) + + models.ContentQuery.WithContext(ctx).Create(&models.Content{ + TenantID: tenant.ID, + UserID: owner.ID, + Title: "Health Content", + Status: consts.ContentStatusPublished, + }) + + now := time.Now() + models.OrderQuery.WithContext(ctx).Create( + &models.Order{ + TenantID: tenant.ID, + UserID: owner.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + AmountPaid: 1000, + PaidAt: now, + CreatedAt: now, + }, + &models.Order{ + TenantID: tenant.ID, + UserID: owner.ID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusFailed, + AmountPaid: 1000, + UpdatedAt: now, + CreatedAt: now, + }, + ) + + models.MediaAssetQuery.WithContext(ctx).Create( + &models.MediaAsset{ + TenantID: tenant.ID, + UserID: owner.ID, + ObjectKey: "failed.mp4", + Type: consts.MediaAssetTypeVideo, + Status: consts.MediaAssetStatusFailed, + CreatedAt: now, + UpdatedAt: now, + }, + &models.MediaAsset{ + TenantID: tenant.ID, + UserID: owner.ID, + ObjectKey: "ready.mp4", + Type: consts.MediaAssetTypeVideo, + Status: consts.MediaAssetStatusReady, + CreatedAt: now, + UpdatedAt: now, + }, + &models.MediaAsset{ + TenantID: tenant.ID, + UserID: owner.ID, + ObjectKey: "processing.mp4", + Type: consts.MediaAssetTypeVideo, + Status: consts.MediaAssetStatusProcessing, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-48 * time.Hour), + }, + ) + + startAt := now.AddDate(0, 0, -7).Format(time.RFC3339) + endAt := now.Add(time.Second).Format(time.RFC3339) + filter := &super_dto.SuperHealthOverviewFilter{ + TenantID: &tenant.ID, + StartAt: &startAt, + EndAt: &endAt, + UploadStuckHours: lo.ToPtr(int64(24)), + } + + res, err := Super.HealthOverview(ctx, filter) + So(err, ShouldBeNil) + So(res.TenantTotal, ShouldEqual, 1) + So(res.OrderTotal, ShouldEqual, 2) + So(res.OrderFailed, ShouldEqual, 1) + So(res.UploadTotal, ShouldEqual, 3) + So(res.UploadFailed, ShouldEqual, 1) + So(res.UploadProcessingStuck, ShouldEqual, 1) + }) +} + func (s *SuperTestSuite) Test_ContentReview() { Convey("ContentReview", s.T(), func() { ctx := s.T().Context() diff --git a/docs/backend_sort_alignment_plan.md b/docs/backend_sort_alignment_plan.md new file mode 100644 index 0000000..7310362 --- /dev/null +++ b/docs/backend_sort_alignment_plan.md @@ -0,0 +1,79 @@ +# Backend Sort Alignment Plan (Superadmin) + +## Goal +Align backend sort support with the superadmin UI for the following lists: +- Tenants list: `user_count`, `income_amount_paid_sum` +- Balance anomalies: `user_id`, `created_at` +- Order anomalies: `order_id` + +## Scope +- Backend services only (no UI changes). +- Sorting behavior and documentation updates. +- Add service-level tests to cover the new sort fields. + +## Non-Goals +- Changing pagination logic or query filters. +- Adding new UI sorting fields. +- Changing business semantics of existing aggregates. + +## Proposed Approach +### 1) Tenants list sorting (已完成) +Backend: `services.Super.ListTenants` + +Add two aggregate subqueries and join them for ordering: +- `user_count` + - `tenant_users` aggregate: `SELECT tenant_id, COUNT(*) AS user_count FROM tenant_users GROUP BY tenant_id` +- `income_amount_paid_sum` + - `orders` aggregate: `SELECT tenant_id, COALESCE(SUM(amount_paid), 0) AS income_amount_paid_sum FROM orders WHERE status IN (paid, refunding, refunded) GROUP BY tenant_id` + +Ordering behavior: +- Extend the sort switch to accept `user_count` and `income_amount_paid_sum`. +- Map sort to the joined aggregate columns (use `COALESCE` to avoid null ordering issues). + +Count safety: +- Keep `Count()` on the base tenant query or use `COUNT(DISTINCT tenants.id)` after joins to avoid inflation. +- Apply joins only for the query that fetches paged data when possible. + +### 2) Balance anomalies sorting (已完成) +Backend: `services.Super.ListBalanceAnomalies` + +Extend sort switch: +- `user_id` -> `tbl.ID` +- `created_at` -> `tbl.CreatedAt` + +### 3) Order anomalies sorting (已完成) +Backend: `services.Super.ListOrderAnomalies` + +Extend sort switch: +- `order_id` -> `tbl.ID` + +### 4) Documentation updates (DTO comments) (已完成) +Update sort field documentation to reflect new options: +- `TenantListFilter` (add `user_count`, `income_amount_paid_sum`) +- `SuperBalanceAnomalyFilter` (add `user_id`, `created_at`) +- `SuperOrderAnomalyFilter` (add `order_id`) + +## Tests (已完成) +Add service-level tests (in `backend/app/services/super_test.go` or a new test file): +- Tenants list sorting: + - Create tenants with different member counts and income sums. + - Verify `asc`/`desc` on `user_count` and `income_amount_paid_sum`. +- Balance anomalies sorting: + - Create users with negative balances and controlled `created_at`. + - Verify `asc`/`desc` on `created_at` and `user_id`. +- Order anomalies sorting: + - Create anomaly orders with distinct IDs. + - Verify ordering by `order_id`. + +Run: +- `go test ./...` + +## Files to Change +- `backend/app/services/super.go` +- `backend/app/http/super/v1/dto/super.go` +- `backend/app/http/super/v1/dto/super_finance.go` +- `backend/app/services/super_test.go` (or new test file) + +## Risks / Notes +- Join-based sorting can impact performance on large tables; consider indexes on `tenant_users.tenant_id` and `orders.tenant_id`. +- Ensure aggregate semantics match current `buildTenantItems` logic to keep UI numbers consistent.