Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

451 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=5ACG_MEDIA→Media.status=0ACG_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
}