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,55 @@
package vidser
import (
"reflect"
"testing"
"go.mongodb.org/mongo-driver/bson"
)
func TestApplyLiaoBaTopSortAllowsDuplicateAndLargeValues(t *testing.T) {
existingVideo := bson.M{"liaoBaTopSort": 1000}
targetUpdate := bson.M{"title": "unchanged"}
sortCode := 1000
applyLiaoBaTopSort(targetUpdate, &sortCode)
if got := targetUpdate["liaoBaTopSort"]; got != sortCode {
t.Fatalf("liaoBaTopSort = %v, want %d", got, sortCode)
}
if got := targetUpdate["liaoBaTop"]; got != true {
t.Fatalf("liaoBaTop = %v, want true", got)
}
if got := targetUpdate["title"]; got != "unchanged" {
t.Fatalf("unrelated field changed: %v", got)
}
if got := existingVideo["liaoBaTopSort"]; got != sortCode {
t.Fatalf("existing video sort changed: %v", got)
}
}
func TestApplyLiaoBaTopSortZeroUnpinsCurrentVideo(t *testing.T) {
set := bson.M{}
sortCode := 0
applyLiaoBaTopSort(set, &sortCode)
if got := set["liaoBaTopSort"]; got != 0 {
t.Fatalf("liaoBaTopSort = %v, want 0", got)
}
if got := set["liaoBaTop"]; got != false {
t.Fatalf("liaoBaTop = %v, want false", got)
}
}
func TestLiaoBaTopSortModeListHasStableTieBreakers(t *testing.T) {
want := SortModeList{
{Field: "liaoBaTopSort", Value: -1},
{Field: "reviewAt", Value: -1},
{Field: "_id", Value: -1},
}
if got := LiaoBaTopSortModeList(); !reflect.DeepEqual(got, want) {
t.Fatalf("sort = %#v, want %#v", got, want)
}
}
+260
View File
@@ -0,0 +1,260 @@
package vidser
import (
"91porn-server/app/service/modulevideoser"
"91porn-server/models/v/modulevidmod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/vidtimeonlinemod"
"context"
"fmt"
"math/rand"
"net/url"
"strconv"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
"91porn-server/common"
"91porn-server/common/constant"
"91porn-server/common/log"
"91porn-server/common/mdrsdb"
"91porn-server/models/v/vidmod"
"github.com/shopspring/decimal"
)
func getMediaPathFromUrl(rawUrl string) string {
url, err := url.Parse(rawUrl)
if err != nil || url.Path == "" {
return ""
}
return url.Path
}
func getMediaFileInfoFromUrl(rawUrl string) (sourceID, fileName string) {
url, err := url.Parse(rawUrl)
if err != nil {
return
}
path := url.Path
sliX := strings.Split(path, "/")
if len(sliX) > 0 {
fileName = sliX[len(sliX)-1]
}
sliP := strings.Split(fileName, ".")
if len(sliP) > 0 {
sourceID = sliP[0]
}
return
}
func getIMGPathFromUrl(rawUrl string) string {
//新桶,无需加ims,可直接回源
if strings.Contains(rawUrl, "cf230705") || strings.Contains(rawUrl, "bf211222") {
return rawUrl
}
if rawUrl == "" {
return ""
}
url, err := url.Parse(rawUrl)
if err != nil {
return ""
}
return common.BindUrl(constant.ImageSourceIMS, url.Path)
}
func GetMediaList(ctx context.Context, page, size, vloggerUserId int64, title, tagText, hashId string, vMia, videoType int, syncType, terminalCdo string, startTime, endTime time.Time, sortType, maxTotal int) (data []mdrsdb.MediaResource, count int64, err error) {
data, count, err = mdrsdb.FindMediaList(ctx, page, size, vloggerUserId, title, tagText, hashId, vMia, videoType, syncType, terminalCdo, startTime, endTime, sortType, maxTotal)
for i := range data {
data[i].CoverImage = getIMGPathFromUrl(data[i].CoverImage)
data[i].VerticalCover = getIMGPathFromUrl(data[i].VerticalCover)
data[i].Src1080 = getMediaPathFromUrl(data[i].Src1080)
data[i].Src720 = getMediaPathFromUrl(data[i].Src720)
data[i].Src480 = getMediaPathFromUrl(data[i].Src480)
data[i].VerticalCover = getIMGPathFromUrl(data[i].VerticalCover)
tags := strings.Split(data[i].TagsText, ",")
if len(tags) > 5 {
tags = tags[:5]
}
data[i].TagList = tags
}
return
}
func UseMedias(ctx context.Context, ids []int64, authorID uint64, sectionID primitive.ObjectID, inTags []string, videoType int, manager string, coins *int64, onlineTime time.Time) ([]SensitiveHit, error) {
mbs, err := mdrsdb.FindMediaListWithIDs(ctx, ids, videoType)
if err != nil {
return nil, err
}
terms := LoadEnabledSensitiveTerms()
hits := make([]SensitiveHit, 0)
useIds := make([]int64, 0, len(mbs))
for i, v := range mbs {
log.Info(fmt.Sprintf("Synchronize media library video information:video_id:%d up_id:%d authorId:%d", v.Id, v.VloggerUserId, authorID))
mbs[i].CoverImage = getIMGPathFromUrl(mbs[i].CoverImage)
mbs[i].VerticalCover = getIMGPathFromUrl(mbs[i].VerticalCover)
mbs[i].Src1080 = getMediaPathFromUrl(mbs[i].Src1080)
mbs[i].Src720 = getMediaPathFromUrl(mbs[i].Src720)
mbs[i].Src480 = getMediaPathFromUrl(mbs[i].Src480)
mbs[i].VerticalCover = getIMGPathFromUrl(mbs[i].VerticalCover)
var m3u8 string
var m3u8Size int64
if strings.Contains(mbs[i].Src720, "/") {
m3u8 = mbs[i].Src720
m3u8Size = mbs[i].FileSize720
}
if m3u8 == "" {
m3u8 = mbs[i].Src480
m3u8Size = mbs[i].FileSize480
}
if m3u8 == "" {
m3u8 = mbs[i].Src1080
m3u8Size = mbs[i].FileSize1080
}
tagsList := strings.Split(mbs[i].TagsText, ",")
tags := []string{}
for _, tag := range tagsList {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
if len(tags) > 5 {
tags = tags[:5]
}
tags = append(tags, inTags...)
if authorID == 0 && v.VloggerUserId != 0 {
authorID = v.VloggerUserId
} else if authorID == 0 {
authorID = 100001
}
author, _ := usermod.FindUserByUID(authorID)
if author == nil {
authorID = uint64(rand.Int63n(50000) + 100001)
}
var pics []string
if len(v.Pics) > 0 {
pics = strings.Split(v.Pics, ",")
for i, p := range pics {
pics[i] = getIMGPathFromUrl(p)
}
}
// videoType : 0 长视频 1短视频 2图文
// v.MediaType: 0视频 1 图文
vt := vidmod.SP
if videoType == 1 {
vt = vidmod.SHORT
} else if videoType == 2 && v.MediaType == 1 {
vt = vidmod.COVER
}
var ratio float64 = 0
if v.Height > 0 && v.Width > 0 {
ratio = float64(decimal.NewFromInt(int64(v.Width)).Div(decimal.NewFromInt(int64(v.Height))).Shift(2).IntPart()) / 100
}
temp := vidmod.WebSubmitReq{
UID: authorID,
NewsType: vt,
Title: v.Title,
Content: v.Content,
Tags: tags,
PlayTime: v.Length,
Cover: mbs[i].CoverImage,
VerticalCover: mbs[i].VerticalCover,
SeriesCover: pics,
Via: mbs[i].TerminalCdo,
Coins: mbs[i].SaleCoin,
Size: int(m3u8Size),
Resolution: strconv.Itoa(v.Width) + "*" + strconv.Itoa(v.Height),
Ratio: ratio,
Width: v.Width,
Height: v.Height,
MimeType: "",
Actor: "",
SourceURL: m3u8,
MD5: v.HashId,
SourceID: strconv.FormatInt(v.Id, 10),
MDSID: v.HashId,
Account: manager,
RichText: v.RichText,
PreviewStart: v.PreviewStart,
}
if coins != nil {
temp.Coins = *coins
}
if len(tags) > 0 {
if tags[0] != "" {
temp.Status = 1
}
}
if m3u8 != "" {
temp.SourceID, temp.Filename = getMediaFileInfoFromUrl(m3u8)
if temp.PlayTime > 60 {
temp.FreeTime = 15
} else {
temp.FreeTime = int(temp.PlayTime / 4)
}
}
if authorID > 0 {
temp.UID = authorID
}
if !onlineTime.IsZero() {
temp.Status = 0
}
if temp.NewsType == vidmod.COVER {
temp.Status = 0
}
if !onlineTime.IsZero() {
temp.Status = vidmod.OnlinePass
}
// 敏感词命中:强制为待审核,且不进入自动上架/专题上架链路
hit := CheckTextHits(terms, temp.SourceID, temp.Title, temp.Content, temp.RichText)
if hit != nil {
temp.Status = vidmod.WaitingCheck
log.Warn("vidser.UseMedias sensitive hit, forced offline & skip auto-online",
log.Any("sourceId", hit.SourceID),
log.Any("title", hit.Title),
log.Any("detail", hit.Detail))
hits = append(hits, *hit)
}
code, vid := SubmitBase(temp)
if code != 200 {
log.ErrorX(ctx, "vidser UseMedias is fail", log.Any("code", code), log.Any("vid", vid))
continue
}
if !onlineTime.IsZero() && hit == nil {
// 加入批量自动上架(命中过的资源不进入此路径)
BatchAuditVidTimeOnlineInfo(vidtimeonlinemod.BatchAuditReq{
Vids: []primitive.ObjectID{vid},
SectionID: sectionID,
OnlineTime: onlineTime,
}, int(temp.Coins), manager)
} else if !sectionID.IsZero() && temp.Status == 1 {
// 加入专题
sv := modulevidmod.SectionVideo{
SectionID: sectionID,
VideoID: vid,
VideoReviewedAt: time.Now(),
SortCode: 50,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_ = modulevideoser.AddVideo(&sv)
}
useIds = append(useIds, v.Id)
}
if len(useIds) > 0 {
if err = mdrsdb.UseMedia(ctx, useIds, videoType); err != nil {
return hits, err
}
}
return hits, nil
}
func IgnoreMedias(ctx context.Context, ids []int64, videoType int) (err error) {
return mdrsdb.IgnoreMedia(ctx, ids, videoType)
}
@@ -0,0 +1,27 @@
package vidser
import (
"testing"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestApplyScheduledVideoModuleID(t *testing.T) {
moduleID := primitive.NewObjectID()
update := bson.M{}
applyScheduledVideoModuleID(update, moduleID)
if got := update["mId"]; got != moduleID.Hex() {
t.Fatalf("mId = %v, want %s", got, moduleID.Hex())
}
}
func TestApplyScheduledVideoModuleIDIgnoresEmptyID(t *testing.T) {
update := bson.M{}
applyScheduledVideoModuleID(update, primitive.NilObjectID)
if _, ok := update["mId"]; ok {
t.Fatal("empty module id must not be written")
}
}
+170
View File
@@ -0,0 +1,170 @@
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, " "))
}
@@ -0,0 +1,27 @@
package vidser
import (
"sort"
"91porn-server/models/v/moduleconfmod"
"go.mongodb.org/mongo-driver/bson"
)
var excludedTagSearchModuleIDs = moduleconfmod.ExcludedSearchModuleIDs
func tagSearchCacheKey(base string, excludedModuleIDs []string) string {
moduleIDs := append([]string(nil), excludedModuleIDs...)
sort.Strings(moduleIDs)
for _, moduleID := range moduleIDs {
base += ":" + moduleID
}
return base
}
func applyTagSearchVisibilityFilter(filter bson.M, excludedModuleIDs []string) {
if len(excludedModuleIDs) == 0 {
return
}
filter["mId"] = bson.M{"$nin": excludedModuleIDs}
}
@@ -0,0 +1,34 @@
package vidser
import (
"reflect"
"testing"
"go.mongodb.org/mongo-driver/bson"
)
func TestTagSearchCacheKeyTracksExcludedModules(t *testing.T) {
base := "tag-videos"
first := tagSearchCacheKey(base, []string{"module-b", "module-a"})
second := tagSearchCacheKey(base, []string{"module-a", "module-b"})
if first != second {
t.Fatalf("cache keys differ for the same module set: %q != %q", first, second)
}
if first == tagSearchCacheKey(base, []string{"module-a"}) {
t.Fatal("cache key did not change when the excluded module set changed")
}
}
func TestApplyTagSearchVisibilityFilter(t *testing.T) {
filter := bson.M{"status": 1}
excluded := []string{"module-a", "module-b"}
applyTagSearchVisibilityFilter(filter, excluded)
want := bson.M{
"status": 1,
"mId": bson.M{"$nin": excluded},
}
if !reflect.DeepEqual(filter, want) {
t.Fatalf("filter = %#v, want %#v", filter, want)
}
}
+19
View File
@@ -0,0 +1,19 @@
package vidser
type SortMode struct {
Field string
Value int
}
type SortModeList []SortMode
// LiaoBaTopSortModeList returns the deterministic order used by pinned-video
// lists. Duplicate liaoBaTopSort values are allowed, so reviewAt and _id are
// used as stable tie-breakers.
func LiaoBaTopSortModeList() SortModeList {
return SortModeList{
{Field: "liaoBaTopSort", Value: -1},
{Field: "reviewAt", Value: -1},
{Field: "_id", Value: -1},
}
}
File diff suppressed because it is too large Load Diff