From 5ac2ea028c79ab719ca9c73b9151ba0666699bb3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 13 Jan 2026 15:02:05 +0800 Subject: [PATCH] feat: add super admin health review --- backend/app/http/super/v1/contents.go | 19 ++ backend/app/http/super/v1/dto/super.go | 44 +++ backend/app/http/super/v1/routes.gen.go | 13 + backend/app/http/super/v1/tenants.go | 16 + backend/app/services/super.go | 432 ++++++++++++++++++++++++ backend/app/services/super_test.go | 156 +++++++++ backend/docs/docs.go | 198 +++++++++++ backend/docs/swagger.json | 198 +++++++++++ backend/docs/swagger.yaml | 131 +++++++ docs/todo_list.md | 1 + 10 files changed, 1208 insertions(+) diff --git a/backend/app/http/super/v1/contents.go b/backend/app/http/super/v1/contents.go index 2310750..e00a61c 100644 --- a/backend/app/http/super/v1/contents.go +++ b/backend/app/http/super/v1/contents.go @@ -4,6 +4,7 @@ import ( dto "quyun/v2/app/http/super/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" ) @@ -67,3 +68,21 @@ func (c *contents) ListTenantContents(ctx fiber.Ctx, tenantID int64, filter *dto func (c *contents) UpdateStatus(ctx fiber.Ctx, tenantID, contentID int64, form *dto.SuperTenantContentStatusUpdateForm) error { return services.Super.UpdateContentStatus(ctx, tenantID, contentID, form) } + +// Review content +// +// @Router /super/v1/contents/:id/review [post] +// @Summary Review content +// @Description Review content +// @Tags Content +// @Accept json +// @Produce json +// @Param id path int64 true "Content ID" +// @Param form body dto.SuperContentReviewForm true "Review form" +// @Success 200 {string} string "Reviewed" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *contents) Review(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperContentReviewForm) error { + return services.Super.ReviewContent(ctx, user.ID, id, form) +} diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 13977bf..241c3e9 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -294,6 +294,43 @@ type TenantItem struct { Users []*SuperUserLite `json:"users"` } +type TenantHealthItem struct { + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // Code 租户编码。 + Code string `json:"code"` + // Name 租户名称。 + Name string `json:"name"` + // Status 租户状态。 + Status consts.TenantStatus `json:"status"` + // StatusDescription 租户状态描述(用于展示)。 + StatusDescription string `json:"status_description"` + // Owner 租户所有者信息。 + Owner *TenantOwnerUserLite `json:"owner"` + // MemberCount 租户成员数量(包含管理员)。 + MemberCount int64 `json:"member_count"` + // ContentCount 内容总数。 + ContentCount int64 `json:"content_count"` + // PublishedContentCount 已发布内容数量。 + PublishedContentCount int64 `json:"published_content_count"` + // PaidOrders 已支付订单数(内容购买)。 + PaidOrders int64 `json:"paid_orders"` + // PaidAmount 已支付金额(分)。 + PaidAmount int64 `json:"paid_amount"` + // RefundOrders 已退款订单数(内容购买)。 + RefundOrders int64 `json:"refund_orders"` + // RefundAmount 已退款金额(分)。 + RefundAmount int64 `json:"refund_amount"` + // RefundRate 退款率(退款订单数 / 已支付订单数)。 + RefundRate float64 `json:"refund_rate"` + // LastPaidAt 最近成交时间(RFC3339,空代表暂无成交)。 + LastPaidAt string `json:"last_paid_at"` + // HealthLevel 健康等级(healthy/warning/risk)。 + HealthLevel string `json:"health_level"` + // Alerts 异常提示列表(用于运营侧提示)。 + Alerts []string `json:"alerts"` +} + type TenantOwnerUserLite struct { // ID 用户ID。 ID int64 `json:"id"` @@ -347,6 +384,13 @@ type SuperTenantContentStatusUpdateForm struct { Status consts.ContentStatus `json:"status" validate:"required,oneof=unpublished blocked"` } +type SuperContentReviewForm struct { + // Action 审核动作(approve/reject)。 + Action string `json:"action" validate:"required,oneof=approve reject"` + // Reason 审核说明(驳回时填写,便于作者修正)。 + Reason string `json:"reason"` +} + type SuperTenantUserItem struct { // User 用户信息。 User *SuperUserLite `json:"user"` diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index bfb6d2f..1fa40d4 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -7,6 +7,7 @@ package v1 import ( dto "quyun/v2/app/http/super/v1/dto" "quyun/v2/app/middlewares" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" log "github.com/sirupsen/logrus" @@ -63,6 +64,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("contentID"), Body[dto.SuperTenantContentStatusUpdateForm]("form"), )) + r.log.Debugf("Registering route: Post /super/v1/contents/:id/review -> contents.Review") + router.Post("/super/v1/contents/:id/review"[len(r.Path()):], Func3( + r.contents.Review, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperContentReviewForm]("form"), + )) // Register routes for controller: orders r.log.Debugf("Registering route: Get /super/v1/orders -> orders.List") router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1( @@ -101,6 +109,11 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("tenantID"), Query[dto.SuperTenantUserListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/health -> tenants.Health") + router.Get("/super/v1/tenants/health"[len(r.Path()):], DataFunc1( + r.tenants.Health, + Query[dto.TenantListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/tenants/statuses -> tenants.Statuses") router.Get("/super/v1/tenants/statuses"[len(r.Path()):], DataFunc0( r.tenants.Statuses, diff --git a/backend/app/http/super/v1/tenants.go b/backend/app/http/super/v1/tenants.go index 3423fd9..3f05f47 100644 --- a/backend/app/http/super/v1/tenants.go +++ b/backend/app/http/super/v1/tenants.go @@ -28,6 +28,22 @@ func (c *tenants) List(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.P return services.Super.ListTenants(ctx, filter) } +// Tenant health overview +// +// @Router /super/v1/tenants/health [get] +// @Summary Tenant health overview +// @Description Tenant health overview +// @Tags Tenant +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.TenantHealthItem} +// @Bind filter query +func (c *tenants) Health(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.Pager, error) { + return services.Super.TenantHealth(ctx, filter) +} + // List tenant users // // @Router /super/v1/tenants/:tenantID/users [get] diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 08a4d77..be018f3 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -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)) diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 2545421..4762761 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -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) + }) +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index a9410ff..96173b2 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -133,6 +133,48 @@ const docTemplate = `{ } } }, + "/super/v1/contents/{id}/review": { + "post": { + "description": "Review content", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Review content", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Content ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -373,6 +415,58 @@ const docTemplate = `{ } } }, + "/super/v1/tenants/health": { + "get": { + "description": "Tenant health overview", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "Tenant health overview", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantHealthItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/tenants/statuses": { "get": { "description": "Tenant statuses", @@ -4677,6 +4771,26 @@ const docTemplate = `{ } } }, + "dto.SuperContentReviewForm": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string", + "enum": [ + "approve", + "reject" + ] + }, + "reason": { + "description": "Reason 审核说明(驳回时填写,便于作者修正)。", + "type": "string" + } + } + }, "dto.SuperContentTenantLite": { "type": "object", "properties": { @@ -5004,6 +5118,90 @@ const docTemplate = `{ } } }, + "dto.TenantHealthItem": { + "type": "object", + "properties": { + "alerts": { + "description": "Alerts 异常提示列表(用于运营侧提示)。", + "type": "array", + "items": { + "type": "string" + } + }, + "code": { + "description": "Code 租户编码。", + "type": "string" + }, + "content_count": { + "description": "ContentCount 内容总数。", + "type": "integer" + }, + "health_level": { + "description": "HealthLevel 健康等级(healthy/warning/risk)。", + "type": "string" + }, + "last_paid_at": { + "description": "LastPaidAt 最近成交时间(RFC3339,空代表暂无成交)。", + "type": "string" + }, + "member_count": { + "description": "MemberCount 租户成员数量(包含管理员)。", + "type": "integer" + }, + "name": { + "description": "Name 租户名称。", + "type": "string" + }, + "owner": { + "description": "Owner 租户所有者信息。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantOwnerUserLite" + } + ] + }, + "paid_amount": { + "description": "PaidAmount 已支付金额(分)。", + "type": "integer" + }, + "paid_orders": { + "description": "PaidOrders 已支付订单数(内容购买)。", + "type": "integer" + }, + "published_content_count": { + "description": "PublishedContentCount 已发布内容数量。", + "type": "integer" + }, + "refund_amount": { + "description": "RefundAmount 已退款金额(分)。", + "type": "integer" + }, + "refund_orders": { + "description": "RefundOrders 已退款订单数(内容购买)。", + "type": "integer" + }, + "refund_rate": { + "description": "RefundRate 退款率(退款订单数 / 已支付订单数)。", + "type": "number" + }, + "status": { + "description": "Status 租户状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 租户状态描述(用于展示)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + } + } + }, "dto.TenantInviteAcceptForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 58fec52..b3fed3a 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -127,6 +127,48 @@ } } }, + "/super/v1/contents/{id}/review": { + "post": { + "description": "Review content", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Review content", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Content ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -367,6 +409,58 @@ } } }, + "/super/v1/tenants/health": { + "get": { + "description": "Tenant health overview", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "Tenant health overview", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantHealthItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/tenants/statuses": { "get": { "description": "Tenant statuses", @@ -4671,6 +4765,26 @@ } } }, + "dto.SuperContentReviewForm": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string", + "enum": [ + "approve", + "reject" + ] + }, + "reason": { + "description": "Reason 审核说明(驳回时填写,便于作者修正)。", + "type": "string" + } + } + }, "dto.SuperContentTenantLite": { "type": "object", "properties": { @@ -4998,6 +5112,90 @@ } } }, + "dto.TenantHealthItem": { + "type": "object", + "properties": { + "alerts": { + "description": "Alerts 异常提示列表(用于运营侧提示)。", + "type": "array", + "items": { + "type": "string" + } + }, + "code": { + "description": "Code 租户编码。", + "type": "string" + }, + "content_count": { + "description": "ContentCount 内容总数。", + "type": "integer" + }, + "health_level": { + "description": "HealthLevel 健康等级(healthy/warning/risk)。", + "type": "string" + }, + "last_paid_at": { + "description": "LastPaidAt 最近成交时间(RFC3339,空代表暂无成交)。", + "type": "string" + }, + "member_count": { + "description": "MemberCount 租户成员数量(包含管理员)。", + "type": "integer" + }, + "name": { + "description": "Name 租户名称。", + "type": "string" + }, + "owner": { + "description": "Owner 租户所有者信息。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantOwnerUserLite" + } + ] + }, + "paid_amount": { + "description": "PaidAmount 已支付金额(分)。", + "type": "integer" + }, + "paid_orders": { + "description": "PaidOrders 已支付订单数(内容购买)。", + "type": "integer" + }, + "published_content_count": { + "description": "PublishedContentCount 已发布内容数量。", + "type": "integer" + }, + "refund_amount": { + "description": "RefundAmount 已退款金额(分)。", + "type": "integer" + }, + "refund_orders": { + "description": "RefundOrders 已退款订单数(内容购买)。", + "type": "integer" + }, + "refund_rate": { + "description": "RefundRate 退款率(退款订单数 / 已支付订单数)。", + "type": "number" + }, + "status": { + "description": "Status 租户状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 租户状态描述(用于展示)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + } + } + }, "dto.TenantInviteAcceptForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 2fdf9f8..75e54a6 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -879,6 +879,20 @@ definitions: description: Likes 累计点赞数。 type: integer type: object + dto.SuperContentReviewForm: + properties: + action: + description: Action 审核动作(approve/reject)。 + enum: + - approve + - reject + type: string + reason: + description: Reason 审核说明(驳回时填写,便于作者修正)。 + type: string + required: + - action + type: object dto.SuperContentTenantLite: properties: code: @@ -1093,6 +1107,64 @@ definitions: required: - duration type: object + dto.TenantHealthItem: + properties: + alerts: + description: Alerts 异常提示列表(用于运营侧提示)。 + items: + type: string + type: array + code: + description: Code 租户编码。 + type: string + content_count: + description: ContentCount 内容总数。 + type: integer + health_level: + description: HealthLevel 健康等级(healthy/warning/risk)。 + type: string + last_paid_at: + description: LastPaidAt 最近成交时间(RFC3339,空代表暂无成交)。 + type: string + member_count: + description: MemberCount 租户成员数量(包含管理员)。 + type: integer + name: + description: Name 租户名称。 + type: string + owner: + allOf: + - $ref: '#/definitions/dto.TenantOwnerUserLite' + description: Owner 租户所有者信息。 + paid_amount: + description: PaidAmount 已支付金额(分)。 + type: integer + paid_orders: + description: PaidOrders 已支付订单数(内容购买)。 + type: integer + published_content_count: + description: PublishedContentCount 已发布内容数量。 + type: integer + refund_amount: + description: RefundAmount 已退款金额(分)。 + type: integer + refund_orders: + description: RefundOrders 已退款订单数(内容购买)。 + type: integer + refund_rate: + description: RefundRate 退款率(退款订单数 / 已支付订单数)。 + type: number + status: + allOf: + - $ref: '#/definitions/consts.TenantStatus' + description: Status 租户状态。 + status_description: + description: StatusDescription 租户状态描述(用于展示)。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + type: object dto.TenantInviteAcceptForm: properties: code: @@ -1827,6 +1899,34 @@ paths: summary: List contents tags: - Content + /super/v1/contents/{id}/review: + post: + consumes: + - application/json + description: Review content + parameters: + - description: Content ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Review form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperContentReviewForm' + produces: + - application/json + responses: + "200": + description: Reviewed + schema: + type: string + summary: Review content + tags: + - Content /super/v1/orders: get: consumes: @@ -2164,6 +2264,37 @@ paths: summary: List tenant users tags: - Tenant + /super/v1/tenants/health: + get: + consumes: + - application/json + description: Tenant health overview + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.TenantHealthItem' + type: array + type: object + summary: Tenant health overview + tags: + - Tenant /super/v1/tenants/statuses: get: consumes: diff --git a/docs/todo_list.md b/docs/todo_list.md index cd831c5..2acaba1 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -197,6 +197,7 @@ - 内容资源权限与预览差异化(未购预览、已购/管理员/成员全量)。 - 审计操作显式传入操作者信息(服务层不再依赖 ctx 读取)。 - 运营统计报表(overview + CSV 导出基础版)。 +- 超管后台治理能力(健康度/异常监控/内容审核)。 ## 里程碑建议 - M1:完成 P0