fix: align superadmin sort fields

This commit is contained in:
2026-01-17 09:14:34 +08:00
parent b796636b5d
commit 94a10a3af0
5 changed files with 743 additions and 10 deletions

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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) != "" {

View File

@@ -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()

View File

@@ -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.