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
+377
View File
@@ -0,0 +1,377 @@
package contentreviewmod
import (
"fmt"
"time"
"91porn-server/common/db"
"91porn-server/common/log"
"91porn-server/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var (
mdbTask *db.MongoDB
mdbIssue *db.MongoDB
)
const (
taskTable = models.ContentReviewTask
issueTable = models.ContentReviewIssue
)
func collTask(t *db.MongoTool) *db.MongoTool {
if t == nil {
return mdbTask.Coll(taskTable)
}
return t.Coll(taskTable)
}
func collIssue(t *db.MongoTool) *db.MongoTool {
if t == nil {
return mdbIssue.Coll(issueTable)
}
return t.Coll(issueTable)
}
func Init() {
mdbTask = db.Init(taskTable)
mdbIssue = db.Init(issueTable)
initIndex()
}
func initIndex() {
taskIdx := []mongo.IndexModel{
{Keys: bson.D{{Key: "status", Value: 1}}},
{Keys: bson.D{{Key: "createdAt", Value: -1}}},
}
if _, err := collTask(nil).CreateIndex(taskIdx); err != nil {
panic(fmt.Sprintf("%s set index err: %+v", taskTable, err))
}
issueIdx := []mongo.IndexModel{
{Keys: bson.D{{Key: "taskId", Value: 1}, {Key: "createdAt", Value: 1}}},
{Keys: bson.D{{Key: "targetId", Value: 1}}},
}
if _, err := collIssue(nil).CreateIndex(issueIdx); err != nil {
panic(fmt.Sprintf("%s set index err: %+v", issueTable, err))
}
}
// CreateTask 新建任务
func CreateTask(t *ReviewTask) error {
now := time.Now()
t.CreatedAt = now
t.Status = TaskStatusPending
if _, err := collTask(nil).InsertOne(t); err != nil {
log.Error(fmt.Sprintf("[contentreviewmod] CreateTask fail: %+v", err))
return err
}
return nil
}
// GetTaskByID 查询任务
func GetTaskByID(id primitive.ObjectID) (*ReviewTask, error) {
var t ReviewTask
if err := collTask(nil).FindOneByID(&t, id); err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
return &t, nil
}
// HeartbeatTimeout 心跳超时阈值:超过该时间未更新心跳的 running 任务视为僵尸任务(进程崩溃后留下的)
const HeartbeatTimeout = 3 * time.Minute
// PickNextTask 取最早的一个待处理任务:pending OR 心跳超时的 running
// skd 单进程下足够;调用方需保证进程内串行执行
func PickNextTask() (*ReviewTask, error) {
threshold := time.Now().Add(-HeartbeatTimeout)
cond := bson.M{
"$or": []bson.M{
{"status": TaskStatusPending},
{"status": TaskStatusRunning, "heartbeatAt": bson.M{"$lt": threshold}},
},
}
var t ReviewTask
opts := options.FindOne().SetSort(bson.D{{Key: "createdAt", Value: 1}})
if err := collTask(nil).FindOne(&t, cond, opts); err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
return &t, nil
}
// AcquireTask 标记任务进入运行态并刷心跳
// 首次执行(pending)时设置 startedAt;续跑(running 心跳超时)保留原 startedAt
func AcquireTask(id primitive.ObjectID, isResume bool) error {
now := time.Now()
set := bson.M{
"status": TaskStatusRunning,
"heartbeatAt": now,
}
if !isResume {
set["startedAt"] = now
}
_, err := collTask(nil).UpdateOne(bson.M{"_id": id}, bson.M{"$set": set})
return err
}
// UpdateProgress 更新进度(批处理过程中调用),同时刷心跳和游标
func UpdateProgress(id, lastTargetID primitive.ObjectID, checked, issues int64, total int64) error {
set := bson.M{
"checkedCount": checked,
"issueCount": issues,
"heartbeatAt": time.Now(),
}
if !lastTargetID.IsZero() {
set["lastTargetId"] = lastTargetID
}
if total >= 0 {
set["totalCount"] = total
}
_, err := collTask(nil).UpdateOne(bson.M{"_id": id}, bson.M{"$set": set})
return err
}
// MarkFinished 标记完成
func MarkFinished(id primitive.ObjectID, checked, issues int64) error {
_, err := collTask(nil).UpdateOne(bson.M{"_id": id}, bson.M{"$set": bson.M{
"status": TaskStatusFinished,
"checkedCount": checked,
"issueCount": issues,
"finishedAt": time.Now(),
}})
return err
}
// MarkFailed 标记失败
func MarkFailed(id primitive.ObjectID, errMsg string) error {
_, err := collTask(nil).UpdateOne(bson.M{"_id": id}, bson.M{"$set": bson.M{
"status": TaskStatusFailed,
"errMsg": errMsg,
"finishedAt": time.Now(),
}})
return err
}
// MarkTgSent 标记 TG 发送成功
func MarkTgSent(id primitive.ObjectID) error {
_, err := collTask(nil).UpdateOne(bson.M{"_id": id}, bson.M{"$set": bson.M{"tgSent": true}})
return err
}
// ListTasks 任务分页
func ListTasks(page, size int64, taskType string, status *int) ([]*ReviewTask, int64, error) {
cond := bson.M{}
if taskType != "" {
cond["taskType"] = taskType
}
if status != nil {
cond["status"] = *status
}
total, err := collTask(nil).Count(cond)
if err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
opts := options.FindOptions{}
opts.SetSort(bson.D{{Key: "createdAt", Value: -1}}).
SetSkip((page - 1) * size).
SetLimit(size)
var list []*ReviewTask
if err = collTask(nil).Find(&list, cond, &opts); err != nil {
return nil, total, err
}
return list, total, nil
}
// InsertIssues 批量写入命中记录
// 注意:底层 db.InsertMany 通过反射访问元素字段,要求 slice 元素必须是 struct 值
// 而非指针,否则 FieldByName 会 panic;这里把 []*ReviewIssue 拷贝为 []ReviewIssue
func InsertIssues(list []*ReviewIssue) error {
if len(list) == 0 {
return nil
}
now := time.Now()
flat := make([]ReviewIssue, len(list))
for i, p := range list {
p.CreatedAt = now
flat[i] = *p
}
if _, err := collIssue(nil).InsertMany(&flat); err != nil {
log.Error(fmt.Sprintf("[contentreviewmod] InsertIssues fail: %+v", err))
return err
}
return nil
}
// incTaskResolved 原子累加任务的已解决命中计数
func incTaskResolved(taskID primitive.ObjectID, delta int64) error {
_, err := collTask(nil).UpdateOne(bson.M{"_id": taskID}, bson.M{"$inc": bson.M{"resolvedCount": delta}})
return err
}
// CountUnresolvedIssues 实时统计某任务下未解决(resolveStatus != 已解决)的记录数。
// 列表页已改用 IssueCount-ResolvedCount 冗余差值,本函数保留用于计数重算/对账修复。
func CountUnresolvedIssues(taskID primitive.ObjectID) (int64, error) {
cond := bson.M{
"taskId": taskID,
"resolveStatus": bson.M{"$ne": ResolveStatusResolved},
}
return collIssue(nil).Count(cond)
}
// GetIssueByID 按 ID 查命中记录
func GetIssueByID(id primitive.ObjectID) (*ReviewIssue, error) {
var i ReviewIssue
if err := collIssue(nil).FindOneByID(&i, id); err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
return &i, nil
}
// GetIssuesByIDs 批量按 ID 拉取命中记录;缺失/重复 ID 不会出现在返回中
func GetIssuesByIDs(ids []primitive.ObjectID) ([]*ReviewIssue, error) {
if len(ids) == 0 {
return nil, nil
}
var list []*ReviewIssue
if err := collIssue(nil).Find(&list, bson.M{"_id": bson.M{"$in": ids}}); err != nil {
return nil, err
}
return list, nil
}
// BatchOffShelfResolveIssues 批量把命中切到已解决(resolveAction=offshelf),
// 调用方应先成功执行批量下架动作。
// 仅 resolveStatus ∈ {Pending, Rejected} 的会被改动;perTaskResolved 是按 TaskID
// 聚合的本批已解决条数,本函数原子地为每个任务 $inc resolvedCount。
func BatchOffShelfResolveIssues(ids []primitive.ObjectID, perTaskResolved map[primitive.ObjectID]int64, operator string) error {
if len(ids) == 0 {
return nil
}
now := time.Now()
filter := bson.M{
"_id": bson.M{"$in": ids},
"resolveStatus": bson.M{"$in": []int{
ResolveStatusPending,
ResolveStatusRejected,
}},
}
update := bson.M{"$set": bson.M{
"resolveStatus": ResolveStatusResolved,
"resolveAction": ResolveActionOffShelf,
"submittedAt": now,
"submittedBy": operator,
"resolvedAt": now,
}}
if _, err := collIssue(nil).UpdateMany(filter, update); err != nil {
return err
}
for taskID, n := range perTaskResolved {
if n == 0 {
continue
}
if err := incTaskResolved(taskID, n); err != nil {
log.Warn(fmt.Sprintf("[contentreviewmod] inc resolvedCount fail: taskId=%s delta=%d err=%+v", taskID.Hex(), n, err))
}
}
return nil
}
// ResolveIssue 管理员提交修改并直接标记已解决
// 允许从未解决(0)或已搁置(1)切到已解决(2);调用方应先成功写回数据源
// 状态确实发生转换(ModifiedCount>0)时,原子累加所属任务的已解决计数
func ResolveIssue(id, taskID primitive.ObjectID, submittedTitle, submittedContent, submittedRichText, operator string) (bool, error) {
now := time.Now()
filter := bson.M{
"_id": id,
"resolveStatus": bson.M{"$in": []int{
ResolveStatusPending,
ResolveStatusRejected,
}},
}
update := bson.M{"$set": bson.M{
"resolveStatus": ResolveStatusResolved,
"resolveAction": ResolveActionText,
"submittedTitle": submittedTitle,
"submittedContent": submittedContent,
"submittedRichText": submittedRichText,
"submittedAt": now,
"submittedBy": operator,
"resolvedAt": now,
}}
res, err := collIssue(nil).UpdateOne(filter, update)
if err != nil {
return false, err
}
if res.ModifiedCount > 0 {
if err := incTaskResolved(taskID, 1); err != nil {
log.Warn(fmt.Sprintf("[contentreviewmod] inc resolvedCount fail: taskId=%s err=%+v", taskID.Hex(), err))
}
}
return res.ModifiedCount > 0, nil
}
// RejectIssue 搁置:记录原因并把状态切到 已搁置(1)
// 只在当前状态为 未解决(0)时允许;reason 必填便于追溯
func RejectIssue(id primitive.ObjectID, operator, reason string) (bool, error) {
filter := bson.M{
"_id": id,
"resolveStatus": ResolveStatusPending,
}
update := bson.M{"$set": bson.M{
"resolveStatus": ResolveStatusRejected,
"reviewedAt": time.Now(),
"reviewedBy": operator,
"rejectReason": reason,
}}
res, err := collIssue(nil).UpdateOne(filter, update)
if err != nil {
return false, err
}
return res.ModifiedCount > 0, nil
}
// ListIssues 命中记录分页;resolveStatus 传 nil 表示全部
func ListIssues(taskID primitive.ObjectID, page, size int64, resolveStatus *int) ([]*ReviewIssue, int64, error) {
cond := bson.M{"taskId": taskID}
if resolveStatus != nil {
cond["resolveStatus"] = *resolveStatus
}
total, err := collIssue(nil).Count(cond)
if err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
opts := options.FindOptions{}
opts.SetSort(bson.D{{Key: "createdAt", Value: 1}}).
SetSkip((page - 1) * size).
SetLimit(size)
var list []*ReviewIssue
if err = collIssue(nil).Find(&list, cond, &opts); err != nil {
return nil, total, err
}
return list, total, nil
}
+121
View File
@@ -0,0 +1,121 @@
package contentreviewmod
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TaskType 任务类型常量
const (
TaskTypeVideo = "VIDEO" // 视频帖子(扫描 VideoModel 表)
TaskTypeACG = "ACG" // ACG 漫画/小说(扫描 Media + MediaContent 表)
)
// TaskStatus 任务状态机
//
// 0 待执行 → 1 执行中 → 2 已完成
// ↓
// 3 失败(panic 或异常)
//
// 心跳超时的 1 执行中 任务,下次 cron tick 会被自动接管续跑
const (
TaskStatusPending = 0 // 待执行:刚创建,等待 cron 调度
TaskStatusRunning = 1 // 执行中:cron 已挑出并正在扫描
TaskStatusFinished = 2 // 已完成:扫描完所有目标
TaskStatusFailed = 3 // 失败:执行中遇到不可恢复错误
)
// IssueTargetType 命中记录关联的目标对象类型(字符串枚举)
type IssueTargetType string
const (
IssueTargetVideo IssueTargetType = "VIDEO" // 命中视频帖子(VideoModel
IssueTargetACGMedia IssueTargetType = "ACG_MEDIA" // 命中 ACG 的 Media 自身字段
IssueTargetACGContent IssueTargetType = "ACG_CONTENT" // 命中 ACG 的 MediaContent 章节
)
// ResolveStatus 命中记录的解决状态机
//
// 0 未解决 ─── Resolve(提交修改+写回数据源) ─────────→ 2 已解决
// │ ↑
// └── Reject(搁置,记录原因) ──→ 1 已搁置 ── Resolve ────┘
//
// 未解决 / 已搁置 都视为"待解决"unresolvedCount 累计)
const (
ResolveStatusPending = 0 // 未解决:初始状态,等待管理员处理
ResolveStatusRejected = 1 // 已搁置:管理员审阅后决定暂不修改数据源,已记录搁置原因;仍可后续 Resolve
ResolveStatusResolved = 2 // 已解决:管理员提交修改并写回数据源,闭环
)
// SensitiveWordSnap 任务发起时刻对敏感词库的快照
// 防止敏感词库后续被修改影响历史任务的可追溯性
type SensitiveWordSnap struct {
Word string `json:"word" bson:"word"` // 词条
Category string `json:"category" bson:"category"` // 一级分类
}
// ReviewTask 内容审查任务记录
// 由管理员发起,skd 服务的定时调度器异步扫描;同一时刻只跑一个任务
type ReviewTask struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` // 任务 ID
TaskType string `json:"taskType" bson:"taskType"` // 任务类型:VIDEO / ACG
SnapshotWords []SensitiveWordSnap `json:"snapshotWords" bson:"snapshotWords"` // 发起时刻的敏感词快照
Status int `json:"status" bson:"status"` // 任务状态 见 TaskStatusXxx
TotalCount int64 `json:"totalCount" bson:"totalCount"` // 待扫描总数(启动时初始化)
CheckedCount int64 `json:"checkedCount" bson:"checkedCount"` // 已扫描数量
IssueCount int64 `json:"issueCount" bson:"issueCount"` // 累计命中记录数
// ResolvedCount 已解决命中数的冗余计数:每次 Resolve 成功 $inc +1。
// 待解决数 = IssueCount - ResolvedCount,列表页读时算差值,避免对每个任务实时 count。
ResolvedCount int64 `json:"resolvedCount" bson:"resolvedCount"`
LastTargetID primitive.ObjectID `json:"lastTargetId" bson:"lastTargetId"` // 已扫描的最后一条目标 _id,断点续跑游标
HeartbeatAt time.Time `json:"heartbeatAt" bson:"heartbeatAt"` // 心跳:每批结束刷新,超时视为僵尸任务可被接管
TgSent bool `json:"tgSent" bson:"tgSent"` // 完成通知 TG 是否发送成功
Operator string `json:"operator" bson:"operator"` // 发起人账号
ErrMsg string `json:"errMsg" bson:"errMsg"` // 失败原因(status=3 时)
CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 任务创建时间
StartedAt time.Time `json:"startedAt" bson:"startedAt"` // 首次启动时间(续跑不重置)
FinishedAt time.Time `json:"finishedAt" bson:"finishedAt"` // 完成时间
}
// ReviewIssue 命中问题记录(一条 issue 代表一个目标对象的全部字段命中信息)
//
// VIDEO: Title/Content/RichText 字段对应 VideoModel.Title/Content/RichText
// ACG_MEDIA: Title=Media.Title, Content=Media.Summary, RichText 不使用
// ACG_CONTENT: Title=MediaContent.Name, Content=MediaContent.Text, MediaID 关联回 Media._id
//
// Title/Content/Tags/RichText 字段存放经过 <mark>...</mark> 高亮标记后的文本
type ReviewIssue struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` // 记录 ID
TaskID primitive.ObjectID `json:"taskId" bson:"taskId"` // 所属任务 ID
TargetType IssueTargetType `json:"targetType" bson:"targetType"` // 目标对象类型 见 IssueTargetXxx
TargetID primitive.ObjectID `json:"targetId" bson:"targetId"` // 目标对象 _id
MediaID primitive.ObjectID `json:"mediaId,omitempty" bson:"mediaId,omitempty"` // ACG_CONTENT 时关联的 Media._id
Title string `json:"title" bson:"title"` // 高亮后的标题(VIDEO/ACG_MEDIA.Title 或 ACG_CONTENT.Name
Content string `json:"content" bson:"content"` // 高亮后的内容(VIDEO.Content / ACG_MEDIA.Summary / ACG_CONTENT.Text
Tags string `json:"tags" bson:"tags"` // 高亮后的标签("标签1、标签2"
RichText string `json:"richText" bson:"richText"` // 高亮后的富文本(仅 VIDEO 有)
Description string `json:"description" bson:"description"` // 问题描述,形如"标题命中 词1、词2\n标签命中 词3"
HitWords []string `json:"hitWords" bson:"hitWords"` // 去重后的全部命中词
CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 记录创建时间
// —— 解决/审核流程 ——
ResolveStatus int `json:"resolveStatus" bson:"resolveStatus"` // 解决状态 见 ResolveStatusXxx
SubmittedTitle string `json:"submittedTitle" bson:"submittedTitle"` // 管理员提交修改后的标题(待审核内容)
SubmittedContent string `json:"submittedContent" bson:"submittedContent"` // 管理员提交修改后的内容
SubmittedRichText string `json:"submittedRichText" bson:"submittedRichText"` // 管理员提交修改后的富文本(仅 VIDEO)
SubmittedAt time.Time `json:"submittedAt" bson:"submittedAt"` // 最近一次提交时间
SubmittedBy string `json:"submittedBy" bson:"submittedBy"` // 最近一次提交人账号
RejectReason string `json:"rejectReason" bson:"rejectReason"` // 审核打回原因(resolveStatus 退回 0 时保留)
ReviewedAt time.Time `json:"reviewedAt" bson:"reviewedAt"` // 最近一次审核时间
ReviewedBy string `json:"reviewedBy" bson:"reviewedBy"` // 最近一次审核人账号
ResolvedAt time.Time `json:"resolvedAt" bson:"resolvedAt"` // 最终解决时间(resolveStatus=2 时)
ResolveAction string `json:"resolveAction" bson:"resolveAction"` // 解决方式:空/"text"-文本修改 "offshelf"-直接下架
}
// 解决方式取值
const (
ResolveActionText = "text" // 提交文本修改
ResolveActionOffShelf = "offshelf" // 批量下架处置
)