fix: align superadmin sort fields
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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()
|
||||
|
||||
79
docs/backend_sort_alignment_plan.md
Normal file
79
docs/backend_sort_alignment_plan.md
Normal 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.
|
||||
Reference in New Issue
Block a user