feat: add batch content report processing

This commit is contained in:
2026-01-16 17:59:18 +08:00
parent 028c462eaa
commit b796636b5d
11 changed files with 599 additions and 8 deletions

View File

@@ -3166,6 +3166,163 @@ func (s *super) ProcessContentReport(ctx context.Context, operatorID, id int64,
return nil
}
func (s *super) BatchProcessContentReports(ctx context.Context, operatorID int64, form *super_dto.SuperContentReportBatchProcessForm) 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"
}
// 去重并过滤非法ID避免批量处理出现空指令。
unique := make(map[int64]struct{})
reportIDs := make([]int64, 0, len(form.ReportIDs))
for _, id := range form.ReportIDs {
if id <= 0 {
continue
}
if _, ok := unique[id]; ok {
continue
}
unique[id] = struct{}{}
reportIDs = append(reportIDs, id)
}
if len(reportIDs) == 0 {
return errorx.ErrBadRequest.WithMsg("举报ID不能为空")
}
status := "approved"
if action == "reject" {
status = "rejected"
}
handledReason := strings.TrimSpace(form.Reason)
handledAt := time.Now()
var reports []*models.ContentReport
contentMap := make(map[int64]*models.Content)
// 事务内批量更新举报处理结果及关联内容状态,保证一致性。
err := models.Q.Transaction(func(tx *models.Query) error {
reportTbl, reportQuery := tx.ContentReport.QueryContext(ctx)
list, err := reportQuery.Where(reportTbl.ID.In(reportIDs...)).Find()
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if len(list) != len(reportIDs) {
return errorx.ErrRecordNotFound.WithMsg("部分举报不存在")
}
for _, report := range list {
if strings.TrimSpace(report.Status) != "" && report.Status != "pending" {
return errorx.ErrStatusConflict.WithMsg("仅可处理待处理举报")
}
}
_, err = reportQuery.Where(reportTbl.ID.In(reportIDs...)).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" {
contentIDs := make([]int64, 0, len(list))
for _, report := range list {
contentIDs = append(contentIDs, report.ContentID)
}
contentTbl, contentQuery := tx.Content.QueryContext(ctx)
contentQueryUnscoped := contentQuery.Unscoped()
contents, err := contentQueryUnscoped.Where(contentTbl.ID.In(contentIDs...)).Find()
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
for _, content := range contents {
contentMap[content.ID] = content
}
if contentAction != "ignore" {
var nextStatus consts.ContentStatus
switch contentAction {
case "block":
nextStatus = consts.ContentStatusBlocked
case "unpublish":
nextStatus = consts.ContentStatusUnpublished
}
if nextStatus != "" && len(contents) > 0 {
if _, err := contentQuery.Where(contentTbl.ID.In(contentIDs...)).UpdateSimple(
contentTbl.Status.Value(nextStatus),
); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
}
}
}
reports = list
return nil
})
if err != nil {
return err
}
if action == "approve" {
title := "内容举报成立"
actionLabel := "不处理内容"
switch contentAction {
case "block":
actionLabel = "封禁内容"
case "unpublish":
actionLabel = "下架内容"
}
for _, report := range reports {
content := contentMap[report.ContentID]
if content == nil {
continue
}
detail := "内容《" + content.Title + "》举报成立,处理结果:" + actionLabel
if handledReason != "" {
detail += "。说明:" + handledReason
}
if Notification != nil {
_ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail)
}
}
}
if Audit != nil {
detail := "处理内容举报:" + action + " / " + contentAction
if handledReason != "" {
detail += " / " + handledReason
}
for _, report := range reports {
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))

View File

