package vidser import ( "errors" "fmt" "strings" "91porn-server/common/log" "91porn-server/models/v/sensitivewordmod" "91porn-server/models/v/vidmod" "go.mongodb.org/mongo-driver/bson/primitive" ) // CheckShelvingFields 直接对一组文本做敏感词命中检测;用于单帖编辑场景, // 调用方负责把请求字段与 DB 现值合并后传入。 // 命中规则与 CheckShelvingByIDs 一致:Title / Content / RichText 任一命中即视为违规。 func CheckShelvingFields(id primitive.ObjectID, title, content, richText string) error { terms := loadEnabledSensitiveTerms() if len(terms) == 0 { return nil } titleHits := findSensitiveHits(title, terms) contentHits := findSensitiveHits(content, terms) richHits := findSensitiveHits(richText, terms) if len(titleHits) == 0 && len(contentHits) == 0 && len(richHits) == 0 { return nil } return errors.New(formatHitLine(id, title, titleHits, contentHits, richHits)) } // CheckShelvingByIDs 上架前敏感词命中检测。 // // 与 skd/service/contentreviewser VIDEO 任务的扫描规则保持一致: // 对每条视频的 Title / Content / RichText 三个字段做 substring 命中检测, // 任一字段命中启用状态的敏感词词条即视为违规,不予上架。 // // 返回值: // - nil —— 全部通过或敏感词库为空 // - non-nil err —— 至少一条命中,err.Error() 已按视频聚合 // // 多条命中时,每条视频独占一行,行内字段按"标题命中 / 内容命中 / 富文本命中"顺序拼接。 func CheckShelvingByIDs(ids []primitive.ObjectID) error { if len(ids) == 0 { return nil } terms := loadEnabledSensitiveTerms() if len(terms) == 0 { return nil } videos, err := vidmod.GetVideoListByIDsNoStatus(ids) if err != nil { log.Error("vidser.CheckShelvingByIDs query videos fail", log.Any("ids", ids), log.E(err)) return errors.New("查询视频信息失败") } lines := make([]string, 0) for _, v := range videos { titleHits := findSensitiveHits(v.Title, terms) contentHits := findSensitiveHits(v.Content, terms) richHits := findSensitiveHits(v.RichText, terms) if len(titleHits) == 0 && len(contentHits) == 0 && len(richHits) == 0 { continue } lines = append(lines, formatHitLine(v.ID, v.Title, titleHits, contentHits, richHits)) } if len(lines) == 0 { return nil } return errors.New(strings.Join(lines, "\n")) } // LoadEnabledSensitiveTerms 启用状态敏感词词条加载(代理 sensitivewordmod.LoadEnabledTerms) // 保留此处签名以兼容现有 laosijiser / tianyuctrl 等调用方 func LoadEnabledSensitiveTerms() []string { return sensitivewordmod.LoadEnabledTerms() } // loadEnabledSensitiveTerms 内部短名别名 func loadEnabledSensitiveTerms() []string { return sensitivewordmod.LoadEnabledTerms() } // SensitiveHit 单条命中结果,用于批量同步接口(老司机/天宇媒资库)返回给前端 type SensitiveHit struct { SourceID string `json:"sourceId"` // 来源 id(如 laosiji 资源 id / tianyu fileId) Title string `json:"title"` // 标题 Detail string `json:"detail"` // "标题命中 XX 内容命中 YY 富文本命中 ZZ" } // CheckTextHits 对一组文本做敏感词命中检测,命中返回 *SensitiveHit,未命中返回 nil。 // 与 skd 内容审查任务的匹配规则保持一致:任一字段命中即视为命中。 // 调用方在入库前调用,命中后应将记录的 status 强制为"未上架"语义的值。 // // 词条由外部传入,便于批量场景一次加载、多次匹配;传 nil 或空切片视为词库为空,直接返回 nil。 func CheckTextHits(terms []string, sourceID, title, content, richText string) *SensitiveHit { if len(terms) == 0 { return nil } titleHits := findSensitiveHits(title, terms) contentHits := findSensitiveHits(content, terms) richHits := findSensitiveHits(richText, terms) if len(titleHits) == 0 && len(contentHits) == 0 && len(richHits) == 0 { return nil } parts := make([]string, 0, 3) if len(titleHits) > 0 { parts = append(parts, "标题命中 "+strings.Join(titleHits, "、")) } if len(contentHits) > 0 { parts = append(parts, "内容命中 "+strings.Join(contentHits, "、")) } if len(richHits) > 0 { parts = append(parts, "富文本命中 "+strings.Join(richHits, "、")) } return &SensitiveHit{ SourceID: sourceID, Title: title, Detail: strings.Join(parts, " "), } } // SensitiveForcedOfflineTip 写库后给管理员的统一提示文案 const SensitiveForcedOfflineTip = "由于部分数据内容命中敏感词,状态强制为下架" // findSensitiveHits substring 命中检测的内部别名,代理 sensitivewordmod.MatchHits func findSensitiveHits(input string, terms []string) []string { return sensitivewordmod.MatchHits(input, terms) } // shouldCheckSensitiveOnUpdate 判断单帖编辑 UpdateVidInfo 是否需要做敏感词校验 // // 1. status 被切到 上架(CheckPass=1) / 上架并免费(Free=3) —— 上架转换 // 2. 当前已是上架态(1/3) 且本次更新涉及 Title/Content/RichText —— 已上架内容改动 func shouldCheckSensitiveOnUpdate(vidInfo vidmod.VideoModel, req vidmod.EditReq) bool { if req.Status != nil && (*req.Status == vidmod.CheckPass || *req.Status == vidmod.Free) { return true } isCurrentlyShelved := vidInfo.Status == vidmod.CheckPass || vidInfo.Status == vidmod.Free touchesText := req.Title != nil || req.Content != nil || req.RichText != nil return isCurrentlyShelved && touchesText } // parseObjectIDs 把十六进制 id 字符串数组转 ObjectID,非法 id 静默丢弃 func parseObjectIDs(ids []string) []primitive.ObjectID { out := make([]primitive.ObjectID, 0, len(ids)) for _, s := range ids { oid, err := primitive.ObjectIDFromHex(s) if err != nil { continue } out = append(out, oid) } return out } // formatHitLine 按需求格式拼接单条命中描述: // "id:xxxx 对象标题 XXXXXX。 标题命中 XX 内容命中 XX 富文本命中 XX,不予上架成功" func formatHitLine(id primitive.ObjectID, title string, titleHits, contentHits, richHits []string) string { parts := make([]string, 0, 3) if len(titleHits) > 0 { parts = append(parts, "标题命中 "+strings.Join(titleHits, "、")) } if len(contentHits) > 0 { parts = append(parts, "内容命中 "+strings.Join(contentHits, "、")) } if len(richHits) > 0 { parts = append(parts, "富文本命中 "+strings.Join(richHits, "、")) } return fmt.Sprintf("id:%s 对象标题 %s。 %s,不予上架成功", id.Hex(), title, strings.Join(parts, " ")) }