feat: add super admin health review

This commit is contained in:
2026-01-13 15:02:05 +08:00
parent 19b15bf20a
commit 5ac2ea028c
10 changed files with 1208 additions and 0 deletions

View File

@@ -302,6 +302,9 @@ func (s *super) UpdateUserRoles(ctx context.Context, id int64, form *super_dto.U
}
func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.TenantListFilter{}
}
tbl, q := models.TenantQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
@@ -424,6 +427,132 @@ func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFil
}, nil
}
func (s *super) TenantHealth(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.TenantListFilter{}
}
tbl, q := models.TenantQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Name != nil && strings.TrimSpace(*filter.Name) != "" {
q = q.Where(tbl.Name.Like("%" + strings.TrimSpace(*filter.Name) + "%"))
}
if filter.Code != nil && strings.TrimSpace(*filter.Code) != "" {
q = q.Where(tbl.Code.Like("%" + strings.TrimSpace(*filter.Code) + "%"))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.ExpiredAtFrom != nil {
from, err := s.parseFilterTime(filter.ExpiredAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.ExpiredAt.Gte(*from))
}
}
if filter.ExpiredAtTo != nil {
to, err := s.parseFilterTime(filter.ExpiredAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.ExpiredAt.Lte(*to))
}
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "name":
q = q.Order(tbl.Name.Desc())
case "code":
q = q.Order(tbl.Code.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
case "expired_at":
q = q.Order(tbl.ExpiredAt.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "updated_at":
q = q.Order(tbl.UpdatedAt.Desc())
case "user_id":
q = q.Order(tbl.UserID.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "name":
q = q.Order(tbl.Name)
case "code":
q = q.Order(tbl.Code)
case "status":
q = q.Order(tbl.Status)
case "expired_at":
q = q.Order(tbl.ExpiredAt)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "updated_at":
q = q.Order(tbl.UpdatedAt)
case "user_id":
q = q.Order(tbl.UserID)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
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)
}
items, err := s.buildTenantHealthItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) CreateTenant(ctx context.Context, form *super_dto.TenantCreateForm) error {
uid := form.AdminUserID
if _, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First(); err != nil {
@@ -856,6 +985,67 @@ func (s *super) UpdateContentStatus(ctx context.Context, tenantID, contentID int
return nil
}
func (s *super) ReviewContent(ctx context.Context, operatorID, contentID int64, form *super_dto.SuperContentReviewForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("审核参数不能为空")
}
action := strings.ToLower(strings.TrimSpace(form.Action))
if action != "approve" && action != "reject" {
return errorx.ErrBadRequest.WithMsg("审核动作非法")
}
tbl, q := models.ContentQuery.QueryContext(ctx)
content, err := q.Where(tbl.ID.Eq(contentID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound
}
return errorx.ErrDatabaseError.WithCause(err)
}
if content.Status != consts.ContentStatusReviewing {
return errorx.ErrStatusConflict.WithMsg("内容未处于审核中状态")
}
// 审核动作映射为内容状态。
nextStatus := consts.ContentStatusBlocked
if action == "approve" {
nextStatus = consts.ContentStatusPublished
}
updates := &models.Content{
Status: nextStatus,
UpdatedAt: time.Now(),
}
if nextStatus == consts.ContentStatusPublished {
updates.PublishedAt = time.Now()
}
_, err = q.Where(tbl.ID.Eq(contentID)).Updates(updates)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
// 审核完成后通知作者并记录审计日志。
title := "内容审核结果"
detail := "内容审核通过"
if action == "reject" {
detail = "内容审核驳回"
if strings.TrimSpace(form.Reason) != "" {
detail += ",原因:" + strings.TrimSpace(form.Reason)
}
}
if Notification != nil {
_ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "review_content", cast.ToString(contentID), detail)
}
return nil
}
func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) {
tbl, q := models.OrderQuery.QueryContext(ctx)
@@ -1754,6 +1944,248 @@ func (s *super) buildTenantItems(ctx context.Context, list []*models.Tenant) ([]
return items, nil
}
type tenantHealthContentAgg struct {
TenantID int64 `gorm:"column:tenant_id"`
ContentCount int64 `gorm:"column:content_count"`
PublishedCount int64 `gorm:"column:published_count"`
}
type tenantHealthOrderAgg struct {
TenantID int64 `gorm:"column:tenant_id"`
PaidCount int64 `gorm:"column:paid_count"`
PaidAmount int64 `gorm:"column:paid_amount"`
RefundCount int64 `gorm:"column:refund_count"`
RefundAmount int64 `gorm:"column:refund_amount"`
LastPaidAt time.Time `gorm:"column:last_paid_at"`
}
type tenantHealthMetrics struct {
MemberCount int64
ContentCount int64
PublishedContentCount int64
PaidOrders int64
PaidAmount int64
RefundOrders int64
RefundAmount int64
RefundRate float64
LastPaidAt time.Time
}
func (s *super) buildTenantHealthItems(ctx context.Context, list []*models.Tenant) ([]super_dto.TenantHealthItem, error) {
if len(list) == 0 {
return []super_dto.TenantHealthItem{}, nil
}
tenantIDs := make([]int64, 0, len(list))
ownerIDs := make(map[int64]struct{}, len(list))
for _, t := range list {
tenantIDs = append(tenantIDs, t.ID)
ownerIDs[t.UserID] = struct{}{}
}
// 查询租户所有者信息。
ownerMap := make(map[int64]*models.User, len(ownerIDs))
if len(ownerIDs) > 0 {
ids := make([]int64, 0, len(ownerIDs))
for id := range ownerIDs {
ids = append(ids, id)
}
tblUser, qUser := models.UserQuery.QueryContext(ctx)
users, err := qUser.Where(tblUser.ID.In(ids...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, u := range users {
ownerMap[u.ID] = u
}
}
// 汇总租户成员数。
memberCountMap := make(map[int64]int64, len(list))
var memberRows []struct {
TenantID int64 `gorm:"column:tenant_id"`
Count int64 `gorm:"column:count"`
}
err := models.TenantUserQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.TenantUser{}).
Select("tenant_id, count(*) as count").
Where("tenant_id IN ?", tenantIDs).
Group("tenant_id").
Scan(&memberRows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range memberRows {
memberCountMap[row.TenantID] = row.Count
}
// 汇总内容总量与发布量。
contentMap := make(map[int64]tenantHealthContentAgg, len(list))
contentRows := make([]tenantHealthContentAgg, 0)
err = models.ContentQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Content{}).
Select(
"tenant_id, count(*) as content_count, coalesce(sum(case when status = ? then 1 else 0 end), 0) as published_count",
consts.ContentStatusPublished,
).
Where("tenant_id IN ?", tenantIDs).
Group("tenant_id").
Scan(&contentRows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range contentRows {
contentMap[row.TenantID] = row
}
// 汇总订单成交/退款指标。
orderMap := make(map[int64]tenantHealthOrderAgg, len(list))
orderRows := make([]tenantHealthOrderAgg, 0)
err = models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select(
"tenant_id, "+
"coalesce(sum(case when status = ? then 1 else 0 end), 0) as paid_count, "+
"coalesce(sum(case when status = ? then amount_paid else 0 end), 0) as paid_amount, "+
"coalesce(sum(case when status = ? then 1 else 0 end), 0) as refund_count, "+
"coalesce(sum(case when status = ? then amount_paid else 0 end), 0) as refund_amount, "+
"max(case when status = ? then paid_at else null end) as last_paid_at",
consts.OrderStatusPaid,
consts.OrderStatusPaid,
consts.OrderStatusRefunded,
consts.OrderStatusRefunded,
consts.OrderStatusPaid,
).
Where("tenant_id IN ? AND type = ?", tenantIDs, consts.OrderTypeContentPurchase).
Group("tenant_id").
Scan(&orderRows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range orderRows {
orderMap[row.TenantID] = row
}
items := make([]super_dto.TenantHealthItem, 0, len(list))
for _, t := range list {
contentAgg := contentMap[t.ID]
orderAgg := orderMap[t.ID]
refundRate := 0.0
if orderAgg.PaidCount > 0 {
refundRate = float64(orderAgg.RefundCount) / float64(orderAgg.PaidCount)
}
metrics := tenantHealthMetrics{
MemberCount: memberCountMap[t.ID],
ContentCount: contentAgg.ContentCount,
PublishedContentCount: contentAgg.PublishedCount,
PaidOrders: orderAgg.PaidCount,
PaidAmount: orderAgg.PaidAmount,
RefundOrders: orderAgg.RefundCount,
RefundAmount: orderAgg.RefundAmount,
RefundRate: refundRate,
LastPaidAt: orderAgg.LastPaidAt,
}
healthLevel, alerts := s.evaluateTenantHealth(t, metrics)
item := super_dto.TenantHealthItem{
TenantID: t.ID,
Code: t.Code,
Name: t.Name,
Status: t.Status,
StatusDescription: t.Status.Description(),
MemberCount: metrics.MemberCount,
ContentCount: metrics.ContentCount,
PublishedContentCount: metrics.PublishedContentCount,
PaidOrders: metrics.PaidOrders,
PaidAmount: metrics.PaidAmount,
RefundOrders: metrics.RefundOrders,
RefundAmount: metrics.RefundAmount,
RefundRate: metrics.RefundRate,
LastPaidAt: s.formatTime(metrics.LastPaidAt),
HealthLevel: healthLevel,
Alerts: alerts,
}
if owner := ownerMap[t.UserID]; owner != nil {
item.Owner = &super_dto.TenantOwnerUserLite{
ID: owner.ID,
Username: owner.Username,
}
}
items = append(items, item)
}
return items, nil
}
func (s *super) evaluateTenantHealth(tenant *models.Tenant, metrics tenantHealthMetrics) (string, []string) {
level := 0
alerts := make([]string, 0)
now := time.Now()
// 根据租户状态与过期情况判断风险级别。
if tenant.Status == consts.TenantStatusBanned {
level = 2
alerts = append(alerts, "租户已封禁")
} else if tenant.Status == consts.TenantStatusPendingVerify {
if level < 1 {
level = 1
}
alerts = append(alerts, "租户待审核")
}
if !tenant.ExpiredAt.IsZero() && tenant.ExpiredAt.Before(now) {
level = 2
alerts = append(alerts, "租户已过期")
}
// 内容与成交基础判断。
if metrics.PublishedContentCount == 0 {
if level < 1 {
level = 1
}
alerts = append(alerts, "无已发布内容")
}
if metrics.PaidOrders == 0 {
if level < 1 {
level = 1
}
alerts = append(alerts, "暂无成交")
} else if !metrics.LastPaidAt.IsZero() {
if metrics.LastPaidAt.Before(now.AddDate(0, 0, -90)) {
level = 2
alerts = append(alerts, "成交活跃度偏低")
} else if metrics.LastPaidAt.Before(now.AddDate(0, 0, -30)) {
if level < 1 {
level = 1
}
alerts = append(alerts, "成交活跃度偏低")
}
}
// 退款率异常判断。
if metrics.RefundRate >= 0.2 {
level = 2
alerts = append(alerts, "退款率偏高")
} else if metrics.RefundRate >= 0.1 {
if level < 1 {
level = 1
}
alerts = append(alerts, "退款率偏高")
}
switch level {
case 1:
return "warning", alerts
case 2:
return "risk", alerts
default:
return "healthy", alerts
}
}
func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) {
tbl, q := models.OrderQuery.QueryContext(ctx)
q = q.Where(tbl.Type.Eq(consts.OrderTypeWithdrawal))

View File

@@ -3,6 +3,7 @@ package services
import (
"database/sql"
"testing"
"time"
"quyun/v2/app/commands/testx"
super_dto "quyun/v2/app/http/super/v1/dto"
@@ -163,3 +164,158 @@ func (s *SuperTestSuite) Test_WithdrawalApproval() {
})
})
}
func (s *SuperTestSuite) Test_TenantHealth() {
Convey("TenantHealth", s.T(), func() {
ctx := s.T().Context()
database.Truncate(
ctx,
s.DB,
models.TableNameUser,
models.TableNameTenant,
models.TableNameTenantUser,
models.TableNameContent,
models.TableNameOrder,
)
owner1 := &models.User{Username: "health_owner_1"}
owner2 := &models.User{Username: "health_owner_2"}
models.UserQuery.WithContext(ctx).Create(owner1, owner2)
tenant1 := &models.Tenant{
UserID: owner1.ID,
Name: "Health Tenant 1",
Code: "health1",
Status: consts.TenantStatusVerified,
}
tenant2 := &models.Tenant{
UserID: owner2.ID,
Name: "Health Tenant 2",
Code: "health2",
Status: consts.TenantStatusVerified,
}
models.TenantQuery.WithContext(ctx).Create(tenant1, tenant2)
models.TenantUserQuery.WithContext(ctx).Create(
&models.TenantUser{TenantID: tenant1.ID, UserID: owner1.ID},
&models.TenantUser{TenantID: tenant2.ID, UserID: owner2.ID},
)
models.ContentQuery.WithContext(ctx).Create(
&models.Content{
TenantID: tenant1.ID,
UserID: owner1.ID,
Title: "Content H1",
Status: consts.ContentStatusPublished,
},
&models.Content{
TenantID: tenant2.ID,
UserID: owner2.ID,
Title: "Content H2",
Status: consts.ContentStatusPublished,
},
)
now := time.Now()
models.OrderQuery.WithContext(ctx).Create(
&models.Order{
TenantID: tenant1.ID,
UserID: owner1.ID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
AmountPaid: 1000,
PaidAt: now,
},
&models.Order{
TenantID: tenant2.ID,
UserID: owner2.ID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
AmountPaid: 1000,
PaidAt: now,
},
&models.Order{
TenantID: tenant2.ID,
UserID: owner2.ID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusRefunded,
AmountPaid: 1000,
UpdatedAt: now,
},
)
filter := &super_dto.TenantListFilter{
Pagination: requests.Pagination{Page: 1, Limit: 10},
}
res, err := Super.TenantHealth(ctx, filter)
So(err, ShouldBeNil)
So(res.Total, ShouldEqual, 2)
items := res.Items.([]super_dto.TenantHealthItem)
itemMap := make(map[int64]super_dto.TenantHealthItem, len(items))
for _, item := range items {
itemMap[item.TenantID] = item
}
So(itemMap[tenant1.ID].PaidOrders, ShouldEqual, 1)
So(itemMap[tenant1.ID].RefundOrders, ShouldEqual, 0)
So(itemMap[tenant1.ID].HealthLevel, ShouldEqual, "healthy")
So(itemMap[tenant2.ID].PaidOrders, ShouldEqual, 1)
So(itemMap[tenant2.ID].RefundOrders, ShouldEqual, 1)
So(itemMap[tenant2.ID].HealthLevel, ShouldEqual, "risk")
})
}
func (s *SuperTestSuite) Test_ContentReview() {
Convey("ContentReview", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameTenant, models.TableNameContent)
admin := &models.User{Username: "review_admin"}
owner := &models.User{Username: "review_owner"}
models.UserQuery.WithContext(ctx).Create(admin, owner)
tenant := &models.Tenant{
UserID: owner.ID,
Name: "Review Tenant",
Code: "review",
Status: consts.TenantStatusVerified,
}
models.TenantQuery.WithContext(ctx).Create(tenant)
content := &models.Content{
TenantID: tenant.ID,
UserID: owner.ID,
Title: "Review Content",
Status: consts.ContentStatusReviewing,
}
models.ContentQuery.WithContext(ctx).Create(content)
err := Super.ReviewContent(ctx, admin.ID, content.ID, &super_dto.SuperContentReviewForm{
Action: "approve",
})
So(err, ShouldBeNil)
reloaded, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(content.ID)).First()
So(reloaded.Status, ShouldEqual, consts.ContentStatusPublished)
So(reloaded.PublishedAt.IsZero(), ShouldBeFalse)
content2 := &models.Content{
TenantID: tenant.ID,
UserID: owner.ID,
Title: "Review Content 2",
Status: consts.ContentStatusReviewing,
}
models.ContentQuery.WithContext(ctx).Create(content2)
err = Super.ReviewContent(ctx, admin.ID, content2.ID, &super_dto.SuperContentReviewForm{
Action: "reject",
Reason: "Policy violation",
})
So(err, ShouldBeNil)
reloaded2, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(content2.ID)).First()
So(reloaded2.Status, ShouldEqual, consts.ContentStatusBlocked)
})
}