feat: add batch content report processing
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user