Files
quyun-v2/backend/app/services/content_super.go
Rogee d60c1e9312 feat: add content management feature for superadmin
- Implemented API endpoint for fetching content list with filtering, sorting, and pagination.
- Added DTOs for content items and tenant information.
- Created frontend components for content management, including search and data table functionalities.
- Updated routing to include content management page.
- Enhanced the superadmin menu to navigate to the new content management section.
- Included necessary styles and scripts for the new content management interface.
2025-12-24 16:24:50 +08:00

495 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"strings"
"time"
"quyun/v2/app/errorx"
superdto "quyun/v2/app/http/super/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/pkg/errors"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"go.ipao.vip/gen/field"
)
// SuperTenantContentsPage returns tenant contents list for superadmin.
func (s *content) SuperTenantContentsPage(ctx context.Context, tenantID int64, filter *superdto.TenantContentFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errors.New("tenant_id must be > 0")
}
if filter == nil {
filter = &superdto.TenantContentFilter{}
}
log.WithFields(log.Fields{
"tenant_id": tenantID,
"page": filter.Page,
"limit": filter.Limit,
}).Info("services.content.super_tenant_contents_page")
tbl, query := models.ContentQuery.QueryContext(ctx)
conds := []gen.Condition{
tbl.TenantID.Eq(tenantID),
tbl.DeletedAt.IsNull(),
}
if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" {
conds = append(conds, tbl.Title.Like(database.WrapLike(kw)))
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.Visibility != nil {
conds = append(conds, tbl.Visibility.Eq(*filter.Visibility))
}
if filter.UserID != nil && *filter.UserID > 0 {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.PublishedAtFrom != nil {
conds = append(conds, tbl.PublishedAt.Gte(*filter.PublishedAtFrom))
}
if filter.PublishedAtTo != nil {
conds = append(conds, tbl.PublishedAt.Lte(*filter.PublishedAtTo))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
filter.Pagination.Format()
orderBys := make([]field.Expr, 0, 6)
allowedAsc := map[string]field.Expr{
"id": tbl.ID.Asc(),
"title": tbl.Title.Asc(),
"user_id": tbl.UserID.Asc(),
"status": tbl.Status.Asc(),
"visibility": tbl.Visibility.Asc(),
"published_at": tbl.PublishedAt.Asc(),
"created_at": tbl.CreatedAt.Asc(),
"updated_at": tbl.UpdatedAt.Asc(),
}
allowedDesc := map[string]field.Expr{
"id": tbl.ID.Desc(),
"title": tbl.Title.Desc(),
"user_id": tbl.UserID.Desc(),
"status": tbl.Status.Desc(),
"visibility": tbl.Visibility.Desc(),
"published_at": tbl.PublishedAt.Desc(),
"created_at": tbl.CreatedAt.Desc(),
"updated_at": tbl.UpdatedAt.Desc(),
}
for _, f := range filter.AscFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedAsc[f]; ok {
orderBys = append(orderBys, ob)
}
}
for _, f := range filter.DescFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedDesc[f]; ok {
orderBys = append(orderBys, ob)
}
}
if len(orderBys) == 0 {
orderBys = append(orderBys, tbl.ID.Desc())
} else {
orderBys = append(orderBys, tbl.ID.Desc())
}
items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 {
if item == nil {
return 0
}
return item.ID
})
contentIDs = lo.Filter(contentIDs, func(id int64, _ int) bool { return id > 0 })
priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs)
if err != nil {
return nil, err
}
ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) {
if item == nil || item.UserID <= 0 {
return 0, false
}
return item.UserID, true
}))
ownerMap := map[int64]*superdto.SuperUserLite{}
if len(ownerIDs) > 0 {
uTbl, uQuery := models.UserQuery.QueryContext(ctx)
users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find()
if err != nil {
return nil, err
}
for _, u := range users {
if u == nil {
continue
}
ownerMap[u.ID] = &superdto.SuperUserLite{
ID: u.ID,
Username: u.Username,
Status: u.Status,
Roles: u.Roles,
VerifiedAt: u.VerifiedAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
StatusDescription: u.Status.Description(),
}
}
}
respItems := lo.Map(items, func(model *models.Content, _ int) *superdto.SuperTenantContentItem {
if model == nil {
return nil
}
return &superdto.SuperTenantContentItem{
Content: model,
Price: priceByContent[model.ID],
Owner: ownerMap[model.UserID],
StatusDescription: model.Status.Description(),
VisibilityDescription: model.Visibility.Description(),
}
})
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: respItems,
}, nil
}
func (s *content) SuperContentPage(ctx context.Context, filter *superdto.SuperContentPageFilter) (*requests.Pager, error) {
if filter == nil {
filter = &superdto.SuperContentPageFilter{}
}
log.WithFields(log.Fields{
"tenant_id": lo.FromPtr(filter.TenantID),
"tenant_code": filter.TenantCodeTrimmed(),
"tenant_name": filter.TenantNameTrimmed(),
"user_id": lo.FromPtr(filter.UserID),
"username": filter.UsernameTrimmed(),
"keyword": filter.KeywordTrimmed(),
"status": lo.FromPtr(filter.Status),
"visibility": lo.FromPtr(filter.Visibility),
"page": filter.Page,
"limit": filter.Limit,
}).Info("services.content.super_page")
filter.Pagination.Format()
cTbl, query := models.ContentQuery.QueryContext(ctx)
// 注意:该查询会按需 join users/tenants/content_prices必须显式 select contents.*
// 否则重复列名id/created_at/updated_at 等)会被扫描到 Content 模型上导致字段错乱。
query = query.Select(cTbl.ALL)
conds := []gen.Condition{
cTbl.DeletedAt.IsNull(),
}
if filter.ID != nil && *filter.ID > 0 {
conds = append(conds, cTbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
conds = append(conds, cTbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
conds = append(conds, cTbl.UserID.Eq(*filter.UserID))
}
if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" {
conds = append(conds, cTbl.Title.Like(database.WrapLike(kw)))
}
if filter.Status != nil {
conds = append(conds, cTbl.Status.Eq(*filter.Status))
}
if filter.Visibility != nil {
conds = append(conds, cTbl.Visibility.Eq(*filter.Visibility))
}
if filter.PublishedAtFrom != nil {
conds = append(conds, cTbl.PublishedAt.Gte(*filter.PublishedAtFrom))
}
if filter.PublishedAtTo != nil {
conds = append(conds, cTbl.PublishedAt.Lte(*filter.PublishedAtTo))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, cTbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, cTbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
// Owner username keyword.
if username := filter.UsernameTrimmed(); username != "" {
uTbl, _ := models.UserQuery.QueryContext(ctx)
query = query.LeftJoin(uTbl, uTbl.ID.EqCol(cTbl.UserID))
conds = append(conds, uTbl.Username.Like(database.WrapLike(username)))
}
// Tenant code/name keyword.
tenantCode := filter.TenantCodeTrimmed()
tenantName := filter.TenantNameTrimmed()
if tenantCode != "" || tenantName != "" {
tTbl, _ := models.TenantQuery.QueryContext(ctx)
query = query.LeftJoin(tTbl, tTbl.ID.EqCol(cTbl.TenantID))
if tenantCode != "" {
conds = append(conds, tTbl.Code.Like(database.WrapLike(tenantCode)))
}
if tenantName != "" {
conds = append(conds, tTbl.Name.Like(database.WrapLike(tenantName)))
}
}
// Price amount range filter (content_prices is 1:1 by content_id within tenant).
needPriceJoin := (filter.PriceAmountMin != nil && *filter.PriceAmountMin >= 0) || (filter.PriceAmountMax != nil && *filter.PriceAmountMax >= 0)
if needPriceJoin {
cpTbl, _ := models.ContentPriceQuery.QueryContext(ctx)
query = query.LeftJoin(cpTbl, cpTbl.ContentID.EqCol(cTbl.ID))
if filter.PriceAmountMin != nil && *filter.PriceAmountMin >= 0 {
conds = append(conds, cpTbl.PriceAmount.Gte(*filter.PriceAmountMin))
}
if filter.PriceAmountMax != nil && *filter.PriceAmountMax >= 0 {
conds = append(conds, cpTbl.PriceAmount.Lte(*filter.PriceAmountMax))
}
}
// Sort whitelist.
orderBys := make([]field.Expr, 0, 8)
allowedAsc := map[string]field.Expr{
"id": cTbl.ID.Asc(),
"tenant_id": cTbl.TenantID.Asc(),
"user_id": cTbl.UserID.Asc(),
"title": cTbl.Title.Asc(),
"status": cTbl.Status.Asc(),
"visibility": cTbl.Visibility.Asc(),
"published_at": cTbl.PublishedAt.Asc(),
"created_at": cTbl.CreatedAt.Asc(),
"updated_at": cTbl.UpdatedAt.Asc(),
}
allowedDesc := map[string]field.Expr{
"id": cTbl.ID.Desc(),
"tenant_id": cTbl.TenantID.Desc(),
"user_id": cTbl.UserID.Desc(),
"title": cTbl.Title.Desc(),
"status": cTbl.Status.Desc(),
"visibility": cTbl.Visibility.Desc(),
"published_at": cTbl.PublishedAt.Desc(),
"created_at": cTbl.CreatedAt.Desc(),
"updated_at": cTbl.UpdatedAt.Desc(),
}
for _, f := range filter.AscFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedAsc[f]; ok {
orderBys = append(orderBys, ob)
}
}
for _, f := range filter.DescFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedDesc[f]; ok {
orderBys = append(orderBys, ob)
}
}
if len(orderBys) == 0 {
orderBys = append(orderBys, cTbl.ID.Desc())
} else {
orderBys = append(orderBys, cTbl.ID.Desc())
}
items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
tenantIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) {
if item == nil || item.TenantID <= 0 {
return 0, false
}
return item.TenantID, true
}))
ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) {
if item == nil || item.UserID <= 0 {
return 0, false
}
return item.UserID, true
}))
contentIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) {
if item == nil || item.ID <= 0 {
return 0, false
}
return item.ID, true
}))
tenantMap := map[int64]*models.Tenant{}
if len(tenantIDs) > 0 {
tTbl, tQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, err
}
for _, te := range tenants {
if te == nil {
continue
}
tenantMap[te.ID] = te
}
}
ownerMap := map[int64]*superdto.SuperUserLite{}
if len(ownerIDs) > 0 {
uTbl, uQuery := models.UserQuery.QueryContext(ctx)
users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find()
if err != nil {
return nil, err
}
for _, u := range users {
if u == nil {
continue
}
ownerMap[u.ID] = &superdto.SuperUserLite{
ID: u.ID,
Username: u.Username,
Status: u.Status,
Roles: u.Roles,
VerifiedAt: u.VerifiedAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
StatusDescription: u.Status.Description(),
}
}
}
priceByContent := map[int64]*models.ContentPrice{}
if len(contentIDs) > 0 {
cpTbl, cpQuery := models.ContentPriceQuery.QueryContext(ctx)
conds := []gen.Condition{
cpTbl.ContentID.In(contentIDs...),
}
if len(tenantIDs) > 0 {
conds = append(conds, cpTbl.TenantID.In(tenantIDs...))
}
prices, err := cpQuery.Where(conds...).Find()
if err != nil {
return nil, err
}
for _, p := range prices {
if p == nil {
continue
}
priceByContent[p.ContentID] = p
}
}
respItems := lo.Map(items, func(model *models.Content, _ int) *superdto.SuperContentItem {
if model == nil {
return nil
}
te := tenantMap[model.TenantID]
var lite *superdto.SuperContentTenantLite
if te != nil {
lite = &superdto.SuperContentTenantLite{
ID: te.ID,
Code: te.Code,
Name: te.Name,
}
}
return &superdto.SuperContentItem{
Content: model,
Price: priceByContent[model.ID],
Tenant: lite,
Owner: ownerMap[model.UserID],
StatusDescription: model.Status.Description(),
VisibilityDescription: model.Visibility.Description(),
}
})
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: respItems,
}, nil
}
func (s *content) SuperUpdateTenantContentStatus(
ctx context.Context,
operatorUserID, tenantID, contentID int64,
status consts.ContentStatus,
now time.Time,
) (*models.Content, error) {
if operatorUserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
if tenantID <= 0 {
return nil, errors.New("tenant_id must be > 0")
}
if contentID <= 0 {
return nil, errors.New("content_id must be > 0")
}
if status != consts.ContentStatusUnpublished && status != consts.ContentStatusBlocked {
return nil, errorx.ErrInvalidParameter.WithMsg("invalid status")
}
log.WithFields(log.Fields{
"operator_user_id": operatorUserID,
"tenant_id": tenantID,
"content_id": contentID,
"status": status,
}).Info("services.content.super_update_tenant_content_status")
tbl, query := models.ContentQuery.QueryContext(ctx)
model, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ID.Eq(contentID),
tbl.DeletedAt.IsNull(),
).First()
if err != nil {
return nil, err
}
if status == consts.ContentStatusUnpublished && model.Status != consts.ContentStatusPublished {
return nil, errorx.ErrPreconditionFailed.WithMsg("content is not published")
}
if _, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ID.Eq(contentID),
tbl.DeletedAt.IsNull(),
).UpdateSimple(
tbl.Status.Value(status),
); err != nil {
return nil, err
}
model.Status = status
model.UpdatedAt = now
return model, nil
}