451 lines
17 KiB
Go
451 lines
17 KiB
Go
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 字段已通过 <mark>...</mark> 标红;支持按解决状态筛选
|
||
// @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
|
||
}
|