package contentreviewctrl import ( "91porn-server/common" "91porn-server/common/log" "91porn-server/common/stderr" "91porn-server/models/v/contentreviewmod" "91porn-server/models/v/sensitivewordmod" "91porn-server/web/service/contentreviewser" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson/primitive" ) // createReq 发起任务请求体 type createReq struct { TaskType string `json:"taskType" binding:"required"` // VIDEO / ACG } // Create doc // @Summary 发起内容检测任务 // @Description 立即将当前启用的敏感词库做快照写入任务记录,状态为待执行,由 skd 定时调度器异步执行 // @Tags Web-ContentReview // @Accept json // @Produce json // @Param body body createReq true "任务参数" // @Success 200 {string} json "{"msg":"操作成功","data":{"id":"..."}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/create [post] func Create(ctx *gin.Context) { var req createReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } if req.TaskType != contentreviewmod.TaskTypeVideo && req.TaskType != contentreviewmod.TaskTypeACG { common.ServeJSON(ctx, stderr.ErrParamError, "invalid taskType") return } snap, err := buildSnapshot() if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, err.Error()) return } if len(snap) == 0 { common.ServeJSON(ctx, stderr.ErrParamError, "敏感词库为空,请先配置") return } operator, _ := common.GetAdminAct(ctx) task := &contentreviewmod.ReviewTask{ TaskType: req.TaskType, SnapshotWords: snap, Operator: operator, } if err := contentreviewmod.CreateTask(task); err != nil { common.ServeJSON(ctx, stderr.Failure, err.Error()) return } common.ServeJSON(ctx, stderr.Success, gin.H{"id": task.ID.Hex()}) } // listReq 任务列表请求 type listReq struct { Page int64 `form:"page"` // 页码,从 1 开始 Size int64 `form:"size"` // 每页条数 TaskType string `form:"taskType"` // 任务类型筛选 Status *int `form:"status"` // 任务状态筛选 } // taskWithStat 任务列表返回元素:嵌入 ReviewTask,附带由 IssueCount-ResolvedCount 算出的待解决数 type taskWithStat struct { *contentreviewmod.ReviewTask UnresolvedCount int64 `json:"unresolvedCount"` // 待解决问题数 = IssueCount - ResolvedCount } // List doc // @Summary 检测任务列表 // @Description 分页查询检测任务;待解决问题数 unresolvedCount = issueCount - resolvedCount,读时算差值,无需实时聚合 // @Tags Web-ContentReview // @Accept json // @Produce json // @Param page query int false "页码,默认1" // @Param size query int false "每页条数,默认20" // @Param taskType query string false "任务类型 VIDEO/ACG" // @Param status query int false "状态 0待执行 1执行中 2已完成 3失败" // @Success 200 {string} json "{"msg":"操作成功","data":{"list":[{...,"resolvedCount":0,"unresolvedCount":0}],"total":0}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/list [get] func List(ctx *gin.Context) { var req listReq if err := ctx.ShouldBind(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } list, total, err := contentreviewmod.ListTasks(req.Page, req.Size, req.TaskType, req.Status) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, nil) return } enriched := make([]taskWithStat, 0, len(list)) for _, t := range list { unresolved := t.IssueCount - t.ResolvedCount if unresolved < 0 { unresolved = 0 } enriched = append(enriched, taskWithStat{ReviewTask: t, UnresolvedCount: unresolved}) } common.ServeJSON(ctx, stderr.Success, gin.H{"list": enriched, "total": total}) } // Detail doc // @Summary 检测任务详情 // @Description 根据任务ID查询任务详情(含敏感词快照、进度、状态等) // @Tags Web-ContentReview // @Accept json // @Produce json // @Param id query string true "任务ID" // @Success 200 {string} json "{"msg":"操作成功","data":{}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/detail [get] func Detail(ctx *gin.Context) { idStr := ctx.Query("id") id, err := primitive.ObjectIDFromHex(idStr) if err != nil { common.ServeJSON(ctx, stderr.ErrParamError, "invalid id") return } t, err := contentreviewmod.GetTaskByID(id) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, nil) return } if t == nil { common.ServeJSON(ctx, stderr.ErrParamError, "task not found") return } common.ServeJSON(ctx, stderr.Success, t) } // issuesReq 命中记录列表请求 type issuesReq struct { TaskID string `form:"taskId" binding:"required"` // 所属任务 ID Page int64 `form:"page"` // 页码 Size int64 `form:"size"` // 每页条数 ResolveStatus *int `form:"resolveStatus"` // 解决状态筛选 0未解决 1待审核 2已解决 } // Issues doc // @Summary 检测命中记录列表 // @Description 分页查询某个任务下命中的问题记录;Title/Content/Tags/RichText 字段已通过 ... 标红;支持按解决状态筛选 // @Tags Web-ContentReview // @Accept json // @Produce json // @Param taskId query string true "任务ID" // @Param page query int false "页码,默认1" // @Param size query int false "每页条数,默认20" // @Param resolveStatus query int false "解决状态筛选 0未解决 1待审核 2已解决" // @Success 200 {string} json "{"msg":"操作成功","data":{"list":[],"total":0}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/issues [get] func Issues(ctx *gin.Context) { var req issuesReq if err := ctx.ShouldBind(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } tid, err := primitive.ObjectIDFromHex(req.TaskID) if err != nil { common.ServeJSON(ctx, stderr.ErrParamError, "invalid taskId") return } list, total, err := contentreviewmod.ListIssues(tid, req.Page, req.Size, req.ResolveStatus) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, nil) return } common.ServeJSON(ctx, stderr.Success, gin.H{"list": list, "total": total}) } // resolveReq 解决(提交修改并直接生效)请求 type resolveReq struct { ID string `json:"id" binding:"required"` // 命中记录 ID SubmittedTitle string `json:"submittedTitle"` // 修改后的标题(VIDEO.Title / ACG_MEDIA.Title / ACG_CONTENT.Name) SubmittedContent string `json:"submittedContent"` // 修改后的内容(VIDEO.Content / ACG_MEDIA.Summary / ACG_CONTENT.Text) SubmittedRichText string `json:"submittedRichText"` // 修改后的富文本(仅 VIDEO 有效) } // ResolveIssue doc // @Summary 解决命中记录(提交修改并直接生效) // @Description 管理员提交修改即刻生效:先对提交文本做敏感词复校验(仍命中则拒绝并返回详细命中信息),通过后按 TargetType 把 Submitted* 写回数据源(VideoModel/Media/MediaContent),写回成功后标记 issue 为已解决;必须提供至少一个 submittedXxx 字段 // @Tags Web-ContentReview // @Accept json // @Produce json // @Param body body resolveReq true "解决参数" // @Success 200 {string} json "{"msg":"操作成功"}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/issue/resolve [post] func ResolveIssue(ctx *gin.Context) { var req resolveReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } if req.SubmittedTitle == "" && req.SubmittedContent == "" && req.SubmittedRichText == "" { common.ServeJSON(ctx, stderr.ErrParamError, "至少提交一个修改字段") return } id, err := primitive.ObjectIDFromHex(req.ID) if err != nil { common.ServeJSON(ctx, stderr.ErrParamError, "invalid id") return } issue, err := contentreviewmod.GetIssueByID(id) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, nil) return } if issue == nil { common.ServeJSON(ctx, stderr.ErrParamError, "记录不存在") return } if issue.ResolveStatus == contentreviewmod.ResolveStatusResolved { common.ServeJSON(ctx, stderr.ErrParamError, "记录已解决,无需再次提交") return } // 复校验:管理员提交的修正文本不得再含敏感词,否则拒绝写回并返回详细命中信息 if tip := checkSubmittedSensitive(req.SubmittedTitle, req.SubmittedContent, req.SubmittedRichText); tip != "" { common.ServeJSON(ctx, stderr.ContentSensitiveHit, tip) return } // 用提交内容覆盖 issue 字段(仅用于 applier 的入参,不持久化到这里) issue.SubmittedTitle = req.SubmittedTitle issue.SubmittedContent = req.SubmittedContent issue.SubmittedRichText = req.SubmittedRichText // 先写回数据源;成功后才把 issue 标记为已解决 if err := contentreviewser.ApplyResolution(issue); err != nil { log.Error("apply resolution fail", log.Any("issueId", id), log.E(err)) common.ServeJSON(ctx, stderr.Failure, err.Error()) return } operator, _ := common.GetAdminAct(ctx) ok, err := contentreviewmod.ResolveIssue(id, issue.TaskID, req.SubmittedTitle, req.SubmittedContent, req.SubmittedRichText, operator) if err != nil { common.ServeJSON(ctx, stderr.Failure, err.Error()) return } if !ok { common.ServeJSON(ctx, stderr.ErrParamError, "记录状态已变化,请刷新") return } common.ServeJSON(ctx, stderr.Success, nil) } // checkSubmittedSensitive 对管理员提交的修正文本做敏感词复校验。 // 任一字段仍命中启用状态的敏感词即返回详细提示(命中字段 + 命中词),全部通过返回空串。 // 词库为空(含 DB 降级)时不拦截,返回空串。 func checkSubmittedSensitive(title, content, richText string) string { terms := sensitivewordmod.LoadEnabledTerms() if len(terms) == 0 { return "" } titleHits := sensitivewordmod.MatchHits(title, terms) contentHits := sensitivewordmod.MatchHits(content, terms) richHits := sensitivewordmod.MatchHits(richText, terms) if len(titleHits) == 0 && len(contentHits) == 0 && len(richHits) == 0 { return "" } return "提交的修正文本仍含敏感词,请修改后重新提交:" + sensitivewordmod.FormatHitDetail(titleHits, contentHits, richHits) } // batchOffShelfReq 批量下架命中请求 type batchOffShelfReq struct { IDs []string `json:"ids" binding:"required,min=1"` // 命中记录 ID 列表 } // batchOffShelfFailure 单条失败明细 type batchOffShelfFailure struct { ID string `json:"id"` Reason string `json:"reason"` } // batchOffShelfResp 批量下架返回 type batchOffShelfResp struct { Success int `json:"success"` // 成功条数 Fail int `json:"fail"` // 失败条数 Failures []batchOffShelfFailure `json:"failures,omitempty"` // 失败明细 } // BatchOffShelfIssues doc // @Summary 批量下架命中记录 // @Description 对一批命中记录直接执行"下架"处置:按 TargetType 分组下架对应资源(VIDEO→status=5;ACG_MEDIA→Media.status=0;ACG_CONTENT→MediaContent.isActive=false),下架成功后把 issue 标记为已解决,resolveAction=offshelf。前置校验阶段(id 非法 / 记录不存在 / 已解决)逐条剔除并返回原因;下架与标记走批量 UpdateMany,整体失败则该批全记入 failures。 // @Tags Web-ContentReview // @Accept json // @Produce json // @Param body body batchOffShelfReq true "批量下架参数" // @Success 200 {object} batchOffShelfResp // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/issue/batch-offshelf [post] func BatchOffShelfIssues(ctx *gin.Context) { var req batchOffShelfReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } operator, _ := common.GetAdminAct(ctx) resp := batchOffShelfResp{Failures: make([]batchOffShelfFailure, 0)} // 1. 解析 ID,非法的直接计入失败 objIDs := make([]primitive.ObjectID, 0, len(req.IDs)) rawByID := make(map[primitive.ObjectID]string, len(req.IDs)) for _, raw := range req.IDs { id, err := primitive.ObjectIDFromHex(raw) if err != nil { resp.Fail++ resp.Failures = append(resp.Failures, batchOffShelfFailure{ID: raw, Reason: "invalid id"}) continue } objIDs = append(objIDs, id) rawByID[id] = raw } if len(objIDs) == 0 { common.ServeJSON(ctx, stderr.Success, resp) return } // 2. 一次性拉取 issue issues, err := contentreviewmod.GetIssuesByIDs(objIDs) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, err.Error()) return } found := make(map[primitive.ObjectID]*contentreviewmod.ReviewIssue, len(issues)) for _, iss := range issues { found[iss.ID] = iss } // 3. 过滤出可下架的 issue(存在 + 未解决) eligible := make([]*contentreviewmod.ReviewIssue, 0, len(objIDs)) for _, id := range objIDs { iss, ok := found[id] if !ok { resp.Fail++ resp.Failures = append(resp.Failures, batchOffShelfFailure{ID: rawByID[id], Reason: "记录不存在"}) continue } if iss.ResolveStatus == contentreviewmod.ResolveStatusResolved { resp.Fail++ resp.Failures = append(resp.Failures, batchOffShelfFailure{ID: rawByID[id], Reason: "记录已解决"}) continue } eligible = append(eligible, iss) } if len(eligible) == 0 { common.ServeJSON(ctx, stderr.Success, resp) return } // 4. 按类型分组批量下架资源 if err := contentreviewser.ApplyOffShelfBatch(eligible); err != nil { log.Error("batch offshelf apply fail", log.E(err)) reason := "下架失败: " + err.Error() for _, iss := range eligible { resp.Fail++ resp.Failures = append(resp.Failures, batchOffShelfFailure{ID: rawByID[iss.ID], Reason: reason}) } common.ServeJSON(ctx, stderr.Success, resp) return } // 5. 批量标记已解决 + 按 taskID 聚合 $inc resolvedCount eligibleIDs := make([]primitive.ObjectID, 0, len(eligible)) perTask := make(map[primitive.ObjectID]int64, len(eligible)) for _, iss := range eligible { eligibleIDs = append(eligibleIDs, iss.ID) perTask[iss.TaskID]++ } if err := contentreviewmod.BatchOffShelfResolveIssues(eligibleIDs, perTask, operator); err != nil { log.Error("batch mark resolved fail", log.E(err)) reason := "标记已解决失败: " + err.Error() for _, iss := range eligible { resp.Fail++ resp.Failures = append(resp.Failures, batchOffShelfFailure{ID: rawByID[iss.ID], Reason: reason}) } common.ServeJSON(ctx, stderr.Success, resp) return } resp.Success = len(eligible) common.ServeJSON(ctx, stderr.Success, resp) } // rejectReq 搁置/驳回请求 type rejectReq struct { ID string `json:"id" binding:"required"` // 命中记录 ID Reason string `json:"reason" binding:"required"` // 搁置原因 } // RejectIssue doc // @Summary 搁置命中记录 // @Description 管理员认为暂不需要修改数据源,仅记录搁置原因;状态保持未解决,后续可重新提交解决 // @Tags Web-ContentReview // @Accept json // @Produce json // @Param body body rejectReq true "搁置参数" // @Success 200 {string} json "{"msg":"操作成功"}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/content-review/issue/reject [post] func RejectIssue(ctx *gin.Context) { var req rejectReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } id, err := primitive.ObjectIDFromHex(req.ID) if err != nil { common.ServeJSON(ctx, stderr.ErrParamError, "invalid id") return } operator, _ := common.GetAdminAct(ctx) ok, err := contentreviewmod.RejectIssue(id, operator, req.Reason) if err != nil { common.ServeJSON(ctx, stderr.Failure, err.Error()) return } if !ok { common.ServeJSON(ctx, stderr.ErrParamError, "记录不存在或已解决") return } common.ServeJSON(ctx, stderr.Success, nil) } // buildSnapshot 拉取当前启用的敏感词作为任务快照 func buildSnapshot() ([]contentreviewmod.SensitiveWordSnap, error) { enabled := sensitivewordmod.StatusEnabled list, err := sensitivewordmod.FindAll(&sensitivewordmod.ListReq{Status: &enabled}) if err != nil { return nil, err } out := make([]contentreviewmod.SensitiveWordSnap, 0, len(list)) for _, w := range list { out = append(out, contentreviewmod.SensitiveWordSnap{ Word: w.Word, Category: w.Category, }) } return out, nil }