diff --git a/backend/app/http/super/v1/contents.go b/backend/app/http/super/v1/contents.go index 620346c..e3ac782 100644 --- a/backend/app/http/super/v1/contents.go +++ b/backend/app/http/super/v1/contents.go @@ -116,3 +116,19 @@ func (c *contents) Review(ctx fiber.Ctx, user *models.User, id int64, form *dto. func (c *contents) BatchReview(ctx fiber.Ctx, user *models.User, form *dto.SuperContentBatchReviewForm) error { return services.Super.BatchReviewContents(ctx, user.ID, form) } + +// Batch update content status +// +// @Router /super/v1/contents/status/batch [post] +// @Summary Batch update content status +// @Description Batch unpublish or block contents +// @Tags Content +// @Accept json +// @Produce json +// @Param form body dto.SuperContentBatchStatusForm true "Batch status form" +// @Success 200 {string} string "Updated" +// @Bind user local key(__ctx_user) +// @Bind form body +func (c *contents) BatchUpdateStatus(ctx fiber.Ctx, user *models.User, form *dto.SuperContentBatchStatusForm) error { + return services.Super.BatchUpdateContentStatus(ctx, user.ID, form) +} diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 0281576..ca13fa8 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -446,6 +446,15 @@ type SuperContentBatchReviewForm struct { Reason string `json:"reason"` } +type SuperContentBatchStatusForm struct { + // ContentIDs 待处置内容ID列表。 + ContentIDs []int64 `json:"content_ids" validate:"required,min=1,dive,gt=0"` + // Status 目标内容状态(unpublished/blocked)。 + Status consts.ContentStatus `json:"status" validate:"required,oneof=unpublished blocked"` + // Reason 处置说明(可选,用于审计与通知作者)。 + Reason string `json:"reason"` +} + type SuperTenantUserItem struct { // User 用户信息。 User *SuperUserLite `json:"user"` diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 0e940ae..b7f9da3 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -145,6 +145,12 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), Body[dto.SuperContentBatchReviewForm]("form"), )) + r.log.Debugf("Registering route: Post /super/v1/contents/status/batch -> contents.BatchUpdateStatus") + router.Post("/super/v1/contents/status/batch"[len(r.Path()):], Func2( + r.contents.BatchUpdateStatus, + Local[*models.User]("__ctx_user"), + Body[dto.SuperContentBatchStatusForm]("form"), + )) // Register routes for controller: coupons r.log.Debugf("Registering route: Get /super/v1/coupon-grants -> coupons.ListGrants") router.Get("/super/v1/coupon-grants"[len(r.Path()):], DataFunc1( diff --git a/backend/app/services/super.go b/backend/app/services/super.go index d7974e8..da2575d 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -3240,6 +3240,85 @@ func (s *super) BatchReviewContents(ctx context.Context, operatorID int64, form return nil } +func (s *super) BatchUpdateContentStatus(ctx context.Context, operatorID int64, form *super_dto.SuperContentBatchStatusForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if form == nil { + return errorx.ErrBadRequest.WithMsg("处置参数不能为空") + } + + status := form.Status + if status != consts.ContentStatusUnpublished && status != consts.ContentStatusBlocked { + return errorx.ErrBadRequest.WithMsg("处置状态非法") + } + + // 去重并过滤非法ID,避免批量处置出现空指令。 + unique := make(map[int64]struct{}) + contentIDs := make([]int64, 0, len(form.ContentIDs)) + for _, id := range form.ContentIDs { + if id <= 0 { + continue + } + if _, ok := unique[id]; ok { + continue + } + unique[id] = struct{}{} + contentIDs = append(contentIDs, id) + } + if len(contentIDs) == 0 { + return errorx.ErrBadRequest.WithMsg("内容ID不能为空") + } + + reason := strings.TrimSpace(form.Reason) + var contents []*models.Content + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, q := tx.Content.QueryContext(ctx) + list, err := q.Where(tbl.ID.In(contentIDs...)).Find() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if len(list) != len(contentIDs) { + return errorx.ErrRecordNotFound.WithMsg("部分内容不存在") + } + + // 内容治理批量处置,统一更新状态与更新时间。 + updates := &models.Content{ + Status: status, + UpdatedAt: time.Now(), + } + if _, err := q.Where(tbl.ID.In(contentIDs...)).Updates(updates); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + contents = list + return nil + }) + if err != nil { + return err + } + + title := "内容治理结果" + detail := "内容已下架" + action := "content_unpublish" + if status == consts.ContentStatusBlocked { + detail = "内容已封禁" + action = "content_block" + } + if reason != "" { + detail += ",原因:" + reason + } + for _, content := range contents { + if Notification != nil { + _ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail) + } + if Audit != nil { + Audit.Log(ctx, content.TenantID, operatorID, action, cast.ToString(content.ID), detail) + } + } + + return nil +} + func (s *super) ContentStatistics(ctx context.Context, filter *super_dto.SuperContentStatisticsFilter) (*super_dto.SuperContentStatisticsResponse, error) { // 统一统计时间范围与粒度,默认最近 7 天。 reportFilter := &super_dto.SuperReportOverviewFilter{} diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 0cf1ceb..3b0ce41 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -600,6 +600,51 @@ func (s *SuperTestSuite) Test_ContentReview() { }) } +func (s *SuperTestSuite) Test_BatchUpdateContentStatus() { + Convey("BatchUpdateContentStatus", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameTenant, models.TableNameContent) + + admin := &models.User{Username: "batch_admin"} + owner := &models.User{Username: "batch_owner"} + models.UserQuery.WithContext(ctx).Create(admin, owner) + + tenant := &models.Tenant{ + UserID: owner.ID, + Name: "Batch Tenant", + Code: "batch", + Status: consts.TenantStatusVerified, + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + content1 := &models.Content{ + TenantID: tenant.ID, + UserID: owner.ID, + Title: "Batch Content 1", + Status: consts.ContentStatusPublished, + } + content2 := &models.Content{ + TenantID: tenant.ID, + UserID: owner.ID, + Title: "Batch Content 2", + Status: consts.ContentStatusUnpublished, + } + models.ContentQuery.WithContext(ctx).Create(content1, content2) + + err := Super.BatchUpdateContentStatus(ctx, admin.ID, &super_dto.SuperContentBatchStatusForm{ + ContentIDs: []int64{content1.ID, content2.ID}, + Status: consts.ContentStatusBlocked, + Reason: "违规处理", + }) + So(err, ShouldBeNil) + + reloaded1, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(content1.ID)).First() + reloaded2, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(content2.ID)).First() + So(reloaded1.Status, ShouldEqual, consts.ContentStatusBlocked) + So(reloaded2.Status, ShouldEqual, consts.ContentStatusBlocked) + }) +} + func (s *SuperTestSuite) Test_OrderGovernance() { Convey("OrderGovernance", s.T(), func() { ctx := s.T().Context() diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 65da3ab..520b7ea 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -543,6 +543,40 @@ const docTemplate = `{ } } }, + "/super/v1/contents/status/batch": { + "post": { + "description": "Batch unpublish or block contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Batch update content status", + "parameters": [ + { + "description": "Batch status form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentBatchStatusForm" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents/{id}/review": { "post": { "description": "Review content", @@ -8013,6 +8047,39 @@ const docTemplate = `{ } } }, + "dto.SuperContentBatchStatusForm": { + "type": "object", + "required": [ + "content_ids", + "status" + ], + "properties": { + "content_ids": { + "description": "ContentIDs 待处置内容ID列表。", + "type": "array", + "minItems": 1, + "items": { + "type": "integer" + } + }, + "reason": { + "description": "Reason 处置说明(可选,用于审计与通知作者)。", + "type": "string" + }, + "status": { + "description": "Status 目标内容状态(unpublished/blocked)。", + "enum": [ + "unpublished", + "blocked" + ], + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] + } + } + }, "dto.SuperContentReportItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index e67c700..93e5dbb 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -537,6 +537,40 @@ } } }, + "/super/v1/contents/status/batch": { + "post": { + "description": "Batch unpublish or block contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Batch update content status", + "parameters": [ + { + "description": "Batch status form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentBatchStatusForm" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents/{id}/review": { "post": { "description": "Review content", @@ -8007,6 +8041,39 @@ } } }, + "dto.SuperContentBatchStatusForm": { + "type": "object", + "required": [ + "content_ids", + "status" + ], + "properties": { + "content_ids": { + "description": "ContentIDs 待处置内容ID列表。", + "type": "array", + "minItems": 1, + "items": { + "type": "integer" + } + }, + "reason": { + "description": "Reason 处置说明(可选,用于审计与通知作者)。", + "type": "string" + }, + "status": { + "description": "Status 目标内容状态(unpublished/blocked)。", + "enum": [ + "unpublished", + "blocked" + ], + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] + } + } + }, "dto.SuperContentReportItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 67d949f..5bdd9fc 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1376,6 +1376,28 @@ definitions: - action - content_ids type: object + dto.SuperContentBatchStatusForm: + properties: + content_ids: + description: ContentIDs 待处置内容ID列表。 + items: + type: integer + minItems: 1 + type: array + reason: + description: Reason 处置说明(可选,用于审计与通知作者)。 + type: string + status: + allOf: + - $ref: '#/definitions/consts.ContentStatus' + description: Status 目标内容状态(unpublished/blocked)。 + enum: + - unpublished + - blocked + required: + - content_ids + - status + type: object dto.SuperContentReportItem: properties: content_id: @@ -3617,6 +3639,28 @@ paths: summary: Content statistics tags: - Content + /super/v1/contents/status/batch: + post: + consumes: + - application/json + description: Batch unpublish or block contents + parameters: + - description: Batch status form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperContentBatchStatusForm' + produces: + - application/json + responses: + "200": + description: Updated + schema: + type: string + summary: Batch update content status + tags: + - Content /super/v1/coupon-grants: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index ee25332..040223c 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -41,9 +41,9 @@ - 缺口:无显著功能缺口。 ### 2.7 内容治理 `/superadmin/contents` -- 状态:**部分完成** -- 已有:跨租户列表、下架、审核流(含批量审核)、评论治理、内容举报处理。 -- 缺口:批量处置扩展(如批量下架/封禁)。 +- 状态:**已完成** +- 已有:跨租户列表、下架、审核流(含批量审核)、批量下架/封禁、评论治理、内容举报处理。 +- 缺口:无显著功能缺口。 ### 2.8 订单与退款 `/superadmin/orders` - 状态:**已完成** diff --git a/frontend/superadmin/src/service/ContentService.js b/frontend/superadmin/src/service/ContentService.js index 435ae51..694d3df 100644 --- a/frontend/superadmin/src/service/ContentService.js +++ b/frontend/superadmin/src/service/ContentService.js @@ -131,6 +131,17 @@ export const ContentService = { } }); }, + async batchUpdateContentStatus({ content_ids, status, reason } = {}) { + if (!Array.isArray(content_ids) || content_ids.length === 0) throw new Error('content_ids is required'); + return requestJson('/super/v1/contents/status/batch', { + method: 'POST', + body: { + content_ids, + status, + reason + } + }); + }, async getContentStatistics({ tenant_id, start_at, end_at, granularity } = {}) { const iso = (d) => { if (!d) return undefined; diff --git a/frontend/superadmin/src/views/superadmin/Contents.vue b/frontend/superadmin/src/views/superadmin/Contents.vue index 2893b9f..3e167fc 100644 --- a/frontend/superadmin/src/views/superadmin/Contents.vue +++ b/frontend/superadmin/src/views/superadmin/Contents.vue @@ -102,6 +102,17 @@ const reviewActionOptions = [ { label: '驳回', value: 'reject' } ]; +const batchStatusDialogVisible = ref(false); +const batchStatusSubmitting = ref(false); +const batchStatusValue = ref('unpublished'); +const batchStatusReason = ref(''); +const batchStatusTargetIDs = ref([]); + +const batchStatusOptions = [ + { label: '下架内容', value: 'unpublished' }, + { label: '封禁内容', value: 'blocked' } +]; + const statusOptions = [ { label: '全部', value: '' }, { label: 'draft', value: 'draft' }, @@ -359,6 +370,7 @@ function getReportActionLabel(value) { const selectedCount = computed(() => selectedContents.value.length); const reviewTargetCount = computed(() => reviewTargetIDs.value.length); +const batchStatusTargetCount = computed(() => batchStatusTargetIDs.value.length); const reportProcessNeedsContentAction = computed(() => reportProcessAction.value === 'approve'); async function loadContents() { @@ -449,6 +461,52 @@ async function confirmReview() { } } +function openBatchStatusDialog(statusValue) { + if (!selectedContents.value.length) { + toast.add({ severity: 'warn', summary: '请先选择内容', detail: '至少选择 1 条内容进行处置', life: 3000 }); + return; + } + const ids = selectedContents.value.map((row) => getContentID(row)).filter((id) => id > 0); + if (!ids.length) { + toast.add({ severity: 'warn', summary: '选择无效', detail: '未识别到可处置的内容', life: 3000 }); + return; + } + batchStatusTargetIDs.value = ids; + batchStatusValue.value = statusValue || 'unpublished'; + batchStatusReason.value = ''; + batchStatusDialogVisible.value = true; +} + +async function confirmBatchStatus() { + const ids = batchStatusTargetIDs.value.filter((id) => id > 0); + if (!ids.length) return; + + const statusValue = batchStatusValue.value; + const reason = batchStatusReason.value.trim(); + if (statusValue === 'blocked' && !reason) { + toast.add({ severity: 'warn', summary: '请输入原因', detail: '封禁内容时需填写原因', life: 3000 }); + return; + } + + batchStatusSubmitting.value = true; + try { + await ContentService.batchUpdateContentStatus({ + content_ids: ids, + status: statusValue, + reason: reason || undefined + }); + toast.add({ severity: 'success', summary: '处置完成', detail: `已处理 ${ids.length} 条内容`, life: 3000 }); + batchStatusDialogVisible.value = false; + batchStatusTargetIDs.value = []; + selectedContents.value = []; + await loadContents(); + } catch (error) { + toast.add({ severity: 'error', summary: '处置失败', detail: error?.message || '无法完成内容处置', life: 4000 }); + } finally { + batchStatusSubmitting.value = false; + } +} + function onSearch() { page.value = 1; loadContents(); @@ -721,6 +779,8 @@ watch(