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 }