@@ -396,6 +396,107 @@ func (s *SuperTestSuite) Test_ContentReportGovernance() {
})
}
func (s *SuperTestSuite) Test_BatchProcessContentReports() {
Convey("BatchProcessContentReports", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameContentReport, models.TableNameContent, models.TableNameTenant, models.TableNameUser)
owner := &models.User{Username: "batch_report_owner"}
reporter := &models.User{Username: "batch_reporter"}
admin := &models.User{Username: "batch_report_admin"}
models.UserQuery.WithContext(ctx).Create(owner, reporter, admin)
tenant := &models.Tenant{UserID: owner.ID, Code: "t-report-batch", Name: "Report Batch Tenant", Status: consts.TenantStatusVerified}
models.TenantQuery.WithContext(ctx).Create(tenant)
contentA := &models.Content{
TenantID: tenant.ID,
UserID: owner.ID,
Title: "Batch Report A",
Status: consts.ContentStatusPublished,
}
contentB := &models.Content{
TenantID: tenant.ID,
UserID: owner.ID,
Title: "Batch Report B",
Status: consts.ContentStatusPublished,
}
models.ContentQuery.WithContext(ctx).Create(contentA, contentB)
Convey("should batch approve reports and unpublish contents", func() {
reportA := &models.ContentReport{
TenantID: tenant.ID,
ContentID: contentA.ID,
ReporterID: reporter.ID,
Reason: "spam",
Detail: "批量举报A",
Status: "pending",
}
reportB := &models.ContentReport{
TenantID: tenant.ID,
ContentID: contentB.ID,
ReporterID: reporter.ID,
Reason: "abuse",
Detail: "批量举报B",
Status: "pending",
}
models.ContentReportQuery.WithContext(ctx).Create(reportA, reportB)
err := Super.BatchProcessContentReports(ctx, admin.ID, &super_dto.SuperContentReportBatchProcessForm{
ReportIDs: []int64{reportA.ID, reportB.ID},
Action: "approve",
ContentAction: "unpublish",
Reason: "集中处理",
})
So(err, ShouldBeNil)
reloadedA, err := models.ContentReportQuery.WithContext(ctx).Where(models.ContentReportQuery.ID.Eq(reportA.ID)).First()
So(err, ShouldBeNil)
So(reloadedA.Status, ShouldEqual, "approved")
So(reloadedA.HandledBy, ShouldEqual, admin.ID)
So(reloadedA.HandledAction, ShouldEqual, "unpublish")
So(reloadedA.HandledReason, ShouldEqual, "集中处理")
So(reloadedA.HandledAt.IsZero(), ShouldBeFalse)
reloadedB, err := models.ContentReportQuery.WithContext(ctx).Where(models.ContentReportQuery.ID.Eq(reportB.ID)).First()
So(err, ShouldBeNil)
So(reloadedB.Status, ShouldEqual, "approved")
So(reloadedB.HandledAction, ShouldEqual, "unpublish")
contentReloadA, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(contentA.ID)).First()
So(err, ShouldBeNil)
So(contentReloadA.Status, ShouldEqual, consts.ContentStatusUnpublished)
contentReloadB, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(contentB.ID)).First()
So(err, ShouldBeNil)
So(contentReloadB.Status, ShouldEqual, consts.ContentStatusUnpublished)
})
Convey("should reject when report already handled", func() {
report := &models.ContentReport{
TenantID: tenant.ID,
ContentID: contentA.ID,
ReporterID: reporter.ID,
Reason: "other",
Detail: "已处理",
Status: "approved",
}
models.ContentReportQuery.WithContext(ctx).Create(report)
err := Super.BatchProcessContentReports(ctx, admin.ID, &super_dto.SuperContentReportBatchProcessForm{
ReportIDs: []int64{report.ID},
Action: "reject",
Reason: "重复提交",
})
So(err, ShouldNotBeNil)
var appErr *errorx.AppError
So(errors.As(err, &appErr), ShouldBeTrue)
So(appErr.Code, ShouldEqual, errorx.ErrStatusConflict.Code)
})
})
}
func (s *SuperTestSuite) Test_FinanceAnomalies() {
Convey("Finance Anomalies", s.T(), func() {
ctx := s.T().Context()