Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
@@ -0,0 +1,450 @@
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
}