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

@@ -45,3 +45,19 @@ func (c *contentReports) List(ctx fiber.Ctx, filter *dto.SuperContentReportListF
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)
}
// Batch process content reports
//
// @Router /super/v1/content-reports/process/batch [post]
// @Summary Batch process content reports
// @Description Batch process content report records
// @Tags Content
// @Accept json
// @Produce json
// @Param form body dto.SuperContentReportBatchProcessForm true "Batch process form"
// @Success 200 {string} string "Processed"
// @Bind user local key(__ctx_user)
// @Bind form body
func (c *contentReports) BatchProcess(ctx fiber.Ctx, user *models.User, form *dto.SuperContentReportBatchProcessForm) error {
return services.Super.BatchProcessContentReports(ctx, user.ID, form)
}

View File

@@ -98,3 +98,15 @@ type SuperContentReportProcessForm struct {
// Reason 处理说明(可选,用于审计记录)。
Reason string `json:"reason"`
}
// SuperContentReportBatchProcessForm 超管内容举报批量处理表单。
type SuperContentReportBatchProcessForm struct {
// ReportIDs 待处理举报ID列表。
ReportIDs []int64 `json:"report_ids" validate:"required,min=1,dive,gt=0"`
// Action 处理动作approve/reject
Action string `json:"action" validate:"required,oneof=approve reject"`
// ContentAction 内容处置动作block/unpublish/ignore仅在 approve 时生效。
ContentAction string `json:"content_action"`
// Reason 处理说明(可选,用于审计记录)。
Reason string `json:"reason"`
}

View File

@@ -108,6 +108,12 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("id"),
Body[dto.SuperContentReportProcessForm]("form"),
))
r.log.Debugf("Registering route: Post /super/v1/content-reports/process/batch -> contentReports.BatchProcess")
router.Post("/super/v1/content-reports/process/batch"[len(r.Path()):], Func2(
r.contentReports.BatchProcess,
Local[*models.User]("__ctx_user"),
Body[dto.SuperContentReportBatchProcessForm]("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(

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()

View File

@@ -392,6 +392,40 @@ const docTemplate = `{
}
}
},
"/super/v1/content-reports/process/batch": {
"post": {
"description": "Batch process content report records",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Content"
],
"summary": "Batch process content reports",
"parameters": [
{
"description": "Batch process form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SuperContentReportBatchProcessForm"
}
}
],
"responses": {
"200": {
"description": "Processed",
"schema": {
"type": "string"
}
}
}
}
},
"/super/v1/content-reports/{id}/process": {
"post": {
"description": "Process a content report record",
@@ -8155,6 +8189,39 @@ const docTemplate = `{
}
}
},
"dto.SuperContentReportBatchProcessForm": {
"type": "object",
"required": [
"action",
"report_ids"
],
"properties": {
"action": {
"description": "Action 处理动作approve/reject。",
"type": "string",
"enum": [
"approve",
"reject"
]
},
"content_action": {
"description": "ContentAction 内容处置动作block/unpublish/ignore仅在 approve 时生效。",
"type": "string"
},
"reason": {
"description": "Reason 处理说明(可选,用于审计记录)。",
"type": "string"
},
"report_ids": {
"description": "ReportIDs 待处理举报ID列表。",
"type": "array",
"minItems": 1,
"items": {
"type": "integer"
}
}
}
},
"dto.SuperContentReportItem": {
"type": "object",
"properties": {

View File

@@ -386,6 +386,40 @@
}
}
},
"/super/v1/content-reports/process/batch": {
"post": {
"description": "Batch process content report records",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Content"
],
"summary": "Batch process content reports",
"parameters": [
{
"description": "Batch process form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SuperContentReportBatchProcessForm"
}
}
],
"responses": {
"200": {
"description": "Processed",
"schema": {
"type": "string"
}
}
}
}
},
"/super/v1/content-reports/{id}/process": {
"post": {
"description": "Process a content report record",
@@ -8149,6 +8183,39 @@
}
}
},
"dto.SuperContentReportBatchProcessForm": {
"type": "object",
"required": [
"action",
"report_ids"
],
"properties": {
"action": {
"description": "Action 处理动作approve/reject。",
"type": "string",
"enum": [
"approve",
"reject"
]
},
"content_action": {
"description": "ContentAction 内容处置动作block/unpublish/ignore仅在 approve 时生效。",
"type": "string"
},
"reason": {
"description": "Reason 处理说明(可选,用于审计记录)。",
"type": "string"
},
"report_ids": {
"description": "ReportIDs 待处理举报ID列表。",
"type": "array",
"minItems": 1,
"items": {
"type": "integer"
}
}
}
},
"dto.SuperContentReportItem": {
"type": "object",
"properties": {

View File

@@ -1421,6 +1421,30 @@ definitions:
- content_ids
- status
type: object
dto.SuperContentReportBatchProcessForm:
properties:
action:
description: Action 处理动作approve/reject
enum:
- approve
- reject
type: string
content_action:
description: ContentAction 内容处置动作block/unpublish/ignore仅在 approve 时生效。
type: string
reason:
description: Reason 处理说明(可选,用于审计记录)。
type: string
report_ids:
description: ReportIDs 待处理举报ID列表。
items:
type: integer
minItems: 1
type: array
required:
- action
- report_ids
type: object
dto.SuperContentReportItem:
properties:
content_id:
@@ -3596,6 +3620,28 @@ paths:
summary: Process content report
tags:
- Content
/super/v1/content-reports/process/batch:
post:
consumes:
- application/json
description: Batch process content report records
parameters:
- description: Batch process form
in: body
name: form
required: true
schema:
$ref: '#/definitions/dto.SuperContentReportBatchProcessForm'
produces:
- application/json
responses:
"200":
description: Processed
schema:
type: string
summary: Batch process content reports
tags:
- Content
/super/v1/contents:
get:
consumes: