package srv_mediaresource import ( "context" "math/rand" "regexp" "strings" "time" "videoweb/pub/common/log" "videoweb/pub/common/srvdef" "videoweb/pub/common/utils" "videoweb/pub/models/mwacc" "videoweb/pub/models/playletchaptermod" "videoweb/pub/models/playletmod" "videoweb/pub/models/playlettagmod" "videoweb/web/api/proto" "videoweb/web/middleware/auth" "videoweb/web/webg" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) const ( defaultResourceSyncType = 1 defaultResourcePageNum = 1 defaultResourcePageSize = 20 maxResourcePageSize = 100 maxResourceImportIds = 100 resourceAddStatusScanMaxPages = 20 resourceAddedIDBatchSize = 100 playletResourceImportMsg = "同步任务已提交,后台正在处理" playletHeatMinValue = 1000 playletHeatMaxValue = 5000 playletVirtualLikeMinValue = 10 playletVirtualLikeMaxValue = 20 playletResourceMediaTypeAnime = 1 // 动漫 playletResourceMediaTypeManga = 2 // 漫画 playletResourceMediaTypeComic = 4 // 漫画短剧 playletResourceMediaTypeShort = 5 // 短剧 playletResourceMediaTypeNormal = 6 // 普通短剧 ) // ListPlayletResource 分页查询短剧媒资列表 func ListPlayletResource(ctx context.Context, req *proto.PlayletResourceListReq) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } syncType := defaultResourceSyncType if req.SyncType != nil { syncType = *req.SyncType } pageNumber := req.PageNumber if pageNumber <= 0 { pageNumber = defaultResourcePageNum } pageSize := req.PageSize if pageSize <= 0 { pageSize = defaultResourcePageSize } if pageSize > maxResourcePageSize { pageSize = maxResourcePageSize } mediaType, srvErr := resolvePlayletResourceMediaType(req.MediaType) if srvErr != nil { return nil, srvErr } // MediaCenterService 当前只保存配置值,按请求创建成本很低,也避免全局对象在配置热更新场景下读到旧配置 ms := newPlayletMediaCenterFromConfig() if req.IsAdd != nil { return listPlayletResourceByAddStatus(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) } data, err := ms.mediaList(&playletMediaListReq{ MediaType: mediaType, Title: req.Title, Tag: req.Tag, SyncType: syncType, Page: uint(pageNumber), Size: uint(pageSize), }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil { return nil, err } return &proto.PlayletResourceListResp{ List: data.Data, Total: data.Pagination.Count, }, nil } // ListPlayletJHResource 分页查询嘉华短剧媒资列表 func ListPlayletJHResource(ctx context.Context, req *proto.PlayletResourceJHListReq) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } syncType := defaultResourceSyncType if req.SyncType != nil { syncType = *req.SyncType } pageNumber := req.PageNumber if pageNumber <= 0 { pageNumber = defaultResourcePageNum } pageSize := req.PageSize if pageSize <= 0 { pageSize = defaultResourcePageSize } ms := newPlayletJHMediaCenterFromConfig() if req.IsAdd != nil { return listPlayletJHResourceByAddStatus(ctx, ms, req, syncType, pageNumber, pageSize) } data, err := ms.mediaJHList(&playletMediaListReq{ MediaType: playletResourceMediaTypeShort, Title: req.Title, Tag: req.Tag, SyncType: syncType, Page: uint(pageNumber), Size: uint(pageSize), }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if err := fillPlayletJHResourceAddStatus(ctx, data.Data); err != nil { return nil, err } return &proto.PlayletResourceJHListResp{ List: data.Data, Total: data.Pagination.Count, }, nil } // ListPlayletResourceChapters 查询短剧媒资剧集信息(复用入库时的 mediaSync) func ListPlayletResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) { if req == nil || req.Id == 0 { return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") } ms := newPlayletMediaCenterFromConfig() result, err := ms.mediaSync(&playletMediaSyncReq{ Id: req.Id, SyncTime: 0, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if result.IsDelete || result.Media == nil { return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "") } list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList)) for _, item := range result.MediaContentList { videoUrl := strings.TrimSpace(item.M3u8Url) if videoUrl == "" { videoUrl = strings.TrimSpace(item.Urls) } list = append(list, &proto.PlayletResourceChapterItem{ Id: item.ID, EpisodeNumber: item.EpisodeNumber, Title: strings.TrimSpace(item.Title), Cover: item.Cover, VideoUrl: videoUrl, Height: item.Height, Width: item.Width, MediaSize: item.MediaSize, PlayTime: item.PlayTime, SaleCoin: item.SaleCoin, HashId: item.HashId, CreateTime: item.CreateTime, UpdateTime: item.UpdateTime, }) } return &proto.PlayletResourceChapterListResp{ MediaId: req.Id, Title: result.Media.Title, List: list, Total: len(list), }, nil } // ListPlayletJHResourceChapters 查询嘉华短剧媒资剧集信息 func ListPlayletJHResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) { if req == nil || req.Id == 0 { return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") } ms := newPlayletJHMediaCenterFromConfig() result, err := ms.mediaSync(&playletMediaSyncReq{ Id: req.Id, SyncTime: 0, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if result.IsDelete || result.Media == nil { return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "") } list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList)) for _, item := range result.MediaContentList { videoUrl := strings.TrimSpace(item.M3u8Url) if videoUrl == "" { videoUrl = strings.TrimSpace(item.Urls) } list = append(list, &proto.PlayletResourceChapterItem{ Id: item.ID, EpisodeNumber: item.EpisodeNumber, Title: strings.TrimSpace(item.Title), Cover: item.Cover, VideoUrl: videoUrl, Height: item.Height, Width: item.Width, MediaSize: item.MediaSize, PlayTime: item.PlayTime, SaleCoin: item.SaleCoin, HashId: item.HashId, CreateTime: item.CreateTime, UpdateTime: item.UpdateTime, }) } return &proto.PlayletResourceChapterListResp{ MediaId: req.Id, Title: result.Media.Title, List: list, Total: len(list), }, nil } // resolvePlayletResourceMediaType 校验后台显式传入的媒资类型。 func resolvePlayletResourceMediaType(mediaType *int) (int, *srvdef.SrvErr) { if mediaType == nil { return 0, srvdef.New(srvdef.ErrParamError, "媒体类型不能为空", "媒体类型不能为空") } if !validPlayletResourceMediaType(*mediaType) { return 0, srvdef.New(srvdef.ErrParamError, "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧", "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧") } return *mediaType, nil } // validPlayletResourceMediaType 媒资列表当前允许的媒体类型。 func validPlayletResourceMediaType(mediaType int) bool { switch mediaType { case playletResourceMediaTypeAnime, playletResourceMediaTypeManga, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: return true default: return false } } // validPlayletImportMediaType 允许入库的媒资类型。 func validPlayletImportMediaType(mediaType int) bool { switch mediaType { case playletResourceMediaTypeAnime, playletResourceMediaTypeManga, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: return true default: return false } } // contentTypeFromMediaType 媒资中心 media_type 映射为本项目 contentType。 // 1-动漫 → 3;2-漫画 → 4;4-漫画短剧 → 2;5-短剧 → 0;6-普通短剧 → 1。 func contentTypeFromMediaType(mediaType int) playletmod.ContentType { switch mediaType { case playletResourceMediaTypeAnime: return playletmod.ContentTypeAnime case playletResourceMediaTypeManga: return playletmod.ContentTypeManga case playletResourceMediaTypeComic: return playletmod.ContentTypeComic case playletResourceMediaTypeNormal: return playletmod.ContentTypeAlbum default: return playletmod.ContentTypeTheater } } // fillPlayletResourceAddStatus 回填媒资列表是否已加入后台短剧库 func fillPlayletResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceListItem) *srvdef.SrvErr { mediaIds := make([]uint, 0, len(list)) seen := make(map[uint]struct{}, len(list)) for _, item := range list { // 只收集当前页有效媒资ID,空数据和无效ID不参与本地查询。 if item == nil || item.ID == 0 { continue } // 当前页可能出现重复媒资ID,去重后可以减少 Mongo $in 查询条件。 if _, ok := seen[item.ID]; ok { continue } seen[item.ID] = struct{}{} mediaIds = append(mediaIds, item.ID) } if len(mediaIds) == 0 { return nil } // 按当前页媒资ID批量查询本地短剧库,避免逐条查询影响列表性能。 filter := applyPlayletMediaSourceFilter(bson.M{ playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIds}, }, playletmod.MediaSourceHG) playletList, err := playletmod.FindManyPlaylet(ctx, filter) if err != nil { log.Error("查询短剧媒资加入状态失败", log.Any("mediaIds", mediaIds), log.Any("err", err)) return err } addedMap := make(map[uint]bool, len(playletList)) for _, item := range playletList { // mediaCenterId 是本地短剧表保存的媒资中心ID,用它判断当前媒资是否已入库。 if item == nil || item.MediaCenterId == 0 { continue } addedMap[item.MediaCenterId] = true } for _, item := range list { if item == nil { continue } // map 没命中时 bool 零值为 false,表示未加入后台短剧库。 item.IsAdd = addedMap[item.ID] } return nil } // fillPlayletJHResourceAddStatus 回填嘉华媒资列表是否已入库 func fillPlayletJHResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceJHListItem) *srvdef.SrvErr { mediaIds := make([]uint, 0, len(list)) seen := make(map[uint]struct{}, len(list)) for _, item := range list { if item == nil || item.ID == 0 { continue } if _, ok := seen[item.ID]; ok { continue } seen[item.ID] = struct{}{} mediaIds = append(mediaIds, item.ID) } if len(mediaIds) == 0 { return nil } importedMap, err := findImportedPlayletResourceMap(ctx, mediaIds, playletmod.MediaSourceDefault) if err != nil { return err } for _, item := range list { if item == nil { continue } item.IsAdd = importedMap[item.ID] } return nil } // listPlayletJHResourceByAddStatus 按是否已入库过滤嘉华媒资列表 func listPlayletJHResourceByAddStatus( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceJHListReq, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { if *req.IsAdd && !playletJHResourceRequiresUpstreamAddStatusScan(req, syncType) { return listAddedPlayletJHResourceByLocalFilter(ctx, ms, req, syncType, pageNumber, pageSize) } return scanPlayletJHResourceByAddStatus(ctx, ms, req, syncType, pageNumber, pageSize) } // playletJHResourceRequiresUpstreamAddStatusScan 判断嘉华列表是否必须扫描上游才能保证筛选结果准确。 func playletJHResourceRequiresUpstreamAddStatusScan(req *proto.PlayletResourceJHListReq, syncType int) bool { if syncType != defaultResourceSyncType { return true } return req != nil && req.Tag != nil && strings.TrimSpace(*req.Tag) != "" } // listAddedPlayletJHResourceByLocalFilter 查询已入库嘉华媒资,优先走本地playlet表分页 func listAddedPlayletJHResourceByLocalFilter( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceJHListReq, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { filter := buildAddedPlayletJHResourceFilter(req) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletResourceJHListResp{ List: make([]*proto.PlayletResourceJHListItem, 0, pageSize), Total: uint(totalCount), } if totalCount == 0 { return resp, nil } start := int64((pageNumber - 1) * pageSize) needCount := int64(pageNumber * pageSize) matchedCount := int64(0) for batchStart := int64(0); batchStart < totalCount && matchedCount < needCount; batchStart += resourceAddedIDBatchSize { list, err := queryAddedPlayletResourceIds(ctx, filter, batchStart) if err != nil { return nil, err } if len(list) == 0 { break } items, err := queryAddedPlayletJHResourceItems(ms, list, syncType) if err != nil { return nil, err } for _, item := range items { if item == nil { continue } if matchedCount >= start && len(resp.List) < pageSize { resp.List = append(resp.List, item) } matchedCount++ if matchedCount >= needCount { break } } } return resp, nil } // buildAddedPlayletJHResourceFilter 构建本地已入库嘉华媒资查询条件。 func buildAddedPlayletJHResourceFilter(req *proto.PlayletResourceJHListReq) bson.M { filter := applyPlayletMediaSourceFilter(bson.M{ playletmod.FN.Playlet.MediaCenterId: bson.M{"$gt": 0}, playletmod.FN.Playlet.ContentType: contentTypeFromMediaType(playletResourceMediaTypeShort), }, playletmod.MediaSourceDefault) if req != nil && req.Title != nil { if title := strings.TrimSpace(*req.Title); title != "" { filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} } } return filter } // queryAddedPlayletJHResourceItems 按本地短剧的媒资中心ID回查嘉华媒资数据。 func queryAddedPlayletJHResourceItems(ms *playletMediaCenterService, list playletmod.PlayletList, syncType int) ([]*proto.PlayletResourceJHListItem, *srvdef.SrvErr) { result := make([]*proto.PlayletResourceJHListItem, 0, len(list)) for _, item := range list { if item == nil || item.MediaCenterId == 0 { continue } media, err := queryPlayletJHResourceByMediaCenterId(ms, item.MediaCenterId, syncType) if err != nil { return nil, err } if media == nil { continue } media.IsAdd = true result = append(result, media) } return result, nil } // queryPlayletJHResourceByMediaCenterId 按媒资中心ID查询单条嘉华媒资列表数据。 func queryPlayletJHResourceByMediaCenterId(ms *playletMediaCenterService, mediaId uint, syncType int) (*proto.PlayletResourceJHListItem, *srvdef.SrvErr) { data, err := ms.mediaJHList(&playletMediaListReq{ MediaType: playletResourceMediaTypeShort, Id: &mediaId, SyncType: syncType, Page: 1, Size: 1, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } for _, item := range data.Data { if item != nil && item.ID == mediaId { return item, nil } } if len(data.Data) > 0 { return data.Data[0], nil } return nil, nil } // scanPlayletJHResourceByAddStatus 扫描嘉华媒资分页并按入库状态精确过滤。 func scanPlayletJHResourceByAddStatus( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceJHListReq, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { offset := (pageNumber - 1) * pageSize list := make([]*proto.PlayletResourceJHListItem, 0, pageSize) matchedCount := 0 scanPage := uint(1) scanSize := uint(maxResourcePageSize) for { data, err := ms.mediaJHList(&playletMediaListReq{ MediaType: playletResourceMediaTypeShort, Title: req.Title, Tag: req.Tag, SyncType: syncType, Page: scanPage, Size: scanSize, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if err := fillPlayletJHResourceAddStatus(ctx, data.Data); err != nil { return nil, err } for _, item := range data.Data { if item == nil || item.IsAdd != *req.IsAdd { continue } if matchedCount >= offset && len(list) < pageSize { list = append(list, item) } matchedCount++ } if data.Pagination.IsLastPage || len(data.Data) == 0 { break } if data.Pagination.Count > 0 && scanPage*scanSize >= data.Pagination.Count { break } scanPage++ } return &proto.PlayletResourceJHListResp{ List: list, Total: uint(matchedCount), }, nil } // listPlayletResourceByAddStatus 按是否已加入后台短剧库过滤媒资列表,并按过滤后的结果分页 func listPlayletResourceByAddStatus( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceListReq, mediaType int, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { if *req.IsAdd && !playletResourceRequiresUpstreamAddStatusScan(req, syncType) { return listAddedPlayletResourceByLocalFilter(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) } if !*req.IsAdd && !playletResourceRequiresUpstreamAddStatusScan(req, syncType) { return listNotAddedPlayletResourceByLocalEstimate(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) } return scanPlayletResourceByAddStatus(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) } // listAddedPlayletResourceByLocalFilter 查询已加入后台短剧库的媒资,优先走本地playlet表分页 func listAddedPlayletResourceByLocalFilter( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceListReq, mediaType int, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { filter := buildAddedPlayletResourceFilter(req, mediaType) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletResourceListResp{ List: make([]*proto.PlayletResourceListItem, 0, pageSize), Total: uint(totalCount), } if totalCount == 0 { return resp, nil } start := int64((pageNumber - 1) * pageSize) needCount := int64(pageNumber * pageSize) matchedCount := int64(0) for batchStart := int64(0); batchStart < totalCount && matchedCount < needCount; batchStart += resourceAddedIDBatchSize { list, err := queryAddedPlayletResourceIds(ctx, filter, batchStart) if err != nil { return nil, err } if len(list) == 0 { break } items, err := queryAddedPlayletResourceItems(ms, list, mediaType, syncType) if err != nil { return nil, err } for _, item := range items { if item == nil { continue } if matchedCount >= start && len(resp.List) < pageSize { resp.List = append(resp.List, item) } matchedCount++ if matchedCount >= needCount { break } } } return resp, nil } // listNotAddedPlayletResourceByLocalEstimate 查询未加入媒资,total按上游总数减本地已加入数估算。 func listNotAddedPlayletResourceByLocalEstimate( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceListReq, mediaType int, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { scanSize := uint(maxResourcePageSize) firstPage, err := ms.mediaList(&playletMediaListReq{ MediaType: mediaType, Title: req.Title, SyncType: syncType, Page: 1, Size: scanSize, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } addedTotal, countErr := playletmod.CountPlaylet(ctx, buildAddedPlayletResourceFilter(req, mediaType)) if countErr != nil { return nil, countErr } total := int64(firstPage.Pagination.Count) - addedTotal if total < 0 { total = 0 } resp := &proto.PlayletResourceListResp{ List: make([]*proto.PlayletResourceListItem, 0, pageSize), Total: uint(total), } start := (pageNumber - 1) * pageSize needCount := start + pageSize matchedCount := 0 lastPage := calcPlayletResourceLastPage(firstPage.Pagination.Count, scanSize) scanLimit := lastPage if scanLimit > resourceAddStatusScanMaxPages { scanLimit = resourceAddStatusScanMaxPages } for scanPage := uint(1); scanPage <= scanLimit; scanPage++ { currentPage := firstPage if scanPage > 1 { data, err := ms.mediaList(&playletMediaListReq{ MediaType: mediaType, Title: req.Title, SyncType: syncType, Page: scanPage, Size: scanSize, }) if err != nil { log.Warn("查询未加入短剧媒资后续分页失败,停止继续扫描", log.Any("页码", scanPage), log.E(err)) break } currentPage = data } if len(currentPage.Data) == 0 { break } if err := fillPlayletResourceByAddStatusPage(ctx, currentPage.Data, false, start, pageSize, resp, &matchedCount); err != nil { return nil, err } if matchedCount >= needCount || currentPage.Pagination.IsLastPage { break } } if scanLimit < lastPage && matchedCount < needCount { log.Warn("短剧媒资加入状态筛选达到最大扫描页数", log.Any("最大扫描页数", resourceAddStatusScanMaxPages), log.Any("上游总页数", lastPage), log.Any("命中数量", matchedCount), log.Any("需要数量", needCount)) } return resp, nil } // scanPlayletResourceByAddStatus 扫描媒资中心分页并按加入状态精确过滤,适用于本地无法独立判断的筛选。 func scanPlayletResourceByAddStatus( ctx context.Context, ms *playletMediaCenterService, req *proto.PlayletResourceListReq, mediaType int, syncType int, pageNumber int, pageSize int, ) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { offset := (pageNumber - 1) * pageSize list := make([]*proto.PlayletResourceListItem, 0, pageSize) matchedCount := 0 scanPage := uint(1) scanSize := uint(maxResourcePageSize) for { data, err := ms.mediaList(&playletMediaListReq{ MediaType: mediaType, Title: req.Title, Tag: req.Tag, SyncType: syncType, Page: scanPage, Size: scanSize, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil { return nil, err } for _, item := range data.Data { if item == nil || item.IsAdd != *req.IsAdd { continue } if matchedCount >= offset && len(list) < pageSize { list = append(list, item) } matchedCount++ } if data.Pagination.IsLastPage || len(data.Data) == 0 { break } if data.Pagination.Count > 0 && scanPage*scanSize >= data.Pagination.Count { break } scanPage++ } return &proto.PlayletResourceListResp{ List: list, Total: uint(matchedCount), }, nil } // calcPlayletResourceLastPage 计算上游媒资列表最后一页页码 func calcPlayletResourceLastPage(total uint, pageSize uint) uint { if total == 0 || pageSize == 0 { return 0 } return (total + pageSize - 1) / pageSize } // playletResourceRequiresUpstreamAddStatusScan 判断是否必须扫描上游才能保证筛选结果准确。 func playletResourceRequiresUpstreamAddStatusScan(req *proto.PlayletResourceListReq, syncType int) bool { if syncType != defaultResourceSyncType { return true } return req != nil && req.Tag != nil && strings.TrimSpace(*req.Tag) != "" } // buildAddedPlayletResourceFilter 构建本地已加入短剧媒资查询条件。 func buildAddedPlayletResourceFilter(req *proto.PlayletResourceListReq, mediaType int) bson.M { filter := applyPlayletMediaSourceFilter(bson.M{ playletmod.FN.Playlet.MediaCenterId: bson.M{"$gt": 0}, playletmod.FN.Playlet.ContentType: contentTypeFromMediaType(mediaType), }, playletmod.MediaSourceHG) if req != nil && req.Title != nil { if title := strings.TrimSpace(*req.Title); title != "" { filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} } } return filter } // queryAddedPlayletResourceIds 分批查询已加入短剧的媒资中心ID。 func queryAddedPlayletResourceIds(ctx context.Context, filter bson.M, skip int64) (playletmod.PlayletList, *srvdef.SrvErr) { list, err := playletmod.FindManyPlaylet(ctx, filter, options.Find(). SetProjection(bson.D{{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}}). SetSort(bson.D{ {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }). SetSkip(skip). SetLimit(resourceAddedIDBatchSize)) if err != nil { log.Warn("查询已加入短剧媒资ID失败", log.Any("filter", filter), log.E(err)) return nil, err } return list, nil } // queryAddedPlayletResourceItems 按本地短剧的媒资中心ID回查媒资数据。 func queryAddedPlayletResourceItems(ms *playletMediaCenterService, list playletmod.PlayletList, mediaType int, syncType int) ([]*proto.PlayletResourceListItem, *srvdef.SrvErr) { result := make([]*proto.PlayletResourceListItem, 0, len(list)) for _, item := range list { if item == nil || item.MediaCenterId == 0 { continue } media, err := queryPlayletResourceByMediaCenterId(ms, item.MediaCenterId, mediaType, syncType) if err != nil { return nil, err } if media == nil { continue } media.IsAdd = true result = append(result, media) } return result, nil } // queryPlayletResourceByMediaCenterId 按媒资中心ID查询单条媒资列表数据。 func queryPlayletResourceByMediaCenterId(ms *playletMediaCenterService, mediaId uint, mediaType int, syncType int) (*proto.PlayletResourceListItem, *srvdef.SrvErr) { data, err := ms.mediaList(&playletMediaListReq{ MediaType: mediaType, Id: &mediaId, SyncType: syncType, Page: 1, Size: 1, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } for _, item := range data.Data { if item != nil && item.ID == mediaId { return item, nil } } if len(data.Data) > 0 { return data.Data[0], nil } return nil, nil } // fillPlayletResourceByAddStatusPage 按加入状态累计过滤,并截取请求页数据。 func fillPlayletResourceByAddStatusPage(ctx context.Context, list []*proto.PlayletResourceListItem, wantAdd bool, start int, pageSize int, resp *proto.PlayletResourceListResp, matchedCount *int) *srvdef.SrvErr { if err := fillPlayletResourceAddStatus(ctx, list); err != nil { return err } for _, item := range list { if item == nil || item.IsAdd != wantAdd { continue } if *matchedCount >= start && len(resp.List) < pageSize { resp.List = append(resp.List, item) } *matchedCount = *matchedCount + 1 } return nil } // ImportPlayletResource 提交短剧媒资入库任务 func ImportPlayletResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) { // 先做整批请求校验,避免后续产生无效的外部同步请求和数据库写入。 ids, err := validatePlayletResourceImportReq(req) if err != nil { return "", err } operatorId, operatorName := resolveImportOperator(ctx) if err := pushPlayletResourceImportJob(ctx, ids, operatorId, operatorName); err != nil { return "", err } return playletResourceImportMsg, nil } // ImportPlayletJHResource 提交嘉华短剧媒资入库任务 func ImportPlayletJHResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) { ids, err := validatePlayletResourceImportReq(req) if err != nil { return "", err } operatorId, operatorName := resolveImportOperator(ctx) if err := pushPlayletResourceJHImportJob(ctx, ids, operatorId, operatorName); err != nil { return "", err } return playletResourceImportMsg, nil } // importPlayletResourceBatch 批量处理短剧媒资入库任务 func importPlayletResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp { // 先批量查询已导入媒资,避免对已存在数据重复调用媒资中心同步接口。 resp := &proto.PlayletResourceUseResp{ Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)), Failed: make([]*proto.PlayletResourceUseFailed, 0), } importedMap, err := findImportedPlayletResourceMap(ctx, ids, playletmod.MediaSourceHG) if err != nil { for _, id := range ids { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: "查询导入状态失败", }) } return resp } ms := newPlayletMediaCenterFromConfig() for _, id := range ids { // 已入库媒资直接跳过,不再请求媒资中心。 if importedMap[id] { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: "该媒资已导入", }) continue } // 单条媒资独立导入,失败不影响本批次剩余媒资;单条内部还有锁和最终去重校验。 success, reason := importSinglePlayletResource(ctx, ms, id, operatorId, operatorName) if reason != "" { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: reason, }) continue } resp.Success = append(resp.Success, success) } return resp } // importPlayletJHResourceBatch 批量处理嘉华短剧媒资入库任务 func importPlayletJHResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp { resp := &proto.PlayletResourceUseResp{ Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)), Failed: make([]*proto.PlayletResourceUseFailed, 0), } importedMap, err := findImportedPlayletResourceMap(ctx, ids, playletmod.MediaSourceDefault) if err != nil { for _, id := range ids { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: "查询导入状态失败", }) } return resp } ms := newPlayletJHMediaCenterFromConfig() for _, id := range ids { if importedMap[id] { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: "该媒资已导入", }) continue } success, reason := importSinglePlayletJHResource(ctx, ms, id, operatorId, operatorName) if reason != "" { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: reason, }) continue } resp.Success = append(resp.Success, success) } return resp } // findImportedPlayletResourceMap 批量查询指定来源已经导入的媒资ID func findImportedPlayletResourceMap(ctx context.Context, ids []uint, mediaSource string) (map[uint]bool, *srvdef.SrvErr) { result := make(map[uint]bool, len(ids)) if len(ids) == 0 { return result, nil } filter := applyPlayletMediaSourceFilter(bson.M{ playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": ids}, }, mediaSource) playletList, err := playletmod.FindManyPlaylet(ctx, filter) if err != nil { log.Error("批量查询短剧媒资导入状态失败", log.Any("mediaIds", ids), log.Any("err", err)) return nil, err } for _, item := range playletList { // 只记录有效的媒资中心ID,供批量任务跳过已入库数据。 if item == nil || item.MediaCenterId == 0 { continue } result[item.MediaCenterId] = true } return result, nil } func newPlayletMediaCenterFromConfig() *playletMediaCenterService { return newPlayletMediaCenterService(playletMediaCenterConfig{ Url: webg.Conf.MediaResourceDBCfg.BaseUrl, ProId: webg.Conf.MediaResourceDBCfg.Merchat, Secret: webg.Conf.MediaResourceDBCfg.Secret, }) } func newPlayletJHMediaCenterFromConfig() *playletMediaCenterService { return newPlayletMediaCenterService(playletMediaCenterConfig{ Url: webg.Conf.PlayletMediaResourceDBCfg.BaseUrl, ProId: webg.Conf.PlayletMediaResourceDBCfg.Merchat, Secret: webg.Conf.PlayletMediaResourceDBCfg.Secret, }) } func newPlayletMediaCenterBySource(mediaSource string) *playletMediaCenterService { if mediaSource == playletmod.MediaSourceHG { return newPlayletMediaCenterFromConfig() } return newPlayletJHMediaCenterFromConfig() } func applyPlayletMediaSourceFilter(filter bson.M, mediaSource string) bson.M { if filter == nil { filter = bson.M{} } if mediaSource == playletmod.MediaSourceHG { filter[playletmod.FN.Playlet.MediaSource] = playletmod.MediaSourceHG return filter } filter["$or"] = []bson.M{ {playletmod.FN.Playlet.MediaSource: playletmod.MediaSourceDefault}, {playletmod.FN.Playlet.MediaSource: bson.M{"$exists": false}}, } return filter } // validatePlayletResourceImportReq 校验短剧媒资入库请求 func validatePlayletResourceImportReq(req *proto.PlayletResourceUseReq) ([]uint, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } // 过滤空ID和重复ID,避免重复导入同一条媒资。 ids := dedupePlayletResourceIds(req.Ids) if len(ids) == 0 { return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") } // 后台选择入库属于人工操作,限制单次数量可以避免队列任务过大。 if len(ids) > maxResourceImportIds { return nil, srvdef.New(srvdef.ErrParamError, "单次最多导入100条媒资", "") } return ids, nil } // dedupePlayletResourceIds 去重并过滤无效媒资ID func dedupePlayletResourceIds(ids []uint) []uint { result := make([]uint, 0, len(ids)) seen := make(map[uint]struct{}, len(ids)) for _, id := range ids { // 0 不是有效媒资ID,直接丢弃。 if id == 0 { continue } // 同一批次重复选择的ID只处理一次。 if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} result = append(result, id) } return result } type playletResourceImportLockFunc func(uint) (bool, error) type playletResourceImportUnlockFunc func(uint) // importSinglePlayletResource 导入单条短剧媒资 func importSinglePlayletResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) { return importSinglePlayletResourceWithLock(ctx, ms, mediaId, operatorId, operatorName, playletmod.MediaSourceHG, lockPlayletResourceImportMedia, unlockPlayletResourceImportMedia) } // importSinglePlayletJHResource 导入单条嘉华短剧媒资 func importSinglePlayletJHResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) { return importSinglePlayletResourceWithLock(ctx, ms, mediaId, operatorId, operatorName, playletmod.MediaSourceDefault, lockPlayletResourceJHImportMedia, unlockPlayletResourceJHImportMedia) } // importSinglePlayletResourceWithLock 导入单条短剧媒资并使用指定锁 func importSinglePlayletResourceWithLock(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string, mediaSource string, lockFn playletResourceImportLockFunc, unlockFn playletResourceImportUnlockFunc) (*proto.PlayletResourceUseSuccess, string) { locked, err := lockFn(mediaId) if err != nil { log.Error("获取短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err)) return nil, "获取导入锁失败" } if !locked { return nil, "该媒资正在导入" } defer unlockFn(mediaId) // 本地已导入的媒资不再请求媒资中心,减少无效网络调用。 if reason := checkPlayletResourceImported(ctx, mediaId, mediaSource); reason != "" { return nil, reason } // sync_time 传 0,表示拉取该媒资当前完整详情和章节列表。 result, err := ms.mediaSync(&playletMediaSyncReq{ Id: mediaId, SyncTime: 0, }) if err != nil { log.Error("同步短剧媒资失败", log.Any("mediaId", mediaId), log.E(err)) return nil, "同步媒资失败" } if result.IsDelete || result.Media == nil { return nil, "媒资不存在或已删除" } if !validPlayletImportMediaType(result.Media.MediaType) { return nil, "该媒资类型不支持导入短剧" } if len(result.MediaContentList) == 0 { return nil, "媒资章节为空" } // 标签按媒资中心 tags_text 自动生成或复用,不再由请求参数控制。 //tagIds, serr := buildPlayletResourceTagIds(ctx, result.Media.TagsText) //if serr != nil { // log.Error("处理短剧媒资标签失败", log.Any("mediaId", mediaId), log.Any("err", serr)) // return nil, "处理标签失败" //} // 将媒资中心主资源转换成本项目短剧主表模型。 playlet := buildPlayletFromMedia(result.Media, nil, operatorId, operatorName) playlet.MediaSource = mediaSource if serr := ensureImportedPlayletSlug(ctx, playlet); serr != nil { log.Error("生成短剧媒资slug失败", log.Any("mediaId", mediaId), log.Any("err", serr)) return nil, "生成slug失败" } playlet.MediaSyncTime = maxPlayletMediaContentUpdateTime(0, result.MediaContentList) // 原图继续写入 Cover/HorizontalCover;压缩成功才写压缩字段,失败保持空。 playlet.CompressedCover = compressPlayletCoverOrEmpty(playlet.Cover) playlet.CompressedHorizontalCover = compressPlayletCoverOrEmpty(playlet.HorizontalCover) if playlet.Title == "" { return nil, "媒资标题为空" } // 章节转换会过滤没有播放地址的数据,过滤后为空则不写主表。 chapters := buildPlayletChaptersFromMedia(result.MediaContentList) if len(chapters) == 0 { return nil, "媒资章节为空" } // 先插入主表,拿到本项目自增 playlet.Id 后再写章节。 if serr := playletmod.InsertOnePlaylet(ctx, playlet); serr != nil { log.Error("短剧媒资主表入库失败", log.Any("mediaId", mediaId), log.Any("err", serr)) return nil, "主资源入库失败" } for _, chapter := range chapters { // 每个章节都绑定刚生成的短剧ID。 chapter.PlayletId = playlet.Id } // 章节批量写入,减少同一部短剧大量章节逐条访问 Mongo 的开销。 if serr := playletchaptermod.InsertManyPlayletChapter(ctx, chapters); serr != nil { log.Error("短剧媒资章节批量入库失败", log.Any("mediaId", mediaId), log.Any("playletId", playlet.Id), log.Any("chapterCount", len(chapters)), log.Any("err", serr)) // 批量写入可能已经落入部分章节,按预分配的章节ID回滚,避免半成品短剧留库。 rollbackImportedPlayletResource(ctx, playlet.Id, collectPlayletChapterIds(chapters)) return nil, "章节入库失败" } return &proto.PlayletResourceUseSuccess{ MediaId: mediaId, PlayletId: playlet.Id, }, "" } // collectPlayletChapterIds 收集章节ID func collectPlayletChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 { ids := make([]int64, 0, len(chapters)) for _, chapter := range chapters { // 批量插入前已预分配章节ID,回滚时只删除本次任务生成的数据。 if chapter == nil || chapter.Id == 0 { continue } ids = append(ids, chapter.Id) } return ids } // checkPlayletResourceImported 检查指定来源媒资是否已经导入到本项目短剧表 func checkPlayletResourceImported(ctx context.Context, mediaId uint, mediaSource string) string { // mediaCenterId 是媒资中心ID,不是本项目自增ID,用它判断是否导入过。 filter := applyPlayletMediaSourceFilter(bson.M{playletmod.FN.Playlet.MediaCenterId: mediaId}, mediaSource) _, err := playletmod.FindOnePlaylet(ctx, filter) if err == nil { return "该媒资已导入" } if err.Code != srvdef.ErrNoData { log.Error("查询短剧媒资导入状态失败", log.Any("mediaId", mediaId), log.Any("err", err)) return "查询导入状态失败" } return "" } // buildPlayletResourceTagIds 生成短剧入库标签ID列表 func buildPlayletResourceTagIds(ctx context.Context, tagsText string) ([]int64, *srvdef.SrvErr) { tagIds := make([]int64, 0) seen := make(map[int64]struct{}) // 解析媒资中心 tags_text,并自动创建或复用本项目标签。 for _, tagName := range splitPlayletResourceTags(tagsText) { tagId, err := findOrCreatePlayletTag(ctx, tagName) if err != nil { return nil, err } if tagId <= 0 { continue } if _, ok := seen[tagId]; ok { continue } seen[tagId] = struct{}{} tagIds = append(tagIds, tagId) } return tagIds, nil } // splitPlayletResourceTags 拆分媒资中心标签文本 func splitPlayletResourceTags(tagsText string) []string { if strings.TrimSpace(tagsText) == "" { return nil } // 兼容媒资中心可能返回的多种标签分隔符。 parts := strings.FieldsFunc(tagsText, func(r rune) bool { return r == ',' || r == ',' || r == ';' || r == ';' || r == '|' }) result := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, part := range parts { tagName := strings.TrimSpace(part) // 空标签不入库,避免产生脏标签。 if tagName == "" { continue } // 同一个媒资标签文本里重复的标签只保留一次。 if _, ok := seen[tagName]; ok { continue } seen[tagName] = struct{}{} result = append(result, tagName) } return result } // findOrCreatePlayletTag 查询或创建短剧标签 func findOrCreatePlayletTag(ctx context.Context, tagName string) (int64, *srvdef.SrvErr) { // 先按名称查询,存在则直接复用本项目标签ID。 tag, err := playlettagmod.FindOnePlayletTag(ctx, bson.M{playlettagmod.FN.PlayletTag.Name: tagName}) if err == nil { return tag.Id, nil } if err.Code != srvdef.ErrNoData { return 0, err } // 标签不存在时创建默认上架标签,方便导入后的短剧直接关联。 tag = &playlettagmod.PlayletTag{ Name: tagName, Status: true, } source := utils.SlugSourceFromKeywordsOrTitle(nil, tag.Name) tag.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { exist, _ := playlettagmod.FindOnePlayletTag(ctx, bson.M{ playlettagmod.FN.PlayletTag.Slug: slug, }) return exist != nil }) if err = playlettagmod.InsertOnePlayletTag(ctx, tag); err != nil { return 0, err } return tag.Id, nil } // buildPlayletFromMedia 将媒资中心主资源转换为本项目 playlet 主表模型 func buildPlayletFromMedia(media *proto.PlayletResourceListItem, tagIds []int64, operatorId int64, operatorName string) *playletmod.Playlet { // 封面优先使用竖图,适配短剧列表和详情页常见展示比例。 horizontalCover := strings.TrimSpace(media.HorizontalCover) verticalCover := strings.TrimSpace(media.VerticalCover) cover := verticalCover if cover == "" { // 竖图缺失时回退到横图,避免短剧没有封面。 cover = horizontalCover } return &playletmod.Playlet{ // 保存媒资中心ID,用于重复导入拦截和后续追溯来源。 MediaCenterId: uint(media.ID), MediaCreatedAt: mediaResourceTime(media.CreateTime), MediaUpdatedAt: mediaResourceTime(media.UpdateTime), Title: strings.TrimSpace(media.Title), Desc: strings.TrimSpace(media.Summary), Anchor: strings.TrimSpace(media.Anchor), Cover: cover, HorizontalCover: horizontalCover, CompressedHorizontalCover: "", CategoryIds: make([]int64, 0), TagIds: tagIds, ContentType: contentTypeFromMediaType(media.MediaType), OperateType: playletmod.OperateTypePlaylet, TotalEpisode: int64(media.TotalEpisode), UpdateStatus: normalizePlayletUpdateStatus(media.UpdateStatus), HeatValue: randomPlayletHeatValue(), VirtualCountLike: randomPlayletVirtualLike(), Status: playletmod.StatusDown, OperatorId: operatorId, OperatorName: operatorName, } } func ensureImportedPlayletSlug(ctx context.Context, playlet *playletmod.Playlet) *srvdef.SrvErr { if playlet == nil { return srvdef.New(srvdef.ErrParamError, "短剧数据为空", "") } source := utils.SlugSourceFromKeywordsOrTitle(playlet.SeoInfo.Keyworld, playlet.Title) playlet.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { exist, _ := playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Slug: slug, }) return exist != nil }) return nil } func randomPlayletHeatValue() int64 { return int64(rand.Intn(playletHeatMaxValue-playletHeatMinValue+1) + playletHeatMinValue) } func randomPlayletVirtualLike() int64 { return int64(rand.Intn(playletVirtualLikeMaxValue-playletVirtualLikeMinValue+1) + playletVirtualLikeMinValue) } // mediaResourceTime 转换媒资中心时间戳 func mediaResourceTime(ts int64) time.Time { // 媒资中心可能返回秒或毫秒时间戳,这里做兼容转换,避免毫秒时间被当成秒导致年份异常。 if ts <= 0 { return time.Time{} } if ts > 1e12 { return time.UnixMilli(ts) } return time.Unix(ts, 0) } // buildPlayletChaptersFromMedia 将媒资中心章节资源转换为本项目 playlet_chapter 模型 func buildPlayletChaptersFromMedia(mediaContentList []playletMediaContent) playletchaptermod.PlayletChapterList { chapters := make(playletchaptermod.PlayletChapterList, 0, len(mediaContentList)) seenMediaContentIds := make(map[uint]struct{}, len(mediaContentList)) seenEpisodes := make(map[int]struct{}, len(mediaContentList)) seenHashIds := make(map[string]struct{}, len(mediaContentList)) seenVideoUrls := make(map[string]struct{}, len(mediaContentList)) for _, item := range mediaContentList { videoUrl := strings.TrimSpace(item.M3u8Url) if videoUrl == "" { // m3u8_url 缺失时回退到 urls,兼容不同资源格式。 videoUrl = strings.TrimSpace(item.Urls) } if videoUrl == "" { // 没有播放地址的章节不能正常播放,直接跳过。 continue } if isDuplicatePlayletMediaChapter(item, videoUrl, seenMediaContentIds, seenEpisodes, seenHashIds, seenVideoUrls) { continue } episodeNumber := int64(item.EpisodeNumber) if episodeNumber <= 0 { // 媒资中心没给集数时,在去重后按有效章节顺序补齐,保证章节可排序。 episodeNumber = int64(len(chapters) + 1) } height := int64(item.Height) width := int64(item.Width) ratio := item.Ratio if ratio <= 0 && height > 0 { // 宽高比缺失时按宽高计算,供前端展示使用。 ratio = float64(width) / float64(height) } chapters = append(chapters, &playletchaptermod.PlayletChapter{ MediaContentId: uint(item.ID), CurrentEpisode: episodeNumber, Title: strings.TrimSpace(item.Title), Hash: item.HashId, Height: height, Width: width, Ratio: ratio, VideoUrl: videoUrl, MediaSize: int64(item.MediaSize), MediaTime: int64(item.PlayTime), PayType: playletchaptermod.PayTypeFree, SingleCoins: int64(item.SaleCoin), WatermarkStatus: playletchaptermod.WatermarkStatusPending, }) } return chapters } // isDuplicatePlayletMediaChapter 判断媒资中心章节是否重复 func isDuplicatePlayletMediaChapter(item playletMediaContent, videoUrl string, seenMediaContentIds map[uint]struct{}, seenEpisodes map[int]struct{}, seenHashIds map[string]struct{}, seenVideoUrls map[string]struct{}) bool { if item.ID > 0 { // 媒资中心章节ID是最稳定的来源章节标识,优先用它做幂等去重。 if _, ok := seenMediaContentIds[item.ID]; ok { log.Info("跳过重复短剧媒资章节", log.Any("mediaContentId", item.ID), log.Any("episodeNumber", item.EpisodeNumber)) return true } seenMediaContentIds[item.ID] = struct{}{} return false } if item.EpisodeNumber > 0 { // 老数据如果没有媒资章节ID,则按集数去重,避免同一集重复入库。 if _, ok := seenEpisodes[item.EpisodeNumber]; ok { log.Info("跳过重复短剧媒资章节", log.Any("episodeNumber", item.EpisodeNumber)) return true } seenEpisodes[item.EpisodeNumber] = struct{}{} return false } hashId := strings.TrimSpace(item.HashId) if hashId != "" { // 集数缺失时使用资源hash兜底。 if _, ok := seenHashIds[hashId]; ok { log.Info("跳过重复短剧媒资章节", log.Any("hashId", hashId)) return true } seenHashIds[hashId] = struct{}{} return false } // 最后用播放地址兜底,避免无ID、无集数、无hash的重复资源写入。 if _, ok := seenVideoUrls[videoUrl]; ok { log.Info("跳过重复短剧媒资章节", log.Any("videoUrl", videoUrl)) return true } seenVideoUrls[videoUrl] = struct{}{} return false } // rollbackImportedPlayletResource 回滚单条媒资入库产生的数据 func rollbackImportedPlayletResource(ctx context.Context, playletId int64, chapterIds []int64) { for _, chapterId := range chapterIds { // 只删除本次成功插入的章节,避免误删已有数据。 if _, err := playletchaptermod.DelOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}); err != nil { log.Error("回滚短剧媒资章节失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.Any("err", err)) } } // 章节失败后删除刚写入的主表,避免后台看到没有章节的短剧。 if _, err := playletmod.DelOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}); err != nil { log.Error("回滚短剧媒资主表失败", log.Any("playletId", playletId), log.Any("err", err)) } } // resolveImportOperator 从后台登录态解析当前操作人,避免与 srv_playlet 循环依赖。 func resolveImportOperator(ctx context.Context) (int64, string) { c, ok := ctx.(*gin.Context) if !ok || c == nil { return 0, "" } accId := auth.GetAccount(c) if accId <= 0 { return 0, "" } acc, err := mwacc.FindOneAccByFilter(c, bson.M{ mwacc.FN.MwAcc.Id: accId, }, options.FindOne().SetProjection(bson.D{ {Key: mwacc.FN.MwAcc.Id, Value: 1}, {Key: mwacc.FN.MwAcc.UserName, Value: 1}, })) if err != nil || acc == nil { return accId, "" } return acc.Id, acc.UserName }