feat: add super admin health review
This commit is contained in:
@@ -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<int>/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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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<int>/review -> contents.Review")
|
||||
router.Post("/super/v1/contents/:id<int>/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,
|
||||
|
||||
@@ -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<int>/users [get]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
- 内容资源权限与预览差异化(未购预览、已购/管理员/成员全量)。
|
||||
- 审计操作显式传入操作者信息(服务层不再依赖 ctx 读取)。
|
||||
- 运营统计报表(overview + CSV 导出基础版)。
|
||||
- 超管后台治理能力(健康度/异常监控/内容审核)。
|
||||
|
||||
## 里程碑建议
|
||||
- M1:完成 P0
|
||||
|
||||
Reference in New Issue
Block a user