diff --git a/backend/app/http/super/v1/content_reports.go b/backend/app/http/super/v1/content_reports.go new file mode 100644 index 0000000..55b0846 --- /dev/null +++ b/backend/app/http/super/v1/content_reports.go @@ -0,0 +1,47 @@ +package v1 + +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" +) + +// @provider +type contentReports struct{} + +// List content reports +// +// @Router /super/v1/content-reports [get] +// @Summary List content reports +// @Description List content report records across tenants +// @Tags Content +// @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.SuperContentReportItem} +// @Bind filter query +func (c *contentReports) List(ctx fiber.Ctx, filter *dto.SuperContentReportListFilter) (*requests.Pager, error) { + return services.Super.ListContentReports(ctx, filter) +} + +// Process content report +// +// @Router /super/v1/content-reports/:id/process [post] +// @Summary Process content report +// @Description Process a content report record +// @Tags Content +// @Accept json +// @Produce json +// @Param id path int64 true "Report ID" +// @Param form body dto.SuperContentReportProcessForm true "Process form" +// @Success 200 {string} string "Processed" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *contentReports) Process(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperContentReportProcessForm) error { + return services.Super.ProcessContentReport(ctx, user.ID, id, form) +} diff --git a/backend/app/http/super/v1/dto/super_content_report.go b/backend/app/http/super/v1/dto/super_content_report.go new file mode 100644 index 0000000..8a2b4ec --- /dev/null +++ b/backend/app/http/super/v1/dto/super_content_report.go @@ -0,0 +1,100 @@ +package dto + +import "quyun/v2/app/requests" + +// SuperContentReportListFilter 超管内容举报列表过滤条件。 +type SuperContentReportListFilter struct { + requests.Pagination + // ID 举报ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // ContentID 内容ID,精确匹配。 + ContentID *int64 `query:"content_id"` + // ContentTitle 内容标题关键字,模糊匹配。 + ContentTitle *string `query:"content_title"` + // ReporterID 举报人用户ID,精确匹配。 + ReporterID *int64 `query:"reporter_id"` + // ReporterName 举报人用户名/昵称,模糊匹配。 + ReporterName *string `query:"reporter_name"` + // HandledBy 处理人用户ID,精确匹配。 + HandledBy *int64 `query:"handled_by"` + // HandledByName 处理人用户名/昵称,模糊匹配。 + HandledByName *string `query:"handled_by_name"` + // Reason 举报原因关键字,模糊匹配。 + Reason *string `query:"reason"` + // Keyword 举报描述关键字,模糊匹配。 + Keyword *string `query:"keyword"` + // Status 处理状态(pending/approved/rejected/all)。 + Status *string `query:"status"` + // CreatedAtFrom 举报时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 举报时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // HandledAtFrom 处理时间起始(RFC3339)。 + HandledAtFrom *string `query:"handled_at_from"` + // HandledAtTo 处理时间结束(RFC3339)。 + HandledAtTo *string `query:"handled_at_to"` + // Asc 升序字段(id/created_at/handled_at/status)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at/handled_at/status)。 + Desc *string `query:"desc"` +} + +// SuperContentReportItem 超管内容举报列表项。 +type SuperContentReportItem struct { + // ID 举报ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // ContentID 内容ID。 + ContentID int64 `json:"content_id"` + // ContentTitle 内容标题。 + ContentTitle string `json:"content_title"` + // ContentStatus 内容状态。 + ContentStatus string `json:"content_status"` + // ContentOwnerID 内容作者用户ID。 + ContentOwnerID int64 `json:"content_owner_id"` + // ContentOwnerName 内容作者用户名/昵称。 + ContentOwnerName string `json:"content_owner_name"` + // ReporterID 举报人用户ID。 + ReporterID int64 `json:"reporter_id"` + // ReporterName 举报人用户名/昵称。 + ReporterName string `json:"reporter_name"` + // Reason 举报原因。 + Reason string `json:"reason"` + // Detail 举报描述。 + Detail string `json:"detail"` + // Status 处理状态。 + Status string `json:"status"` + // HandledBy 处理人用户ID。 + HandledBy int64 `json:"handled_by"` + // HandledByName 处理人用户名/昵称。 + HandledByName string `json:"handled_by_name"` + // HandledAction 处理动作(block/unpublish/ignore)。 + HandledAction string `json:"handled_action"` + // HandledReason 处理说明。 + HandledReason string `json:"handled_reason"` + // CreatedAt 举报时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // HandledAt 处理时间(RFC3339)。 + HandledAt string `json:"handled_at"` +} + +// SuperContentReportProcessForm 超管内容举报处理表单。 +type SuperContentReportProcessForm struct { + // Action 处理动作(approve/reject)。 + Action string `json:"action"` + // ContentAction 内容处置动作(block/unpublish/ignore),仅在 approve 时生效。 + ContentAction string `json:"content_action"` + // Reason 处理说明(可选,用于审计记录)。 + Reason string `json:"reason"` +} diff --git a/backend/app/http/super/v1/provider.gen.go b/backend/app/http/super/v1/provider.gen.go index 0d17894..2c8982e 100755 --- a/backend/app/http/super/v1/provider.gen.go +++ b/backend/app/http/super/v1/provider.gen.go @@ -31,6 +31,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*contentReports, error) { + obj := &contentReports{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*contents, error) { obj := &contents{} @@ -98,6 +105,7 @@ func Provide(opts ...opt.Option) error { assets *assets, auditLogs *auditLogs, comments *comments, + contentReports *contentReports, contents *contents, coupons *coupons, creatorApplications *creatorApplications, @@ -117,6 +125,7 @@ func Provide(opts ...opt.Option) error { assets: assets, auditLogs: auditLogs, comments: comments, + contentReports: contentReports, contents: contents, coupons: coupons, creatorApplications: creatorApplications, diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 132add4..a44fa66 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -28,6 +28,7 @@ type Routes struct { assets *assets auditLogs *auditLogs comments *comments + contentReports *contentReports contents *contents coupons *coupons creatorApplications *creatorApplications @@ -94,6 +95,19 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.SuperCommentDeleteForm]("form"), )) + // Register routes for controller: contentReports + r.log.Debugf("Registering route: Get /super/v1/content-reports -> contentReports.List") + router.Get("/super/v1/content-reports"[len(r.Path()):], DataFunc1( + r.contentReports.List, + Query[dto.SuperContentReportListFilter]("filter"), + )) + r.log.Debugf("Registering route: Post /super/v1/content-reports/:id/process -> contentReports.Process") + router.Post("/super/v1/content-reports/:id/process"[len(r.Path()):], Func3( + r.contentReports.Process, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperContentReportProcessForm]("form"), + )) // Register routes for controller: contents r.log.Debugf("Registering route: Get /super/v1/contents -> contents.List") router.Get("/super/v1/contents"[len(r.Path()):], DataFunc1( diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 91e1d67..22d372a 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -2611,6 +2611,474 @@ func (s *super) DeleteComment(ctx context.Context, operatorID, id int64, form *s return nil } +func (s *super) ListContentReports(ctx context.Context, filter *super_dto.SuperContentReportListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperContentReportListFilter{} + } + + tbl, q := models.ContentReportQuery.QueryContext(ctx) + + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.ContentID != nil && *filter.ContentID > 0 { + q = q.Where(tbl.ContentID.Eq(*filter.ContentID)) + } + if filter.ReporterID != nil && *filter.ReporterID > 0 { + q = q.Where(tbl.ReporterID.Eq(*filter.ReporterID)) + } + if filter.HandledBy != nil && *filter.HandledBy > 0 { + q = q.Where(tbl.HandledBy.Eq(*filter.HandledBy)) + } + if filter.Reason != nil && strings.TrimSpace(*filter.Reason) != "" { + keyword := "%" + strings.TrimSpace(*filter.Reason) + "%" + q = q.Where(tbl.Reason.Like(keyword)) + } + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + q = q.Where(field.Or(tbl.Detail.Like(keyword), tbl.Reason.Like(keyword))) + } + + status := "" + if filter.Status != nil { + status = strings.TrimSpace(*filter.Status) + } + if status != "" && status != "all" { + q = q.Where(tbl.Status.Eq(status)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + reporterIDs, reporterFilter, err := s.lookupUserIDs(ctx, filter.ReporterName) + if err != nil { + return nil, err + } + if reporterFilter { + if len(reporterIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.ReporterID.In(reporterIDs...)) + } + } + + handledIDs, handledFilter, err := s.lookupUserIDs(ctx, filter.HandledByName) + if err != nil { + return nil, err + } + if handledFilter { + if len(handledIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.HandledBy.In(handledIDs...)) + } + } + + if filter.ContentTitle != nil && strings.TrimSpace(*filter.ContentTitle) != "" { + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + contentQuery = contentQuery.Unscoped() + keyword := "%" + strings.TrimSpace(*filter.ContentTitle) + "%" + contentQuery = contentQuery.Where(field.Or( + contentTbl.Title.Like(keyword), + contentTbl.Description.Like(keyword), + contentTbl.Summary.Like(keyword), + )) + if filter.TenantID != nil && *filter.TenantID > 0 { + contentQuery = contentQuery.Where(contentTbl.TenantID.Eq(*filter.TenantID)) + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + contentQuery = contentQuery.Where(contentTbl.TenantID.In(tenantIDs...)) + } + } + contentIDs, err := contentQuery.Select(contentTbl.ID).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + ids := make([]int64, 0, len(contentIDs)) + for _, content := range contentIDs { + ids = append(ids, content.ID) + } + if len(ids) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.ContentID.In(ids...)) + } + } + + 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)) + } + } + + if filter.HandledAtFrom != nil { + from, err := s.parseFilterTime(filter.HandledAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.HandledAt.Gte(*from)) + } + } + if filter.HandledAtTo != nil { + to, err := s.parseFilterTime(filter.HandledAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.HandledAt.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 "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "handled_at": + q = q.Order(tbl.HandledAt.Desc()) + case "status": + q = q.Order(tbl.Status.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 "created_at": + q = q.Order(tbl.CreatedAt) + case "handled_at": + q = q.Order(tbl.HandledAt) + case "status": + q = q.Order(tbl.Status) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.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) + } + if len(list) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: []super_dto.SuperContentReportItem{}, + }, nil + } + + tenantSet := make(map[int64]struct{}) + contentSet := make(map[int64]struct{}) + userSet := make(map[int64]struct{}) + for _, report := range list { + tenantSet[report.TenantID] = struct{}{} + contentSet[report.ContentID] = struct{}{} + if report.ReporterID > 0 { + userSet[report.ReporterID] = struct{}{} + } + if report.HandledBy > 0 { + userSet[report.HandledBy] = struct{}{} + } + } + + tenantIDs = make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + tenantIDs = append(tenantIDs, id) + } + contentIDs := make([]int64, 0, len(contentSet)) + for id := range contentSet { + contentIDs = append(contentIDs, id) + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + if len(tenantIDs) > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + contentMap := make(map[int64]*models.Content, len(contentIDs)) + if len(contentIDs) > 0 { + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + contentQuery = contentQuery.Unscoped() + contents, err := contentQuery.Where(contentTbl.ID.In(contentIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, content := range contents { + contentMap[content.ID] = content + if content.UserID > 0 { + userSet[content.UserID] = struct{}{} + } + } + } + + userIDs := make([]int64, 0, len(userSet)) + for id := range userSet { + userIDs = append(userIDs, id) + } + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + items := make([]super_dto.SuperContentReportItem, 0, len(list)) + for _, report := range list { + tenant := tenantMap[report.TenantID] + content := contentMap[report.ContentID] + reporter := userMap[report.ReporterID] + handler := userMap[report.HandledBy] + + reporterName := "" + if reporter != nil { + if reporter.Username != "" { + reporterName = reporter.Username + } else { + reporterName = reporter.Nickname + } + } + if reporterName == "" && report.ReporterID > 0 { + reporterName = "ID:" + strconv.FormatInt(report.ReporterID, 10) + } + + handlerName := "" + if handler != nil { + if handler.Username != "" { + handlerName = handler.Username + } else { + handlerName = handler.Nickname + } + } + if handlerName == "" && report.HandledBy > 0 { + handlerName = "ID:" + strconv.FormatInt(report.HandledBy, 10) + } + + ownerID := int64(0) + ownerName := "" + if content != nil { + ownerID = content.UserID + if owner := userMap[content.UserID]; owner != nil { + if owner.Username != "" { + ownerName = owner.Username + } else { + ownerName = owner.Nickname + } + } + if ownerName == "" && content.UserID > 0 { + ownerName = "ID:" + strconv.FormatInt(content.UserID, 10) + } + } + + item := super_dto.SuperContentReportItem{ + ID: report.ID, + TenantID: report.TenantID, + ContentID: report.ContentID, + ContentTitle: "", + ContentStatus: "", + ContentOwnerID: ownerID, + ContentOwnerName: ownerName, + ReporterID: report.ReporterID, + ReporterName: reporterName, + Reason: report.Reason, + Detail: report.Detail, + Status: report.Status, + HandledBy: report.HandledBy, + HandledByName: handlerName, + HandledAction: report.HandledAction, + HandledReason: report.HandledReason, + CreatedAt: s.formatTime(report.CreatedAt), + HandledAt: s.formatTime(report.HandledAt), + } + if tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if content != nil { + item.ContentTitle = content.Title + item.ContentStatus = string(content.Status) + } + + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) ProcessContentReport(ctx context.Context, operatorID, id int64, form *super_dto.SuperContentReportProcessForm) 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("处理动作非法") + } + + contentAction := strings.ToLower(strings.TrimSpace(form.ContentAction)) + if action == "approve" { + if contentAction == "" { + contentAction = "ignore" + } + switch contentAction { + case "block", "unpublish", "ignore": + default: + return errorx.ErrBadRequest.WithMsg("内容处置动作非法") + } + } else { + contentAction = "ignore" + } + + tbl, q := models.ContentReportQuery.QueryContext(ctx) + report, err := q.Where(tbl.ID.Eq(id)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound + } + return errorx.ErrDatabaseError.WithCause(err) + } + if strings.TrimSpace(report.Status) != "" && report.Status != "pending" { + return errorx.ErrStatusConflict.WithMsg("举报已处理") + } + + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + contentQuery = contentQuery.Unscoped() + content, err := contentQuery.Where(contentTbl.ID.Eq(report.ContentID)).First() + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrDatabaseError.WithCause(err) + } + if errors.Is(err, gorm.ErrRecordNotFound) { + content = nil + } + + status := "approved" + if action == "reject" { + status = "rejected" + } + handledReason := strings.TrimSpace(form.Reason) + handledAt := time.Now() + + err = models.Q.Transaction(func(tx *models.Query) error { + reportTbl, reportQuery := tx.ContentReport.QueryContext(ctx) + _, err := reportQuery.Where(reportTbl.ID.Eq(report.ID)).UpdateSimple( + reportTbl.Status.Value(status), + reportTbl.HandledBy.Value(operatorID), + reportTbl.HandledAction.Value(contentAction), + reportTbl.HandledReason.Value(handledReason), + reportTbl.HandledAt.Value(handledAt), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if action == "approve" && content != nil { + var nextStatus consts.ContentStatus + switch contentAction { + case "block": + nextStatus = consts.ContentStatusBlocked + case "unpublish": + nextStatus = consts.ContentStatusUnpublished + } + if nextStatus != "" && content.Status != nextStatus { + contentTbl, contentQuery := tx.Content.QueryContext(ctx) + _, err := contentQuery.Where(contentTbl.ID.Eq(content.ID)).UpdateSimple( + contentTbl.Status.Value(nextStatus), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + } + } + + return nil + }) + if err != nil { + return err + } + + if action == "approve" && content != nil { + title := "内容举报成立" + actionLabel := "不处理内容" + switch contentAction { + case "block": + actionLabel = "封禁内容" + case "unpublish": + actionLabel = "下架内容" + } + detail := "内容《" + content.Title + "》举报成立,处理结果:" + actionLabel + if handledReason != "" { + detail += "。说明:" + handledReason + } + _ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail) + } + + if Audit != nil { + detail := "处理内容举报:" + action + " / " + contentAction + if handledReason != "" { + detail += " / " + handledReason + } + Audit.Log(ctx, report.TenantID, operatorID, "process_content_report", cast.ToString(report.ID), detail) + } + + return nil +} + func (s *super) UpdateContentStatus(ctx context.Context, tenantID, contentID int64, form *super_dto.SuperTenantContentStatusUpdateForm) error { tbl, q := models.ContentQuery.QueryContext(ctx) _, err := q.Where(tbl.ID.Eq(contentID), tbl.TenantID.Eq(tenantID)).Update(tbl.Status, consts.ContentStatus(form.Status)) diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 8fc38f5..474588a 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -287,6 +287,113 @@ func (s *SuperTestSuite) Test_CommentGovernance() { }) } +func (s *SuperTestSuite) Test_ContentReportGovernance() { + Convey("Content Report Governance", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameContentReport, models.TableNameContent, models.TableNameTenant, models.TableNameUser) + + owner := &models.User{Username: "owner_report"} + reporter := &models.User{Username: "reporter"} + admin := &models.User{Username: "admin_report"} + models.UserQuery.WithContext(ctx).Create(owner, reporter, admin) + + tenant := &models.Tenant{UserID: owner.ID, Code: "t-report", Name: "Report Tenant", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(tenant) + + content := &models.Content{ + TenantID: tenant.ID, + UserID: owner.ID, + Title: "Report Content", + Description: "Report Desc", + Status: consts.ContentStatusPublished, + } + models.ContentQuery.WithContext(ctx).Create(content) + + Convey("should list reports", func() { + report := &models.ContentReport{ + TenantID: tenant.ID, + ContentID: content.ID, + ReporterID: reporter.ID, + Reason: "spam", + Detail: "内容涉嫌违规", + Status: "pending", + } + models.ContentReportQuery.WithContext(ctx).Create(report) + + filter := &super_dto.SuperContentReportListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + } + res, err := Super.ListContentReports(ctx, filter) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + items := res.Items.([]super_dto.SuperContentReportItem) + So(items[0].ContentTitle, ShouldEqual, "Report Content") + So(items[0].ReporterName, ShouldEqual, reporter.Username) + So(items[0].Status, ShouldEqual, "pending") + }) + + Convey("should process report and block content", func() { + report := &models.ContentReport{ + TenantID: tenant.ID, + ContentID: content.ID, + ReporterID: reporter.ID, + Reason: "abuse", + Detail: "严重违规", + Status: "pending", + } + models.ContentReportQuery.WithContext(ctx).Create(report) + + err := Super.ProcessContentReport(ctx, admin.ID, report.ID, &super_dto.SuperContentReportProcessForm{ + Action: "approve", + ContentAction: "block", + Reason: "违规属实", + }) + So(err, ShouldBeNil) + + reloaded, err := models.ContentReportQuery.WithContext(ctx).Where(models.ContentReportQuery.ID.Eq(report.ID)).First() + So(err, ShouldBeNil) + So(reloaded.Status, ShouldEqual, "approved") + So(reloaded.HandledBy, ShouldEqual, admin.ID) + So(reloaded.HandledAction, ShouldEqual, "block") + So(reloaded.HandledReason, ShouldEqual, "违规属实") + So(reloaded.HandledAt.IsZero(), ShouldBeFalse) + + contentReload, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(content.ID)).First() + So(err, ShouldBeNil) + So(contentReload.Status, ShouldEqual, consts.ContentStatusBlocked) + }) + + Convey("should reject report without content action", func() { + report := &models.ContentReport{ + TenantID: tenant.ID, + ContentID: content.ID, + ReporterID: reporter.ID, + Reason: "other", + Detail: "误报", + Status: "pending", + } + models.ContentReportQuery.WithContext(ctx).Create(report) + + err := Super.ProcessContentReport(ctx, admin.ID, report.ID, &super_dto.SuperContentReportProcessForm{ + Action: "reject", + Reason: "证据不足", + }) + So(err, ShouldBeNil) + + reloaded, err := models.ContentReportQuery.WithContext(ctx).Where(models.ContentReportQuery.ID.Eq(report.ID)).First() + So(err, ShouldBeNil) + So(reloaded.Status, ShouldEqual, "rejected") + So(reloaded.HandledBy, ShouldEqual, admin.ID) + So(reloaded.HandledAction, ShouldEqual, "ignore") + So(reloaded.HandledReason, ShouldEqual, "证据不足") + + contentReload, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(content.ID)).First() + So(err, ShouldBeNil) + So(contentReload.Status, ShouldEqual, consts.ContentStatusPublished) + }) + }) +} + func (s *SuperTestSuite) Test_FinanceAnomalies() { Convey("Finance Anomalies", s.T(), func() { ctx := s.T().Context() diff --git a/backend/database/migrations/20260116024811_create_content_reports.sql b/backend/database/migrations/20260116024811_create_content_reports.sql new file mode 100644 index 0000000..ff37e3f --- /dev/null +++ b/backend/database/migrations/20260116024811_create_content_reports.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS content_reports ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + content_id BIGINT NOT NULL, + reporter_id BIGINT NOT NULL, + reason VARCHAR(64) NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'pending', + handled_by BIGINT NOT NULL DEFAULT 0, + handled_action VARCHAR(32) NOT NULL DEFAULT '', + handled_reason VARCHAR(255) NOT NULL DEFAULT '', + handled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE content_reports IS '内容举报:记录用户对内容的举报与超管处置结果,用于内容治理。'; +COMMENT ON COLUMN content_reports.id IS '主键ID。'; +COMMENT ON COLUMN content_reports.tenant_id IS '租户ID;用途:跨租户筛选与归属;约束:必须存在。'; +COMMENT ON COLUMN content_reports.content_id IS '内容ID;用途:定位被举报内容;约束:必须存在。'; +COMMENT ON COLUMN content_reports.reporter_id IS '举报用户ID;用途:追溯举报来源;约束:必须存在。'; +COMMENT ON COLUMN content_reports.reason IS '举报原因;用途:快速分类;约束:建议前端枚举。'; +COMMENT ON COLUMN content_reports.detail IS '举报描述;用途:补充说明与证据描述;可为空字符串。'; +COMMENT ON COLUMN content_reports.status IS '处置状态;用途:pending/approved/rejected;约束:默认 pending。'; +COMMENT ON COLUMN content_reports.handled_by IS '处理人ID;用途:审计追踪;默认 0 表示未处理。'; +COMMENT ON COLUMN content_reports.handled_action IS '处理动作;用途:记录处置动作如 block/unpublish/ignore;可为空字符串。'; +COMMENT ON COLUMN content_reports.handled_reason IS '处理说明;用途:超管处理备注;可为空字符串。'; +COMMENT ON COLUMN content_reports.handled_at IS '处理时间;用途:追踪处理时效;未处理为空。'; +COMMENT ON COLUMN content_reports.created_at IS '创建时间;用途:统计与排序。'; +COMMENT ON COLUMN content_reports.updated_at IS '更新时间;用途:状态变化追踪。'; + +CREATE INDEX IF NOT EXISTS content_reports_tenant_id_idx ON content_reports(tenant_id); +CREATE INDEX IF NOT EXISTS content_reports_content_id_idx ON content_reports(content_id); +CREATE INDEX IF NOT EXISTS content_reports_reporter_id_idx ON content_reports(reporter_id); +CREATE INDEX IF NOT EXISTS content_reports_status_idx ON content_reports(status); +CREATE INDEX IF NOT EXISTS content_reports_created_at_idx ON content_reports(created_at); +CREATE INDEX IF NOT EXISTS content_reports_handled_at_idx ON content_reports(handled_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS content_reports; +-- +goose StatementEnd diff --git a/backend/database/models/content_reports.gen.go b/backend/database/models/content_reports.gen.go new file mode 100644 index 0000000..f2a012c --- /dev/null +++ b/backend/database/models/content_reports.gen.go @@ -0,0 +1,67 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "go.ipao.vip/gen" +) + +const TableNameContentReport = "content_reports" + +// ContentReport mapped from table +type ContentReport struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID。" json:"id"` // 主键ID。 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID;用途:跨租户筛选与归属;约束:必须存在。" json:"tenant_id"` // 租户ID;用途:跨租户筛选与归属;约束:必须存在。 + ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID;用途:定位被举报内容;约束:必须存在。" json:"content_id"` // 内容ID;用途:定位被举报内容;约束:必须存在。 + ReporterID int64 `gorm:"column:reporter_id;type:bigint;not null;comment:举报用户ID;用途:追溯举报来源;约束:必须存在。" json:"reporter_id"` // 举报用户ID;用途:追溯举报来源;约束:必须存在。 + Reason string `gorm:"column:reason;type:character varying(64);not null;comment:举报原因;用途:快速分类;约束:建议前端枚举。" json:"reason"` // 举报原因;用途:快速分类;约束:建议前端枚举。 + Detail string `gorm:"column:detail;type:text;not null;comment:举报描述;用途:补充说明与证据描述;可为空字符串。" json:"detail"` // 举报描述;用途:补充说明与证据描述;可为空字符串。 + Status string `gorm:"column:status;type:character varying(32);not null;default:pending;comment:处置状态;用途:pending/approved/rejected;约束:默认 pending。" json:"status"` // 处置状态;用途:pending/approved/rejected;约束:默认 pending。 + HandledBy int64 `gorm:"column:handled_by;type:bigint;not null;comment:处理人ID;用途:审计追踪;默认 0 表示未处理。" json:"handled_by"` // 处理人ID;用途:审计追踪;默认 0 表示未处理。 + HandledAction string `gorm:"column:handled_action;type:character varying(32);not null;comment:处理动作;用途:记录处置动作如 block/unpublish/ignore;可为空字符串。" json:"handled_action"` // 处理动作;用途:记录处置动作如 block/unpublish/ignore;可为空字符串。 + HandledReason string `gorm:"column:handled_reason;type:character varying(255);not null;comment:处理说明;用途:超管处理备注;可为空字符串。" json:"handled_reason"` // 处理说明;用途:超管处理备注;可为空字符串。 + HandledAt time.Time `gorm:"column:handled_at;type:timestamp with time zone;comment:处理时间;用途:追踪处理时效;未处理为空。" json:"handled_at"` // 处理时间;用途:追踪处理时效;未处理为空。 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间;用途:统计与排序。" json:"created_at"` // 创建时间;用途:统计与排序。 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间;用途:状态变化追踪。" json:"updated_at"` // 更新时间;用途:状态变化追踪。 +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *ContentReport) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.ContentReport.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *ContentReport) Save(ctx context.Context) error { + return Q.ContentReport.WithContext(ctx).Save(m) +} + +// Create inserts the model using the default DB. +func (m *ContentReport) Create(ctx context.Context) error { + return Q.ContentReport.WithContext(ctx).Create(m) +} + +// Delete removes the row represented by the model using the default DB. +func (m *ContentReport) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.ContentReport.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *ContentReport) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.ContentReport.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *ContentReport) Reload(ctx context.Context) error { + fresh, err := Q.ContentReport.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/content_reports.query.gen.go b/backend/database/models/content_reports.query.gen.go new file mode 100644 index 0000000..f1b4f3a --- /dev/null +++ b/backend/database/models/content_reports.query.gen.go @@ -0,0 +1,509 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newContentReport(db *gorm.DB, opts ...gen.DOOption) contentReportQuery { + _contentReportQuery := contentReportQuery{} + + _contentReportQuery.contentReportQueryDo.UseDB(db, opts...) + _contentReportQuery.contentReportQueryDo.UseModel(&ContentReport{}) + + tableName := _contentReportQuery.contentReportQueryDo.TableName() + _contentReportQuery.ALL = field.NewAsterisk(tableName) + _contentReportQuery.ID = field.NewInt64(tableName, "id") + _contentReportQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _contentReportQuery.ContentID = field.NewInt64(tableName, "content_id") + _contentReportQuery.ReporterID = field.NewInt64(tableName, "reporter_id") + _contentReportQuery.Reason = field.NewString(tableName, "reason") + _contentReportQuery.Detail = field.NewString(tableName, "detail") + _contentReportQuery.Status = field.NewString(tableName, "status") + _contentReportQuery.HandledBy = field.NewInt64(tableName, "handled_by") + _contentReportQuery.HandledAction = field.NewString(tableName, "handled_action") + _contentReportQuery.HandledReason = field.NewString(tableName, "handled_reason") + _contentReportQuery.HandledAt = field.NewTime(tableName, "handled_at") + _contentReportQuery.CreatedAt = field.NewTime(tableName, "created_at") + _contentReportQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + + _contentReportQuery.fillFieldMap() + + return _contentReportQuery +} + +type contentReportQuery struct { + contentReportQueryDo contentReportQueryDo + + ALL field.Asterisk + ID field.Int64 // 主键ID。 + TenantID field.Int64 // 租户ID;用途:跨租户筛选与归属;约束:必须存在。 + ContentID field.Int64 // 内容ID;用途:定位被举报内容;约束:必须存在。 + ReporterID field.Int64 // 举报用户ID;用途:追溯举报来源;约束:必须存在。 + Reason field.String // 举报原因;用途:快速分类;约束:建议前端枚举。 + Detail field.String // 举报描述;用途:补充说明与证据描述;可为空字符串。 + Status field.String // 处置状态;用途:pending/approved/rejected;约束:默认 pending。 + HandledBy field.Int64 // 处理人ID;用途:审计追踪;默认 0 表示未处理。 + HandledAction field.String // 处理动作;用途:记录处置动作如 block/unpublish/ignore;可为空字符串。 + HandledReason field.String // 处理说明;用途:超管处理备注;可为空字符串。 + HandledAt field.Time // 处理时间;用途:追踪处理时效;未处理为空。 + CreatedAt field.Time // 创建时间;用途:统计与排序。 + UpdatedAt field.Time // 更新时间;用途:状态变化追踪。 + + fieldMap map[string]field.Expr +} + +func (c contentReportQuery) Table(newTableName string) *contentReportQuery { + c.contentReportQueryDo.UseTable(newTableName) + return c.updateTableName(newTableName) +} + +func (c contentReportQuery) As(alias string) *contentReportQuery { + c.contentReportQueryDo.DO = *(c.contentReportQueryDo.As(alias).(*gen.DO)) + return c.updateTableName(alias) +} + +func (c *contentReportQuery) updateTableName(table string) *contentReportQuery { + c.ALL = field.NewAsterisk(table) + c.ID = field.NewInt64(table, "id") + c.TenantID = field.NewInt64(table, "tenant_id") + c.ContentID = field.NewInt64(table, "content_id") + c.ReporterID = field.NewInt64(table, "reporter_id") + c.Reason = field.NewString(table, "reason") + c.Detail = field.NewString(table, "detail") + c.Status = field.NewString(table, "status") + c.HandledBy = field.NewInt64(table, "handled_by") + c.HandledAction = field.NewString(table, "handled_action") + c.HandledReason = field.NewString(table, "handled_reason") + c.HandledAt = field.NewTime(table, "handled_at") + c.CreatedAt = field.NewTime(table, "created_at") + c.UpdatedAt = field.NewTime(table, "updated_at") + + c.fillFieldMap() + + return c +} + +func (c *contentReportQuery) QueryContext(ctx context.Context) (*contentReportQuery, *contentReportQueryDo) { + return c, c.contentReportQueryDo.WithContext(ctx) +} + +func (c *contentReportQuery) WithContext(ctx context.Context) *contentReportQueryDo { + return c.contentReportQueryDo.WithContext(ctx) +} + +func (c contentReportQuery) TableName() string { return c.contentReportQueryDo.TableName() } + +func (c contentReportQuery) Alias() string { return c.contentReportQueryDo.Alias() } + +func (c contentReportQuery) Columns(cols ...field.Expr) gen.Columns { + return c.contentReportQueryDo.Columns(cols...) +} + +func (c *contentReportQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := c.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (c *contentReportQuery) fillFieldMap() { + c.fieldMap = make(map[string]field.Expr, 13) + c.fieldMap["id"] = c.ID + c.fieldMap["tenant_id"] = c.TenantID + c.fieldMap["content_id"] = c.ContentID + c.fieldMap["reporter_id"] = c.ReporterID + c.fieldMap["reason"] = c.Reason + c.fieldMap["detail"] = c.Detail + c.fieldMap["status"] = c.Status + c.fieldMap["handled_by"] = c.HandledBy + c.fieldMap["handled_action"] = c.HandledAction + c.fieldMap["handled_reason"] = c.HandledReason + c.fieldMap["handled_at"] = c.HandledAt + c.fieldMap["created_at"] = c.CreatedAt + c.fieldMap["updated_at"] = c.UpdatedAt +} + +func (c contentReportQuery) clone(db *gorm.DB) contentReportQuery { + c.contentReportQueryDo.ReplaceConnPool(db.Statement.ConnPool) + return c +} + +func (c contentReportQuery) replaceDB(db *gorm.DB) contentReportQuery { + c.contentReportQueryDo.ReplaceDB(db) + return c +} + +type contentReportQueryDo struct{ gen.DO } + +func (c contentReportQueryDo) Debug() *contentReportQueryDo { + return c.withDO(c.DO.Debug()) +} + +func (c contentReportQueryDo) WithContext(ctx context.Context) *contentReportQueryDo { + return c.withDO(c.DO.WithContext(ctx)) +} + +func (c contentReportQueryDo) ReadDB() *contentReportQueryDo { + return c.Clauses(dbresolver.Read) +} + +func (c contentReportQueryDo) WriteDB() *contentReportQueryDo { + return c.Clauses(dbresolver.Write) +} + +func (c contentReportQueryDo) Session(config *gorm.Session) *contentReportQueryDo { + return c.withDO(c.DO.Session(config)) +} + +func (c contentReportQueryDo) Clauses(conds ...clause.Expression) *contentReportQueryDo { + return c.withDO(c.DO.Clauses(conds...)) +} + +func (c contentReportQueryDo) Returning(value interface{}, columns ...string) *contentReportQueryDo { + return c.withDO(c.DO.Returning(value, columns...)) +} + +func (c contentReportQueryDo) Not(conds ...gen.Condition) *contentReportQueryDo { + return c.withDO(c.DO.Not(conds...)) +} + +func (c contentReportQueryDo) Or(conds ...gen.Condition) *contentReportQueryDo { + return c.withDO(c.DO.Or(conds...)) +} + +func (c contentReportQueryDo) Select(conds ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.Select(conds...)) +} + +func (c contentReportQueryDo) Where(conds ...gen.Condition) *contentReportQueryDo { + return c.withDO(c.DO.Where(conds...)) +} + +func (c contentReportQueryDo) Order(conds ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.Order(conds...)) +} + +func (c contentReportQueryDo) Distinct(cols ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.Distinct(cols...)) +} + +func (c contentReportQueryDo) Omit(cols ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.Omit(cols...)) +} + +func (c contentReportQueryDo) Join(table schema.Tabler, on ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.Join(table, on...)) +} + +func (c contentReportQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.LeftJoin(table, on...)) +} + +func (c contentReportQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.RightJoin(table, on...)) +} + +func (c contentReportQueryDo) Group(cols ...field.Expr) *contentReportQueryDo { + return c.withDO(c.DO.Group(cols...)) +} + +func (c contentReportQueryDo) Having(conds ...gen.Condition) *contentReportQueryDo { + return c.withDO(c.DO.Having(conds...)) +} + +func (c contentReportQueryDo) Limit(limit int) *contentReportQueryDo { + return c.withDO(c.DO.Limit(limit)) +} + +func (c contentReportQueryDo) Offset(offset int) *contentReportQueryDo { + return c.withDO(c.DO.Offset(offset)) +} + +func (c contentReportQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *contentReportQueryDo { + return c.withDO(c.DO.Scopes(funcs...)) +} + +func (c contentReportQueryDo) Unscoped() *contentReportQueryDo { + return c.withDO(c.DO.Unscoped()) +} + +func (c contentReportQueryDo) Create(values ...*ContentReport) error { + if len(values) == 0 { + return nil + } + return c.DO.Create(values) +} + +func (c contentReportQueryDo) CreateInBatches(values []*ContentReport, batchSize int) error { + return c.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (c contentReportQueryDo) Save(values ...*ContentReport) error { + if len(values) == 0 { + return nil + } + return c.DO.Save(values) +} + +func (c contentReportQueryDo) First() (*ContentReport, error) { + if result, err := c.DO.First(); err != nil { + return nil, err + } else { + return result.(*ContentReport), nil + } +} + +func (c contentReportQueryDo) Take() (*ContentReport, error) { + if result, err := c.DO.Take(); err != nil { + return nil, err + } else { + return result.(*ContentReport), nil + } +} + +func (c contentReportQueryDo) Last() (*ContentReport, error) { + if result, err := c.DO.Last(); err != nil { + return nil, err + } else { + return result.(*ContentReport), nil + } +} + +func (c contentReportQueryDo) Find() ([]*ContentReport, error) { + result, err := c.DO.Find() + return result.([]*ContentReport), err +} + +func (c contentReportQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*ContentReport, err error) { + buf := make([]*ContentReport, 0, batchSize) + err = c.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (c contentReportQueryDo) FindInBatches(result *[]*ContentReport, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return c.DO.FindInBatches(result, batchSize, fc) +} + +func (c contentReportQueryDo) Attrs(attrs ...field.AssignExpr) *contentReportQueryDo { + return c.withDO(c.DO.Attrs(attrs...)) +} + +func (c contentReportQueryDo) Assign(attrs ...field.AssignExpr) *contentReportQueryDo { + return c.withDO(c.DO.Assign(attrs...)) +} + +func (c contentReportQueryDo) Joins(fields ...field.RelationField) *contentReportQueryDo { + for _, _f := range fields { + c = *c.withDO(c.DO.Joins(_f)) + } + return &c +} + +func (c contentReportQueryDo) Preload(fields ...field.RelationField) *contentReportQueryDo { + for _, _f := range fields { + c = *c.withDO(c.DO.Preload(_f)) + } + return &c +} + +func (c contentReportQueryDo) FirstOrInit() (*ContentReport, error) { + if result, err := c.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*ContentReport), nil + } +} + +func (c contentReportQueryDo) FirstOrCreate() (*ContentReport, error) { + if result, err := c.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*ContentReport), nil + } +} + +func (c contentReportQueryDo) FindByPage(offset int, limit int) (result []*ContentReport, count int64, err error) { + result, err = c.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = c.Offset(-1).Limit(-1).Count() + return +} + +func (c contentReportQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = c.Count() + if err != nil { + return + } + + err = c.Offset(offset).Limit(limit).Scan(result) + return +} + +func (c contentReportQueryDo) Scan(result interface{}) (err error) { + return c.DO.Scan(result) +} + +func (c contentReportQueryDo) Delete(models ...*ContentReport) (result gen.ResultInfo, err error) { + return c.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (c contentReportQueryDo) ForceDelete() (gen.ResultInfo, error) { + return c.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (c contentReportQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return c.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (c contentReportQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return c.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (c contentReportQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := c.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (c contentReportQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := c.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (c contentReportQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := c.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (c contentReportQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := c.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (c contentReportQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := c.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (c contentReportQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := c.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (c contentReportQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(c.TableName(), "id") + if err := c.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (c contentReportQueryDo) GetByID(id int64) (*ContentReport, error) { + pk := field.NewInt64(c.TableName(), "id") + return c.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (c contentReportQueryDo) GetByIDs(ids ...int64) ([]*ContentReport, error) { + if len(ids) == 0 { + return []*ContentReport{}, nil + } + pk := field.NewInt64(c.TableName(), "id") + return c.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (c contentReportQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(c.TableName(), "id") + return c.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (c contentReportQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(c.TableName(), "id") + return c.Where(pk.In(ids...)).Delete() +} + +func (c *contentReportQueryDo) withDO(do gen.Dao) *contentReportQueryDo { + c.DO = *do.(*gen.DO) + return c +} diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index 096082b..fc251f6 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -40,9 +40,9 @@ type Content struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured + Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"` ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"` Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` - Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index b670e6b..d462865 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -46,6 +46,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.Key = field.NewString(tableName, "key") _contentQuery.IsPinned = field.NewBool(tableName, "is_pinned") + _contentQuery.Author = contentQueryBelongsToAuthor{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Author", "User"), + } + _contentQuery.ContentAssets = contentQueryHasManyContentAssets{ db: db.Session(&gorm.Session{}), @@ -58,12 +64,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { RelationField: field.NewRelation("Comments", "Comment"), } - _contentQuery.Author = contentQueryBelongsToAuthor{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Author", "User"), - } - _contentQuery.fillFieldMap() return _contentQuery @@ -94,12 +94,12 @@ type contentQuery struct { DeletedAt field.Field Key field.String // Musical key/tone IsPinned field.Bool // Whether content is pinned/featured - ContentAssets contentQueryHasManyContentAssets + Author contentQueryBelongsToAuthor + + ContentAssets contentQueryHasManyContentAssets Comments contentQueryHasManyComments - Author contentQueryBelongsToAuthor - fieldMap map[string]field.Expr } @@ -195,23 +195,104 @@ func (c *contentQuery) fillFieldMap() { func (c contentQuery) clone(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool) + c.Author.db = db.Session(&gorm.Session{Initialized: true}) + c.Author.db.Statement.ConnPool = db.Statement.ConnPool c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool c.Comments.db = db.Session(&gorm.Session{Initialized: true}) c.Comments.db.Statement.ConnPool = db.Statement.ConnPool - c.Author.db = db.Session(&gorm.Session{Initialized: true}) - c.Author.db.Statement.ConnPool = db.Statement.ConnPool return c } func (c contentQuery) replaceDB(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceDB(db) + c.Author.db = db.Session(&gorm.Session{}) c.ContentAssets.db = db.Session(&gorm.Session{}) c.Comments.db = db.Session(&gorm.Session{}) - c.Author.db = db.Session(&gorm.Session{}) return c } +type contentQueryBelongsToAuthor struct { + db *gorm.DB + + field.RelationField +} + +func (a contentQueryBelongsToAuthor) Where(conds ...field.Expr) *contentQueryBelongsToAuthor { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a contentQueryBelongsToAuthor) WithContext(ctx context.Context) *contentQueryBelongsToAuthor { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a contentQueryBelongsToAuthor) Session(session *gorm.Session) *contentQueryBelongsToAuthor { + a.db = a.db.Session(session) + return &a +} + +func (a contentQueryBelongsToAuthor) Model(m *Content) *contentQueryBelongsToAuthorTx { + return &contentQueryBelongsToAuthorTx{a.db.Model(m).Association(a.Name())} +} + +func (a contentQueryBelongsToAuthor) Unscoped() *contentQueryBelongsToAuthor { + a.db = a.db.Unscoped() + return &a +} + +type contentQueryBelongsToAuthorTx struct{ tx *gorm.Association } + +func (a contentQueryBelongsToAuthorTx) Find() (result *User, err error) { + return result, a.tx.Find(&result) +} + +func (a contentQueryBelongsToAuthorTx) Append(values ...*User) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a contentQueryBelongsToAuthorTx) Replace(values ...*User) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a contentQueryBelongsToAuthorTx) Delete(values ...*User) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a contentQueryBelongsToAuthorTx) Clear() error { + return a.tx.Clear() +} + +func (a contentQueryBelongsToAuthorTx) Count() int64 { + return a.tx.Count() +} + +func (a contentQueryBelongsToAuthorTx) Unscoped() *contentQueryBelongsToAuthorTx { + a.tx = a.tx.Unscoped() + return &a +} + type contentQueryHasManyContentAssets struct { db *gorm.DB @@ -374,87 +455,6 @@ func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx return &a } -type contentQueryBelongsToAuthor struct { - db *gorm.DB - - field.RelationField -} - -func (a contentQueryBelongsToAuthor) Where(conds ...field.Expr) *contentQueryBelongsToAuthor { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a contentQueryBelongsToAuthor) WithContext(ctx context.Context) *contentQueryBelongsToAuthor { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a contentQueryBelongsToAuthor) Session(session *gorm.Session) *contentQueryBelongsToAuthor { - a.db = a.db.Session(session) - return &a -} - -func (a contentQueryBelongsToAuthor) Model(m *Content) *contentQueryBelongsToAuthorTx { - return &contentQueryBelongsToAuthorTx{a.db.Model(m).Association(a.Name())} -} - -func (a contentQueryBelongsToAuthor) Unscoped() *contentQueryBelongsToAuthor { - a.db = a.db.Unscoped() - return &a -} - -type contentQueryBelongsToAuthorTx struct{ tx *gorm.Association } - -func (a contentQueryBelongsToAuthorTx) Find() (result *User, err error) { - return result, a.tx.Find(&result) -} - -func (a contentQueryBelongsToAuthorTx) Append(values ...*User) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a contentQueryBelongsToAuthorTx) Replace(values ...*User) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a contentQueryBelongsToAuthorTx) Delete(values ...*User) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a contentQueryBelongsToAuthorTx) Clear() error { - return a.tx.Clear() -} - -func (a contentQueryBelongsToAuthorTx) Count() int64 { - return a.tx.Count() -} - -func (a contentQueryBelongsToAuthorTx) Unscoped() *contentQueryBelongsToAuthorTx { - a.tx = a.tx.Unscoped() - return &a -} - type contentQueryDo struct{ gen.DO } func (c contentQueryDo) Debug() *contentQueryDo { diff --git a/backend/database/models/query.gen.go b/backend/database/models/query.gen.go index 3e45346..8ed742c 100644 --- a/backend/database/models/query.gen.go +++ b/backend/database/models/query.gen.go @@ -23,6 +23,7 @@ var ( ContentAccessQuery *contentAccessQuery ContentAssetQuery *contentAssetQuery ContentPriceQuery *contentPriceQuery + ContentReportQuery *contentReportQuery CouponQuery *couponQuery MediaAssetQuery *mediaAssetQuery NotificationQuery *notificationQuery @@ -50,6 +51,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { ContentAccessQuery = &Q.ContentAccess ContentAssetQuery = &Q.ContentAsset ContentPriceQuery = &Q.ContentPrice + ContentReportQuery = &Q.ContentReport CouponQuery = &Q.Coupon MediaAssetQuery = &Q.MediaAsset NotificationQuery = &Q.Notification @@ -78,6 +80,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { ContentAccess: newContentAccess(db, opts...), ContentAsset: newContentAsset(db, opts...), ContentPrice: newContentPrice(db, opts...), + ContentReport: newContentReport(db, opts...), Coupon: newCoupon(db, opts...), MediaAsset: newMediaAsset(db, opts...), Notification: newNotification(db, opts...), @@ -107,6 +110,7 @@ type Query struct { ContentAccess contentAccessQuery ContentAsset contentAssetQuery ContentPrice contentPriceQuery + ContentReport contentReportQuery Coupon couponQuery MediaAsset mediaAssetQuery Notification notificationQuery @@ -137,6 +141,7 @@ func (q *Query) clone(db *gorm.DB) *Query { ContentAccess: q.ContentAccess.clone(db), ContentAsset: q.ContentAsset.clone(db), ContentPrice: q.ContentPrice.clone(db), + ContentReport: q.ContentReport.clone(db), Coupon: q.Coupon.clone(db), MediaAsset: q.MediaAsset.clone(db), Notification: q.Notification.clone(db), @@ -174,6 +179,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { ContentAccess: q.ContentAccess.replaceDB(db), ContentAsset: q.ContentAsset.replaceDB(db), ContentPrice: q.ContentPrice.replaceDB(db), + ContentReport: q.ContentReport.replaceDB(db), Coupon: q.Coupon.replaceDB(db), MediaAsset: q.MediaAsset.replaceDB(db), Notification: q.Notification.replaceDB(db), @@ -201,6 +207,7 @@ type queryCtx struct { ContentAccess *contentAccessQueryDo ContentAsset *contentAssetQueryDo ContentPrice *contentPriceQueryDo + ContentReport *contentReportQueryDo Coupon *couponQueryDo MediaAsset *mediaAssetQueryDo Notification *notificationQueryDo @@ -228,6 +235,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { ContentAccess: q.ContentAccess.WithContext(ctx), ContentAsset: q.ContentAsset.WithContext(ctx), ContentPrice: q.ContentPrice.WithContext(ctx), + ContentReport: q.ContentReport.WithContext(ctx), Coupon: q.Coupon.WithContext(ctx), MediaAsset: q.MediaAsset.WithContext(ctx), Notification: q.Notification.WithContext(ctx), diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d1c731c..1f2fc4b 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -340,6 +340,100 @@ const docTemplate = `{ } } }, + "/super/v1/content-reports": { + "get": { + "description": "List content report records across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "List content reports", + "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.SuperContentReportItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/content-reports/{id}/process": { + "post": { + "description": "Process a content report record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Process content report", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Report ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Process form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentReportProcessForm" + } + } + ], + "responses": { + "200": { + "description": "Processed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents": { "get": { "description": "List contents", @@ -7835,6 +7929,108 @@ const docTemplate = `{ } } }, + "dto.SuperContentReportItem": { + "type": "object", + "properties": { + "content_id": { + "description": "ContentID 内容ID。", + "type": "integer" + }, + "content_owner_id": { + "description": "ContentOwnerID 内容作者用户ID。", + "type": "integer" + }, + "content_owner_name": { + "description": "ContentOwnerName 内容作者用户名/昵称。", + "type": "string" + }, + "content_status": { + "description": "ContentStatus 内容状态。", + "type": "string" + }, + "content_title": { + "description": "ContentTitle 内容标题。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 举报时间(RFC3339)。", + "type": "string" + }, + "detail": { + "description": "Detail 举报描述。", + "type": "string" + }, + "handled_action": { + "description": "HandledAction 处理动作(block/unpublish/ignore)。", + "type": "string" + }, + "handled_at": { + "description": "HandledAt 处理时间(RFC3339)。", + "type": "string" + }, + "handled_by": { + "description": "HandledBy 处理人用户ID。", + "type": "integer" + }, + "handled_by_name": { + "description": "HandledByName 处理人用户名/昵称。", + "type": "string" + }, + "handled_reason": { + "description": "HandledReason 处理说明。", + "type": "string" + }, + "id": { + "description": "ID 举报ID。", + "type": "integer" + }, + "reason": { + "description": "Reason 举报原因。", + "type": "string" + }, + "reporter_id": { + "description": "ReporterID 举报人用户ID。", + "type": "integer" + }, + "reporter_name": { + "description": "ReporterName 举报人用户名/昵称。", + "type": "string" + }, + "status": { + "description": "Status 处理状态。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + } + } + }, + "dto.SuperContentReportProcessForm": { + "type": "object", + "properties": { + "action": { + "description": "Action 处理动作(approve/reject)。", + "type": "string" + }, + "content_action": { + "description": "ContentAction 内容处置动作(block/unpublish/ignore),仅在 approve 时生效。", + "type": "string" + }, + "reason": { + "description": "Reason 处理说明(可选,用于审计记录)。", + "type": "string" + } + } + }, "dto.SuperContentReviewForm": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 6034b7f..fdb78db 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -334,6 +334,100 @@ } } }, + "/super/v1/content-reports": { + "get": { + "description": "List content report records across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "List content reports", + "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.SuperContentReportItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/content-reports/{id}/process": { + "post": { + "description": "Process a content report record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Process content report", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Report ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Process form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentReportProcessForm" + } + } + ], + "responses": { + "200": { + "description": "Processed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents": { "get": { "description": "List contents", @@ -7829,6 +7923,108 @@ } } }, + "dto.SuperContentReportItem": { + "type": "object", + "properties": { + "content_id": { + "description": "ContentID 内容ID。", + "type": "integer" + }, + "content_owner_id": { + "description": "ContentOwnerID 内容作者用户ID。", + "type": "integer" + }, + "content_owner_name": { + "description": "ContentOwnerName 内容作者用户名/昵称。", + "type": "string" + }, + "content_status": { + "description": "ContentStatus 内容状态。", + "type": "string" + }, + "content_title": { + "description": "ContentTitle 内容标题。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 举报时间(RFC3339)。", + "type": "string" + }, + "detail": { + "description": "Detail 举报描述。", + "type": "string" + }, + "handled_action": { + "description": "HandledAction 处理动作(block/unpublish/ignore)。", + "type": "string" + }, + "handled_at": { + "description": "HandledAt 处理时间(RFC3339)。", + "type": "string" + }, + "handled_by": { + "description": "HandledBy 处理人用户ID。", + "type": "integer" + }, + "handled_by_name": { + "description": "HandledByName 处理人用户名/昵称。", + "type": "string" + }, + "handled_reason": { + "description": "HandledReason 处理说明。", + "type": "string" + }, + "id": { + "description": "ID 举报ID。", + "type": "integer" + }, + "reason": { + "description": "Reason 举报原因。", + "type": "string" + }, + "reporter_id": { + "description": "ReporterID 举报人用户ID。", + "type": "integer" + }, + "reporter_name": { + "description": "ReporterName 举报人用户名/昵称。", + "type": "string" + }, + "status": { + "description": "Status 处理状态。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + } + } + }, + "dto.SuperContentReportProcessForm": { + "type": "object", + "properties": { + "action": { + "description": "Action 处理动作(approve/reject)。", + "type": "string" + }, + "content_action": { + "description": "ContentAction 内容处置动作(block/unpublish/ignore),仅在 approve 时生效。", + "type": "string" + }, + "reason": { + "description": "Reason 处理说明(可选,用于审计记录)。", + "type": "string" + } + } + }, "dto.SuperContentReviewForm": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 789192a..a92b94a 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1376,6 +1376,81 @@ definitions: - action - content_ids type: object + dto.SuperContentReportItem: + properties: + content_id: + description: ContentID 内容ID。 + type: integer + content_owner_id: + description: ContentOwnerID 内容作者用户ID。 + type: integer + content_owner_name: + description: ContentOwnerName 内容作者用户名/昵称。 + type: string + content_status: + description: ContentStatus 内容状态。 + type: string + content_title: + description: ContentTitle 内容标题。 + type: string + created_at: + description: CreatedAt 举报时间(RFC3339)。 + type: string + detail: + description: Detail 举报描述。 + type: string + handled_action: + description: HandledAction 处理动作(block/unpublish/ignore)。 + type: string + handled_at: + description: HandledAt 处理时间(RFC3339)。 + type: string + handled_by: + description: HandledBy 处理人用户ID。 + type: integer + handled_by_name: + description: HandledByName 处理人用户名/昵称。 + type: string + handled_reason: + description: HandledReason 处理说明。 + type: string + id: + description: ID 举报ID。 + type: integer + reason: + description: Reason 举报原因。 + type: string + reporter_id: + description: ReporterID 举报人用户ID。 + type: integer + reporter_name: + description: ReporterName 举报人用户名/昵称。 + type: string + status: + description: Status 处理状态。 + type: string + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + type: object + dto.SuperContentReportProcessForm: + properties: + action: + description: Action 处理动作(approve/reject)。 + type: string + content_action: + description: ContentAction 内容处置动作(block/unpublish/ignore),仅在 approve 时生效。 + type: string + reason: + description: Reason 处理说明(可选,用于审计记录)。 + type: string + type: object dto.SuperContentReviewForm: properties: action: @@ -3345,6 +3420,65 @@ paths: summary: Delete comment tags: - Content + /super/v1/content-reports: + get: + consumes: + - application/json + description: List content report records across tenants + 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.SuperContentReportItem' + type: array + type: object + summary: List content reports + tags: + - Content + /super/v1/content-reports/{id}/process: + post: + consumes: + - application/json + description: Process a content report record + parameters: + - description: Report ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Process form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperContentReportProcessForm' + produces: + - application/json + responses: + "200": + description: Processed + schema: + type: string + summary: Process content report + tags: + - Content /super/v1/contents: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index 20f2a6b..86848ba 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -5,7 +5,7 @@ ## 1) 总体结论 - **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、钱包流水与异常排查、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)、审计日志与系统配置、评论治理。 -- **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺举报处理与批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。 +- **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。 - **未落地**:暂无。 ## 2) 按页面完成度(对照 2.x) @@ -42,8 +42,8 @@ ### 2.7 内容治理 `/superadmin/contents` - 状态:**部分完成** -- 已有:跨租户列表、下架、审核流(含批量审核)、评论治理。 -- 缺口:内容举报处理、批量处置扩展(如批量下架/封禁)。 +- 已有:跨租户列表、下架、审核流(含批量审核)、评论治理、内容举报处理。 +- 缺口:批量处置扩展(如批量下架/封禁)。 ### 2.8 订单与退款 `/superadmin/orders` - 状态:**已完成** @@ -88,10 +88,10 @@ ## 3) `/super/v1` 接口覆盖度概览 - **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名/互动/内容消费)、Contents(含评论治理)、Orders、Withdrawals、Finance(流水/异常)、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 -- **缺失/待补**:内容举报处理。 +- **缺失/待补**:内容治理批量处置扩展(举报/下架/封禁等批量操作)。 ## 4) 建议的下一步(按优先级) -1. **内容举报治理**:补齐举报记录/处置流与批量处置能力。 -2. **租户详情聚合**:补齐租户财务与报表聚合入口。 -3. **订单运营补强**:问题订单标记、支付对账辅助能力。 +1. **租户详情聚合**:补齐租户财务与报表聚合入口。 +2. **订单运营补强**:问题订单标记、支付对账辅助能力。 +3. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理。 diff --git a/frontend/superadmin/src/service/ContentService.js b/frontend/superadmin/src/service/ContentService.js index 76d21c8..435ae51 100644 --- a/frontend/superadmin/src/service/ContentService.js +++ b/frontend/superadmin/src/service/ContentService.js @@ -190,5 +190,80 @@ export const ContentService = { method: 'POST', body: { reason } }); + }, + async listContentReports({ + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + content_id, + content_title, + reporter_id, + reporter_name, + handled_by, + handled_by_name, + reason, + keyword, + status, + created_at_from, + created_at_to, + handled_at_from, + handled_at_to, + sortField, + sortOrder + } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + content_id, + content_title, + reporter_id, + reporter_name, + handled_by, + handled_by_name, + reason, + keyword, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to), + handled_at_from: iso(handled_at_from), + handled_at_to: iso(handled_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/content-reports', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async processContentReport(id, { action, content_action, reason } = {}) { + if (!id) throw new Error('id is required'); + return requestJson(`/super/v1/content-reports/${id}/process`, { + method: 'POST', + body: { + action, + content_action, + reason + } + }); } }; diff --git a/frontend/superadmin/src/views/superadmin/Contents.vue b/frontend/superadmin/src/views/superadmin/Contents.vue index e4eb88e..2893b9f 100644 --- a/frontend/superadmin/src/views/superadmin/Contents.vue +++ b/frontend/superadmin/src/views/superadmin/Contents.vue @@ -55,11 +55,43 @@ const commentCreatedAtTo = ref(null); const commentSortField = ref('created_at'); const commentSortOrder = ref(-1); +const reportLoading = ref(false); +const reports = ref([]); +const reportTotalRecords = ref(0); +const reportPage = ref(1); +const reportRows = ref(10); +const reportID = ref(null); +const reportTenantID = ref(null); +const reportTenantCode = ref(''); +const reportTenantName = ref(''); +const reportContentID = ref(null); +const reportContentTitle = ref(''); +const reportReporterID = ref(null); +const reportReporterName = ref(''); +const reportHandledBy = ref(null); +const reportHandledByName = ref(''); +const reportReason = ref(''); +const reportKeyword = ref(''); +const reportStatus = ref('pending'); +const reportCreatedAtFrom = ref(null); +const reportCreatedAtTo = ref(null); +const reportHandledAtFrom = ref(null); +const reportHandledAtTo = ref(null); +const reportSortField = ref('created_at'); +const reportSortOrder = ref(-1); + const commentDeleteDialogVisible = ref(false); const commentDeleteLoading = ref(false); const commentDeleteReason = ref(''); const commentDeleteTarget = ref(null); +const reportProcessDialogVisible = ref(false); +const reportProcessSubmitting = ref(false); +const reportProcessAction = ref('approve'); +const reportProcessContentAction = ref('unpublish'); +const reportProcessReason = ref(''); +const reportProcessTarget = ref(null); + const reviewDialogVisible = ref(false); const reviewSubmitting = ref(false); const reviewAction = ref('approve'); @@ -92,6 +124,24 @@ const commentStatusOptions = [ { label: '已删除', value: 'deleted' } ]; +const reportStatusOptions = [ + { label: '全部', value: 'all' }, + { label: '待处理', value: 'pending' }, + { label: '已成立', value: 'approved' }, + { label: '已驳回', value: 'rejected' } +]; + +const reportActionOptions = [ + { label: '通过举报', value: 'approve' }, + { label: '驳回举报', value: 'reject' } +]; + +const reportContentActionOptions = [ + { label: '不处理内容', value: 'ignore' }, + { label: '下架内容', value: 'unpublish' }, + { label: '封禁内容', value: 'block' } +]; + function getQueryValue(value) { if (Array.isArray(value)) return value[0]; return value ?? null; @@ -162,6 +212,30 @@ function resetCommentFilters() { commentRows.value = 10; } +function resetReportFilters() { + reportID.value = null; + reportTenantID.value = null; + reportTenantCode.value = ''; + reportTenantName.value = ''; + reportContentID.value = null; + reportContentTitle.value = ''; + reportReporterID.value = null; + reportReporterName.value = ''; + reportHandledBy.value = null; + reportHandledByName.value = ''; + reportReason.value = ''; + reportKeyword.value = ''; + reportStatus.value = 'pending'; + reportCreatedAtFrom.value = null; + reportCreatedAtTo.value = null; + reportHandledAtFrom.value = null; + reportHandledAtTo.value = null; + reportSortField.value = 'created_at'; + reportSortOrder.value = -1; + reportPage.value = 1; + reportRows.value = 10; +} + function applyRouteQuery(query) { resetFilters(); @@ -241,8 +315,51 @@ function formatCommentContent(value) { return `${text.slice(0, 60)}...`; } +function getReportStatusSeverity(value) { + switch (value) { + case 'approved': + return 'success'; + case 'rejected': + return 'secondary'; + case 'pending': + default: + return 'warn'; + } +} + +function getReportStatusLabel(value) { + switch (value) { + case 'approved': + return '已成立'; + case 'rejected': + return '已驳回'; + case 'pending': + default: + return '待处理'; + } +} + +function formatReportDetail(value) { + const text = String(value || ''); + if (text.length <= 80) return text || '-'; + return `${text.slice(0, 80)}...`; +} + +function getReportActionLabel(value) { + switch (value) { + case 'block': + return '封禁内容'; + case 'unpublish': + return '下架内容'; + case 'ignore': + default: + return '不处理内容'; + } +} + const selectedCount = computed(() => selectedContents.value.length); const reviewTargetCount = computed(() => reviewTargetIDs.value.length); +const reportProcessNeedsContentAction = computed(() => reportProcessAction.value === 'approve'); async function loadContents() { loading.value = true; @@ -430,6 +547,100 @@ async function confirmCommentDelete() { } } +async function loadReports() { + reportLoading.value = true; + try { + const result = await ContentService.listContentReports({ + page: reportPage.value, + limit: reportRows.value, + id: reportID.value || undefined, + tenant_id: reportTenantID.value || undefined, + tenant_code: reportTenantCode.value, + tenant_name: reportTenantName.value, + content_id: reportContentID.value || undefined, + content_title: reportContentTitle.value, + reporter_id: reportReporterID.value || undefined, + reporter_name: reportReporterName.value, + handled_by: reportHandledBy.value || undefined, + handled_by_name: reportHandledByName.value, + reason: reportReason.value, + keyword: reportKeyword.value, + status: reportStatus.value || undefined, + created_at_from: reportCreatedAtFrom.value || undefined, + created_at_to: reportCreatedAtTo.value || undefined, + handled_at_from: reportHandledAtFrom.value || undefined, + handled_at_to: reportHandledAtTo.value || undefined, + sortField: reportSortField.value, + sortOrder: reportSortOrder.value + }); + reports.value = result.items; + reportTotalRecords.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载举报列表', life: 4000 }); + } finally { + reportLoading.value = false; + } +} + +function onReportSearch() { + reportPage.value = 1; + loadReports(); +} + +function onReportReset() { + resetReportFilters(); + loadReports(); +} + +function onReportPage(event) { + reportPage.value = (event.page ?? 0) + 1; + reportRows.value = event.rows ?? reportRows.value; + loadReports(); +} + +function onReportSort(event) { + reportSortField.value = event.sortField ?? reportSortField.value; + reportSortOrder.value = event.sortOrder ?? reportSortOrder.value; + loadReports(); +} + +function openReportProcessDialog(report) { + reportProcessTarget.value = report; + reportProcessAction.value = 'approve'; + reportProcessContentAction.value = 'unpublish'; + reportProcessReason.value = ''; + reportProcessDialogVisible.value = true; +} + +async function confirmReportProcess() { + const id = Number(reportProcessTarget.value?.id ?? 0); + if (!id) return; + + const action = reportProcessAction.value; + const contentAction = action === 'approve' ? reportProcessContentAction.value : 'ignore'; + const reason = reportProcessReason.value.trim(); + if (action === 'reject' && !reason) { + toast.add({ severity: 'warn', summary: '请输入原因', detail: '驳回举报时需填写原因', life: 3000 }); + return; + } + + reportProcessSubmitting.value = true; + try { + await ContentService.processContentReport(id, { + action, + content_action: contentAction, + reason: reason || undefined + }); + toast.add({ severity: 'success', summary: '已处理', detail: `举报ID: ${id}`, life: 3000 }); + reportProcessDialogVisible.value = false; + await loadReports(); + } catch (error) { + toast.add({ severity: 'error', summary: '处理失败', detail: error?.message || '无法处理举报', life: 4000 }); + } finally { + reportProcessSubmitting.value = false; + } +} + const unpublishDialogVisible = ref(false); const unpublishLoading = ref(false); const unpublishItem = ref(null); @@ -465,6 +676,8 @@ watch( (value) => { if (value === 'comments') { loadComments(); + } else if (value === 'reports') { + loadReports(); } else if (value === 'contents') { loadContents(); } @@ -497,6 +710,7 @@ watch( 内容列表 评论治理 + 举报治理 @@ -792,6 +1006,167 @@ watch( + +
+
+ 举报列表 + 跨租户内容举报处理 +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +