Initial commit: web_hghl_server
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
package actressser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/app/service/slugser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/response"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/actress"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/storage/proxy"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const homeActressPageSize int64 = 9
|
||||
const videoHomeActressPageSize int64 = 16
|
||||
|
||||
const (
|
||||
actressWorkDefaultPageSize int64 = 10
|
||||
actressWorkMaxPageSize int64 = 100
|
||||
actressWorkAdInterval int64 = 8
|
||||
)
|
||||
|
||||
// ActressListData 查询女优榜单列表,按热度值降序返回全部可用数据
|
||||
func ActressListData(ctx context.Context, path string) (*proto.QueryActressListResp, *srvdef.SrvErr) {
|
||||
list := configser.GetActressWorks()
|
||||
if len(list) == 0 {
|
||||
var err *srvdef.SrvErr
|
||||
list, err = queryActressListDataFromDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resp := &proto.QueryActressListResp{
|
||||
Path: path,
|
||||
Translations: translationser.GetTranslationData(),
|
||||
BottonTranslations: translationser.GetTranslationBottomList(),
|
||||
Advitise: configser.GetAdvitise(),
|
||||
ActressInfoList: list,
|
||||
}
|
||||
configser.RewriteImageCdn(ctx, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// HomeActressData 查询首页女优数据,按热度值降序取前 9 条
|
||||
func HomeActressData(ctx context.Context) ([]proto.ActressInfo, *srvdef.SrvErr) {
|
||||
return homeActressDataByCdn(ctx, homeActressPageSize, configser.GetConfigInfo().ImgCdn)
|
||||
}
|
||||
|
||||
// VideoHomeActressData 查询视频首页热门女优数据,按热度值降序取前 16 条
|
||||
func VideoHomeActressData(ctx context.Context) ([]proto.ActressInfo, *srvdef.SrvErr) {
|
||||
return homeActressDataByCdn(ctx, videoHomeActressPageSize, configser.ImageCdnFromCtx(ctx))
|
||||
}
|
||||
|
||||
// HomeActressDataByLimit 按热度值降序查询指定数量的女优数据
|
||||
func HomeActressDataByLimit(ctx context.Context, limit int64) ([]proto.ActressInfo, *srvdef.SrvErr) {
|
||||
return homeActressDataByCdn(ctx, limit, configser.ImageCdnFromCtx(ctx))
|
||||
}
|
||||
|
||||
func homeActressDataByCdn(ctx context.Context, limit int64, imgCdn string) ([]proto.ActressInfo, *srvdef.SrvErr) {
|
||||
if limit <= 0 {
|
||||
limit = homeActressPageSize
|
||||
}
|
||||
list, err := actress.FindManyActresss(ctx, bson.M{
|
||||
actress.FN.Actress.Status: true,
|
||||
}, homeActressFindOptions(limit))
|
||||
if err != nil {
|
||||
log.Warn("查询首页女优数据失败", log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
data := make([]proto.ActressInfo, 0, len(list))
|
||||
for _, item := range list {
|
||||
data = append(data, homeActressInfo(item, imgCdn))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// queryActressListDataFromDB 回源查询女优榜单,使用字段投影减少 MongoDB 返回体积
|
||||
func queryActressListDataFromDB(ctx context.Context) ([]proto.ActressInfoData, *srvdef.SrvErr) {
|
||||
config := configser.GetConfigInfo()
|
||||
list, err := actress.FindManyActresss(ctx, bson.M{
|
||||
actress.FN.Actress.Status: true,
|
||||
}, options.Find().
|
||||
SetSort(bson.D{{Key: actress.FN.Actress.HotValue, Value: -1}, {Key: actress.FN.Actress.Id, Value: -1}}).
|
||||
SetProjection(bson.M{
|
||||
actress.FN.Actress.Id: 1,
|
||||
actress.FN.Actress.Alias: 1,
|
||||
actress.FN.Actress.Slug: 1,
|
||||
actress.FN.Actress.Avatar: 1,
|
||||
actress.FN.Actress.Cover: 1,
|
||||
actress.FN.Actress.Desc: 1,
|
||||
actress.FN.Actress.Office: 1,
|
||||
actress.FN.Actress.DebutDate: 1,
|
||||
actress.FN.Actress.BornDate: 1,
|
||||
actress.FN.Actress.Region: 1,
|
||||
actress.FN.Actress.Score: 1,
|
||||
actress.FN.Actress.Works: 1,
|
||||
actress.FN.Actress.HotValue: 1,
|
||||
actress.FN.Actress.SeoInfo: 1,
|
||||
actress.FN.Actress.Meas.Gender: 1,
|
||||
actress.FN.Actress.Meas.Height: 1,
|
||||
actress.FN.Actress.Meas.Weight: 1,
|
||||
actress.FN.Actress.Meas.Bust: 1,
|
||||
actress.FN.Actress.Meas.Waist: 1,
|
||||
actress.FN.Actress.Meas.Hip: 1,
|
||||
actress.FN.Actress.Meas.Cup: 1,
|
||||
actress.FN.Actress.Meas.Len: 1,
|
||||
actress.FN.Actress.Meas.Dia: 1,
|
||||
actress.FN.Actress.Meas.Blood: 1,
|
||||
actress.FN.Actress.Real.WatchTimes: 1,
|
||||
actress.FN.Actress.Real.ShowTimes: 1,
|
||||
actress.FN.Actress.Real.Comments: 1,
|
||||
}))
|
||||
if err != nil {
|
||||
log.Warn("查询女优榜单数据失败", log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
data := make([]proto.ActressInfoData, 0, len(list))
|
||||
for _, item := range list {
|
||||
data = append(data, proto.ActressInfoToData(*item, config.ImgCdn))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// homeActressFindOptions 限定首页女优查询数量、排序和字段投影,减少 MongoDB 返回数据量
|
||||
func homeActressFindOptions(limit int64) *options.FindOptions {
|
||||
return options.Find().
|
||||
SetLimit(limit).
|
||||
SetSort(bson.D{{Key: actress.FN.Actress.HotValue, Value: -1}, {Key: actress.FN.Actress.Id, Value: -1}}).
|
||||
SetProjection(bson.M{
|
||||
actress.FN.Actress.Id: 1,
|
||||
actress.FN.Actress.Alias: 1,
|
||||
actress.FN.Actress.Slug: 1,
|
||||
actress.FN.Actress.Avatar: 1,
|
||||
actress.FN.Actress.Works: 1,
|
||||
actress.FN.Actress.HotValue: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// homeActressInfo 将女优模型转换为首页轻量女优结构
|
||||
func homeActressInfo(item *actress.Actress, imgCdn string) proto.ActressInfo {
|
||||
return proto.ActressInfo{
|
||||
Id: item.Id,
|
||||
Alias: item.Alias,
|
||||
Slug: item.Slug,
|
||||
Avatar: netutil.MergeURL(imgCdn, item.Avatar),
|
||||
Works: item.Works,
|
||||
HotValue: item.HotValue,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryActressWorkList 查询指定女优详情和作品混排列表
|
||||
func QueryActressWorkList(ctx context.Context, req *proto.QueryActressWorkListReq) (*proto.QueryActressWorkListResp, *srvdef.SrvErr) {
|
||||
if req == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
actressId, err := slugser.ActressID(ctx, req.Id, req.Slug, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Id = actressId
|
||||
pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, actressWorkDefaultPageSize, actressWorkMaxPageSize)
|
||||
config := configser.GetRequestConfig(ctx)
|
||||
actressInfo := configser.GetActressInfo(req.Id)
|
||||
if actressInfo == nil {
|
||||
log.Warn("女优详情不存在", log.Any("actressId", req.Id))
|
||||
return nil, srvdef.New(srvdef.AtressNotExist)
|
||||
}
|
||||
filter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.ActressIds: bson.M{"$in": []int64{req.Id}},
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
}
|
||||
totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter)
|
||||
if err != nil {
|
||||
log.Warn("统计女优作品总数失败", log.Any("actressId", req.Id), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
ads := configser.GetAdvitise().PostList
|
||||
adsTotalCount := moreser.InsertedAdsCount(totalCount, int64(len(ads)), actressWorkAdInterval)
|
||||
mixedTotalCount := totalCount + adsTotalCount
|
||||
resp := &proto.QueryActressWorkListResp{
|
||||
BasePageResp: response.BasePageResp{
|
||||
TotalCount: mixedTotalCount,
|
||||
PageSize: int(pageSize),
|
||||
PageNum: int(pageNum),
|
||||
List: make([]*proto.QueryVideoListItem, 0),
|
||||
},
|
||||
Translations: translationser.GetTranslationData(),
|
||||
BottonTranslations: translationser.GetTranslationBottomList(),
|
||||
ActressInfo: proto.ActressInfoToData(*actressInfo, config.ImgCdn),
|
||||
}
|
||||
if totalCount == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
start := (pageNum - 1) * pageSize
|
||||
if start >= mixedTotalCount {
|
||||
return resp, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > mixedTotalCount {
|
||||
end = mixedTotalCount
|
||||
}
|
||||
contentStart, contentLimit := moreser.ContentRange(start, end, totalCount, adsTotalCount, actressWorkAdInterval)
|
||||
list := videoinformationmod.VideoInformationList{}
|
||||
if contentLimit > 0 {
|
||||
list, err = videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
|
||||
SetSort(actressWorkSort(req)).
|
||||
SetProjection(actressWorkProjection()).
|
||||
SetSkip(contentStart).
|
||||
SetLimit(contentLimit))
|
||||
if err != nil {
|
||||
log.Warn("查询女优作品列表失败", log.Any("actressId", req.Id), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
mediaList := buildActressWorkMixedListWithoutTotal(list, ads, config.ImgCdn, start, end, contentStart, adsTotalCount)
|
||||
resp.List = mediaList
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// actressWorkSort 观看量和收藏量按真实数据 real.watchTimes、real.likes 排序,不使用 fake 数据
|
||||
func actressWorkSort(req *proto.QueryActressWorkListReq) bson.D {
|
||||
sort := make(bson.D, 0, 4)
|
||||
if dir := actressWorkSortDir(req.AddedTimeSort); dir != 0 {
|
||||
sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: dir})
|
||||
}
|
||||
if dir := actressWorkSortDir(req.WatchTimesSort); dir != 0 {
|
||||
sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: dir})
|
||||
}
|
||||
if dir := actressWorkSortDir(req.LikesSort); dir != 0 {
|
||||
sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Real.Likes, Value: dir})
|
||||
}
|
||||
if len(sort) > 0 {
|
||||
sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1})
|
||||
return sort
|
||||
}
|
||||
return bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.VideoCount, Value: -1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
|
||||
}
|
||||
}
|
||||
|
||||
// actressWorkProjection 限定作品列表卡片字段,避免读取详情页大字段和视频节点。
|
||||
func actressWorkProjection() bson.M {
|
||||
return bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: 1,
|
||||
videoinformationmod.FN.VideoInformation.Title: 1,
|
||||
videoinformationmod.FN.VideoInformation.Slug: 1,
|
||||
videoinformationmod.FN.VideoInformation.AddedTime: 1,
|
||||
videoinformationmod.FN.VideoInformation.CoverImg: 1,
|
||||
videoinformationmod.FN.VideoInformation.Tags: 1,
|
||||
videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1,
|
||||
videoinformationmod.FN.VideoInformation.Fake.WatchTimes: 1,
|
||||
videoinformationmod.FN.VideoInformation.Real.Likes: 1,
|
||||
videoinformationmod.FN.VideoInformation.Fake.Likes: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// actressWorkSortDir 将接口排序值转换为 MongoDB 排序方向
|
||||
func actressWorkSortDir(sortType int64) int {
|
||||
switch sortType {
|
||||
case 1:
|
||||
return 1
|
||||
case 2:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// buildActressWorkMixedListWithoutTotal 根据无总数混排分页窗口组装当前页列表
|
||||
func buildActressWorkMixedListWithoutTotal(list videoinformationmod.VideoInformationList, ads []proxy.AdvitiseInfo, cdn string, start, end, contentStart, adsTotal int64) []*proto.QueryVideoListItem {
|
||||
resp := make([]*proto.QueryVideoListItem, 0, end-start)
|
||||
contentByIndex := make(map[int64]*videoinformationmod.VideoInformation, len(list))
|
||||
for i, item := range list {
|
||||
contentByIndex[contentStart+int64(i)] = item
|
||||
}
|
||||
for pos := start; pos < end; pos++ {
|
||||
adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adsTotal, actressWorkAdInterval)
|
||||
if adIndex >= 0 && adIndex < adsTotal {
|
||||
resp = append(resp, actressWorkAdToListItem(ads[adIndex], cdn))
|
||||
continue
|
||||
}
|
||||
contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adsTotal, actressWorkAdInterval)
|
||||
if item := contentByIndex[contentIndex]; item != nil {
|
||||
resp = append(resp, actressWorkVideoToListItem(item, cdn))
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// actressWorkVideoToListItem 将视频模型转换为女优作品列表中的视频项
|
||||
func actressWorkVideoToListItem(item *videoinformationmod.VideoInformation, cdn string) *proto.QueryVideoListItem {
|
||||
tagNames, tagSlugs := configser.VideoTagNamesAndSlugs(item.Tags)
|
||||
return &proto.QueryVideoListItem{
|
||||
Type: proto.QueryVideoItemTypeVideo,
|
||||
VideoInfo: proto.VideoInfo{ContentData: proto.ContentData{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
Slug: item.Slug,
|
||||
AddedTime: item.AddedTime,
|
||||
CoverImg: netutil.MergeURL(cdn, item.CoverImg),
|
||||
Tags: item.Tags,
|
||||
TagsName: tagNames,
|
||||
TagSlugs: tagSlugs,
|
||||
WatchNum: item.Real.WatchTimes + item.Fake.WatchTimes,
|
||||
LikeNum: item.Real.Likes + item.Fake.Likes,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// actressWorkAdToListItem 将列表广告转换为女优作品列表中的广告项
|
||||
func actressWorkAdToListItem(ad proxy.AdvitiseInfo, cdn string) *proto.QueryVideoListItem {
|
||||
return &proto.QueryVideoListItem{
|
||||
Type: proto.QueryVideoItemTypeAdvertise,
|
||||
AdvertiseId: ad.Id,
|
||||
Link: ad.Link,
|
||||
Location: ad.Location,
|
||||
VideoInfo: proto.VideoInfo{ContentData: proto.ContentData{
|
||||
Title: ad.Title,
|
||||
CoverImg: netutil.MergeURL(cdn, ad.Images),
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package appser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/srvdef"
|
||||
)
|
||||
|
||||
// AppCategoryData 查询应用分类列表及当前分类数据
|
||||
func AppCategoryData(ctx context.Context, categoryId int64) ([]proto.AppCategoryInfo, proto.AppCategoryInfo, *srvdef.SrvErr) {
|
||||
list := GetAppListCache()
|
||||
category := GetCategoryCache(categoryId)
|
||||
configser.RewriteImageCdn(ctx, &list)
|
||||
configser.RewriteImageCdn(ctx, &category)
|
||||
return list, category, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package appser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/http"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/storage/proxy"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
const appListAPIPath = "/media/web/appinfo/applist"
|
||||
|
||||
var jsonHeader = map[string]string{"Content-Type": "application/json"}
|
||||
|
||||
var (
|
||||
appListCron *cron.Cron
|
||||
appListCache struct {
|
||||
lock sync.RWMutex
|
||||
appList []proto.AppCategoryInfo
|
||||
categoryMaps map[int64]proto.AppCategoryInfo
|
||||
}
|
||||
)
|
||||
|
||||
type remoteAppListResp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
List []proto.AppCategoryInfo `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Start 启动定时刷新应用列表缓存(每10分钟一次)
|
||||
func Start() {
|
||||
appListCron = cron.New(cron.WithSeconds())
|
||||
UpdateAppListCache(context.Background())
|
||||
_, _ = appListCron.AddFunc("0 */10 * * * ?", func() {
|
||||
UpdateAppListCache(context.Background())
|
||||
})
|
||||
appListCron.Start()
|
||||
}
|
||||
|
||||
// GetAppListCache 返回全部应用分类列表(带各分类下应用)
|
||||
func GetAppListCache() []proto.AppCategoryInfo {
|
||||
appListCache.lock.RLock()
|
||||
defer appListCache.lock.RUnlock()
|
||||
if len(appListCache.appList) == 0 {
|
||||
return make([]proto.AppCategoryInfo, 0)
|
||||
}
|
||||
result := make([]proto.AppCategoryInfo, len(appListCache.appList))
|
||||
copy(result, appListCache.appList)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetCategoryCache 按分类ID返回单个分类(含应用列表);categoryId=0 返回第一个分类
|
||||
func GetCategoryCache(categoryId int64) proto.AppCategoryInfo {
|
||||
appListCache.lock.RLock()
|
||||
defer appListCache.lock.RUnlock()
|
||||
if categoryId == 0 && len(appListCache.appList) > 0 {
|
||||
return appListCache.appList[0]
|
||||
}
|
||||
if v, ok := appListCache.categoryMaps[categoryId]; ok {
|
||||
return v
|
||||
}
|
||||
return proto.AppCategoryInfo{AppList: make([]proto.AppInfoData, 0)}
|
||||
}
|
||||
|
||||
// UpdateAppListCache 从远端拉取应用列表并刷新内存缓存
|
||||
func UpdateAppListCache(ctx context.Context) {
|
||||
domain := proxy.DomainAddressRepair("", proxy.BucketVideoAddressV2)
|
||||
apiURL := strings.TrimSuffix(domain, "/") + appListAPIPath
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]string{"dc": "dc"})
|
||||
resp, err := http.Post(apiURL, jsonHeader, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
log.Warn("UpdateAppListCache http.Post failed", log.Any("url", apiURL), log.E(err))
|
||||
return
|
||||
}
|
||||
|
||||
var remote remoteAppListResp
|
||||
if err = json.Unmarshal(resp.Data, &remote); err != nil {
|
||||
log.Warn("UpdateAppListCache unmarshal failed", log.Any("url", apiURL), log.E(err))
|
||||
return
|
||||
}
|
||||
if remote.Code != 200 {
|
||||
log.Warn("UpdateAppListCache bad code", log.Any("url", apiURL), log.Any("code", remote.Code))
|
||||
return
|
||||
}
|
||||
|
||||
config := configser.GetConfigInfo()
|
||||
advitise := configser.GetAdvitise()
|
||||
channelCode := config.ChannelCode
|
||||
|
||||
categoryMaps := make(map[int64]proto.AppCategoryInfo)
|
||||
var appList []proto.AppCategoryInfo
|
||||
|
||||
for _, category := range remote.Data.List {
|
||||
var apps []proto.AppInfoData
|
||||
for _, app := range category.AppList {
|
||||
// 过滤掉本应用自身
|
||||
if strings.HasPrefix(app.Name, pubdef.AppName) {
|
||||
continue
|
||||
}
|
||||
apps = append(apps, proto.AppInfoData{
|
||||
Id: app.Id,
|
||||
CategoryId: app.CategoryId,
|
||||
Name: app.Name,
|
||||
Intro: app.Intro,
|
||||
Description: app.Description,
|
||||
Logo: netutil.MergeURL(config.ImgCdn, app.Logo),
|
||||
Url: appendChannelCode(app.Url, channelCode),
|
||||
Sort: app.Sort,
|
||||
})
|
||||
}
|
||||
// 广告位 A(code=12)注入图标广告
|
||||
if category.Code == 12 {
|
||||
for _, ad := range advitise.AList {
|
||||
apps = append(apps, proto.AppInfoData{
|
||||
AdvitiseId: ad.Id,
|
||||
CategoryId: category.Id,
|
||||
Name: ad.Title,
|
||||
Logo: netutil.MergeURL(config.ImgCdn, ad.Images),
|
||||
Url: ad.Link,
|
||||
Location: ad.Location,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 广告位 B(code=13)注入图标文字广告
|
||||
if category.Code == 13 {
|
||||
for _, ad := range advitise.BList {
|
||||
apps = append(apps, proto.AppInfoData{
|
||||
AdvitiseId: ad.Id,
|
||||
CategoryId: category.Id,
|
||||
Name: ad.Title,
|
||||
Logo: netutil.MergeURL(config.ImgCdn, ad.Images),
|
||||
Url: ad.Link,
|
||||
Location: ad.Location,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 空分类不展示
|
||||
if len(apps) == 0 {
|
||||
continue
|
||||
}
|
||||
category.AppList = apps
|
||||
categoryMaps[category.Id] = proto.AppCategoryInfo{
|
||||
Id: category.Id,
|
||||
Title: category.Title,
|
||||
Intro: category.Intro,
|
||||
Sort: category.Sort,
|
||||
Code: category.Code,
|
||||
}
|
||||
appList = append(appList, category)
|
||||
}
|
||||
|
||||
appListCache.lock.Lock()
|
||||
appListCache.appList = appList
|
||||
appListCache.categoryMaps = categoryMaps
|
||||
appListCache.lock.Unlock()
|
||||
}
|
||||
|
||||
// appendChannelCode 为应用 URL 拼接渠道码参数 dc=xxx;已含 dc 参数则不再拼接
|
||||
func appendChannelCode(rawURL, channelCode string) string {
|
||||
if len(channelCode) == 0 {
|
||||
channelCode = pubdef.DefaultChannelCode
|
||||
}
|
||||
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
channelCode = strings.TrimSpace(channelCode)
|
||||
if rawURL == "" || channelCode == "" {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
if query.Has("dc") {
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
query.Set("dc", channelCode)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"time"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/enum/consts"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/utils"
|
||||
"videoweb/pub/models/usermod"
|
||||
"videoweb/pub/mongos"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var tokener = utils.NewTokenRedis()
|
||||
|
||||
var defaultAvatars = []string{
|
||||
"v3/image/bo/ty/1016/girl1.jpeg",
|
||||
"v3/image/re/9y/1016/girl2.jpeg",
|
||||
"v3/image/5f/k4/1016/girl3.jpeg",
|
||||
"v3/image/ej/ss/1016/girl4.jpeg",
|
||||
"v3/image/yq/ys/1016/girl5.jpeg",
|
||||
"v3/image/9e/d3/1016/girl6.jpeg",
|
||||
}
|
||||
|
||||
func randomAvatar() string {
|
||||
return defaultAvatars[time.Now().UnixNano()%int64(len(defaultAvatars))]
|
||||
}
|
||||
|
||||
// generateUniqueInviteCode 生成 8 位唯一邀请码(含大小写字母),最多重试 10 次
|
||||
func generateUniqueInviteCode(ctx context.Context) string {
|
||||
for i := 0; i < 10; i++ {
|
||||
code := utils.GetRandomLetters(8)
|
||||
exist, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.InviteCode: code})
|
||||
if exist == nil {
|
||||
return code
|
||||
}
|
||||
log.Warn("authser:generateUniqueInviteCode collision, retrying", log.Any("code", code))
|
||||
}
|
||||
// 极小概率走到这里:用更长的随机串兜底
|
||||
return utils.GetRandomLetters(12)
|
||||
}
|
||||
|
||||
// LoginResp 登录/注册成功后的响应数据
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
UserId int64 `json:"userId"`
|
||||
AccountNo string `json:"accountNo"`
|
||||
NickName string `json:"nickName"`
|
||||
Status pubdef.UserStatus `json:"status"`
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
InviteCode string `json:"inviteCode"` // 邀请码
|
||||
LoginType pubdef.LoginType `json:"loginType"` // 登录方式 0=设备 3=账号
|
||||
IsRegister bool `json:"isRegister"` // 是否已完成账号注册
|
||||
IsNewUser bool `json:"isNewUser"` // 是否本次请求新创建的用户
|
||||
Recharge int64 `json:"recharge"` // 当前金币个数
|
||||
}
|
||||
|
||||
// UserInfoResp 当前登录用户信息
|
||||
type UserInfoResp struct {
|
||||
UserId int64 `json:"userId"`
|
||||
AccountNo string `json:"accountNo"`
|
||||
NickName string `json:"nickName"`
|
||||
Status pubdef.UserStatus `json:"status"`
|
||||
Avatar string `json:"avatar"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
LoginType pubdef.LoginType `json:"loginType"`
|
||||
IsRegister bool `json:"isRegister"`
|
||||
Recharge int64 `json:"recharge"`
|
||||
}
|
||||
|
||||
// UAInfo 从 UA 中间件提取的设备/系统信息
|
||||
type UAInfo struct {
|
||||
Device string // 设备型号(DevType,如 "iPhone")
|
||||
SysType pubdef.SysType // 系统类型(ios / android / h5 等)
|
||||
AppVer string // App 版本号
|
||||
IP string // 客户端 IP
|
||||
}
|
||||
|
||||
// GuestLoginReq 游客登录请求
|
||||
type GuestLoginReq struct {
|
||||
DevID string // 设备 ID(首选)
|
||||
Mac string // 设备 MAC 地址(备用)
|
||||
DeviceId string // 设备 ID 第二版本(备用)
|
||||
UA UAInfo // 设备/系统信息
|
||||
}
|
||||
|
||||
// hashPassword 使用 bcrypt 对明文密码进行哈希
|
||||
func hashPassword(plain string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// checkPassword 验证明文密码与 bcrypt 哈希是否匹配
|
||||
func checkPassword(plain, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// generateAndSaveToken 生成 JWT 并存入 Redis
|
||||
func generateAndSaveToken(userId int64) (string, *srvdef.SrvErr) {
|
||||
token, err := utils.GenerateUserToken(utils.UserClaims{
|
||||
UserID: userId,
|
||||
Expire: consts.UserJwtTokenExpire,
|
||||
}, appg.Conf.JwtKey)
|
||||
if err != nil {
|
||||
return "", srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "")
|
||||
}
|
||||
if err = tokener.Save(userId, token); err != nil {
|
||||
return "", srvdef.New(srvdef.StoreLevel, "token存储失败", "")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GuestLogin 游客登录:按设备 ID 查找已有用户,找不到则自动注册
|
||||
// 若请求携带有效 token(currentUserId != 0),优先复用该账号
|
||||
func GuestLogin(ctx context.Context, req *GuestLoginReq, currentUserId int64) (*LoginResp, *srvdef.SrvErr) {
|
||||
var user *usermod.User
|
||||
|
||||
// 优先用 token 对应的已有用户
|
||||
if currentUserId > 0 {
|
||||
u, _ := usermod.FindOneUserById(ctx, currentUserId)
|
||||
if u != nil {
|
||||
user = u
|
||||
}
|
||||
}
|
||||
|
||||
// 按设备 ID 查找
|
||||
if user == nil {
|
||||
user, _ = findUserByDevice(ctx, req.DevID, req.Mac, req.DeviceId)
|
||||
}
|
||||
|
||||
isNewUser := false
|
||||
if user == nil {
|
||||
// 未找到 → 自动注册游客
|
||||
var serr *srvdef.SrvErr
|
||||
user, serr = registerGuest(ctx, req)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
isNewUser = true
|
||||
} else {
|
||||
// 已有用户:更新设备信息和 UA(静默,不影响主流程)
|
||||
updateSet := bson.M{
|
||||
usermod.FN.User.LastLoginAt: time.Now(),
|
||||
usermod.FN.User.UpdatedAt: time.Now(),
|
||||
}
|
||||
if req.DevID != "" {
|
||||
updateSet[usermod.FN.User.DevID] = req.DevID
|
||||
}
|
||||
if req.Mac != "" && req.Mac != "00:00:00:00:00:00" {
|
||||
updateSet[usermod.FN.User.Mac] = req.Mac
|
||||
}
|
||||
if req.DeviceId != "" {
|
||||
updateSet[usermod.FN.User.DevV2] = req.DeviceId
|
||||
}
|
||||
if req.UA.Device != "" {
|
||||
updateSet[usermod.FN.User.Device] = req.UA.Device
|
||||
}
|
||||
if req.UA.SysType != "" {
|
||||
updateSet[usermod.FN.User.SysType] = req.UA.SysType.String()
|
||||
}
|
||||
if req.UA.AppVer != "" {
|
||||
updateSet[usermod.FN.User.AppVer] = req.UA.AppVer
|
||||
}
|
||||
if req.UA.IP != "" {
|
||||
updateSet[usermod.FN.User.LastIp] = req.UA.IP
|
||||
}
|
||||
_ = usermod.UpdateOneUser(ctx,
|
||||
bson.M{usermod.FN.User.Id: user.Id},
|
||||
bson.M{"$set": updateSet},
|
||||
)
|
||||
}
|
||||
|
||||
if user.Status == pubdef.UserStatusDisable {
|
||||
return nil, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
|
||||
}
|
||||
|
||||
token, serr := generateAndSaveToken(user.Id)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
cdn := configser.ImageCdnFromCtx(ctx)
|
||||
return &LoginResp{
|
||||
Token: token,
|
||||
UserId: user.Id,
|
||||
AccountNo: user.AccountNo,
|
||||
NickName: user.NickName,
|
||||
Status: user.Status,
|
||||
Avatar: netutil.MergeURL(cdn, user.Avatar),
|
||||
InviteCode: user.InviteCode,
|
||||
LoginType: user.LoginType,
|
||||
IsRegister: user.IsRegister,
|
||||
IsNewUser: isNewUser,
|
||||
Recharge: user.Recharge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetInfo 根据当前登录 token 解析出的用户 ID 查询用户信息
|
||||
func GetInfo(ctx context.Context, userId int64) (*UserInfoResp, *srvdef.SrvErr) {
|
||||
if userId <= 0 {
|
||||
return nil, srvdef.New(srvdef.InvalidToken, srvdef.InvalidToken.Msg(), "")
|
||||
}
|
||||
user, serr := usermod.FindOneUserById(ctx, userId, options.FindOne().SetProjection(userInfoProjection()))
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
if user == nil {
|
||||
return nil, srvdef.New(srvdef.ErrUserNotExsit, srvdef.ErrUserNotExsit.Msg(), "")
|
||||
}
|
||||
if user.Status == pubdef.UserStatusDisable {
|
||||
return nil, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
|
||||
}
|
||||
cdn := configser.ImageCdnFromCtx(ctx)
|
||||
return &UserInfoResp{
|
||||
UserId: user.Id,
|
||||
AccountNo: user.AccountNo,
|
||||
NickName: user.NickName,
|
||||
Status: user.Status,
|
||||
Avatar: netutil.MergeURL(cdn, user.Avatar),
|
||||
InviteCode: user.InviteCode,
|
||||
LoginType: user.LoginType,
|
||||
IsRegister: user.IsRegister,
|
||||
Recharge: user.Recharge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userInfoProjection() bson.M {
|
||||
return bson.M{
|
||||
usermod.FN.User.Id: 1,
|
||||
usermod.FN.User.AccountNo: 1,
|
||||
usermod.FN.User.NickName: 1,
|
||||
usermod.FN.User.Status: 1,
|
||||
usermod.FN.User.Avatar: 1,
|
||||
usermod.FN.User.InviteCode: 1,
|
||||
usermod.FN.User.LoginType: 1,
|
||||
usermod.FN.User.IsRegister: 1,
|
||||
usermod.FN.User.Recharge: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// findUserByDevice 按设备 ID 三级回退查找用户(DevID → Mac → DeviceId)
|
||||
func findUserByDevice(ctx context.Context, devID, mac, deviceId string) (*usermod.User, *srvdef.SrvErr) {
|
||||
invalidMac := "00:00:00:00:00:00"
|
||||
if devID != "" {
|
||||
u, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.DevID: devID})
|
||||
if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
if mac != "" && mac != invalidMac {
|
||||
u, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Mac: mac})
|
||||
if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
if deviceId != "" && deviceId != invalidMac {
|
||||
u, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.DevV2: deviceId})
|
||||
if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// registerGuest 自动创建游客用户
|
||||
func registerGuest(ctx context.Context, req *GuestLoginReq) (*usermod.User, *srvdef.SrvErr) {
|
||||
now := time.Now()
|
||||
user := &usermod.User{
|
||||
Id: mongos.Lid.User.Get(),
|
||||
NickName: getName(),
|
||||
Avatar: randomAvatar(),
|
||||
InviteCode: generateUniqueInviteCode(ctx),
|
||||
Status: pubdef.UserStatusNormal,
|
||||
LoginType: pubdef.LoginTypeDevice,
|
||||
IsRegister: false,
|
||||
DevID: req.DevID,
|
||||
Mac: req.Mac,
|
||||
DevV2: req.DeviceId,
|
||||
Device: req.UA.Device,
|
||||
SysType: req.UA.SysType.String(),
|
||||
AppVer: req.UA.AppVer,
|
||||
OriVer: req.UA.AppVer,
|
||||
RegIp: req.UA.IP,
|
||||
LastIp: req.UA.IP,
|
||||
RegisterTime: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if serr := usermod.InsertOneUser(ctx, user); serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Register 账号注册
|
||||
// currentUserId != 0 时为游客升级模式:在已有游客账号上绑定账号密码
|
||||
// currentUserId == 0 时为直接注册新账号
|
||||
func Register(ctx context.Context, accountNo, password, agentInviteCode string, currentUserId int64, ua UAInfo) (*LoginResp, *srvdef.SrvErr) {
|
||||
// 检查 accountNo 是否已被其他用户占用
|
||||
exist, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.AccountNo: accountNo})
|
||||
if exist != nil && exist.Id != currentUserId {
|
||||
return nil, srvdef.New(srvdef.ErrDbInputExist, "账号已存在", "")
|
||||
}
|
||||
|
||||
hashed, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return nil, srvdef.New(srvdef.Failure, "密码处理失败", "")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 游客升级:更新已有记录
|
||||
if currentUserId > 0 {
|
||||
user, _ := usermod.FindOneUserById(ctx, currentUserId)
|
||||
if user == nil {
|
||||
return nil, srvdef.New(srvdef.ErrUserNotExsit, "用户不存在", "")
|
||||
}
|
||||
if user.IsRegister {
|
||||
return nil, srvdef.New(srvdef.ErrDbInputExist, "已完成账号注册,无需重复操作", "")
|
||||
}
|
||||
setDoc := bson.M{
|
||||
usermod.FN.User.AccountNo: accountNo,
|
||||
usermod.FN.User.Password: hashed,
|
||||
usermod.FN.User.IsRegister: true,
|
||||
usermod.FN.User.LoginType: pubdef.LoginTypeAccount,
|
||||
usermod.FN.User.UpdatedAt: now,
|
||||
}
|
||||
if agentInviteCode != "" && user.AgentInviteCode == "" {
|
||||
setDoc[usermod.FN.User.AgentInviteCode] = agentInviteCode
|
||||
}
|
||||
if ua.Device != "" {
|
||||
setDoc[usermod.FN.User.Device] = ua.Device
|
||||
}
|
||||
if ua.SysType != "" {
|
||||
setDoc[usermod.FN.User.SysType] = ua.SysType.String()
|
||||
}
|
||||
if ua.AppVer != "" {
|
||||
setDoc[usermod.FN.User.AppVer] = ua.AppVer
|
||||
}
|
||||
if ua.IP != "" {
|
||||
setDoc[usermod.FN.User.LastIp] = ua.IP
|
||||
}
|
||||
if serr := usermod.UpdateOneUser(ctx,
|
||||
bson.M{usermod.FN.User.Id: currentUserId},
|
||||
bson.M{"$set": setDoc},
|
||||
); serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
// 给邀请人 +1
|
||||
if agentInviteCode != "" {
|
||||
_ = usermod.UpdateOneUser(ctx,
|
||||
bson.M{usermod.FN.User.InviteCode: agentInviteCode},
|
||||
bson.M{"$inc": bson.M{usermod.FN.User.Invites: 1},
|
||||
"$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()}},
|
||||
)
|
||||
}
|
||||
token, serr := generateAndSaveToken(user.Id)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
cdn := configser.ImageCdnFromCtx(ctx)
|
||||
return &LoginResp{
|
||||
Token: token,
|
||||
UserId: user.Id,
|
||||
AccountNo: accountNo,
|
||||
NickName: user.NickName,
|
||||
Status: user.Status,
|
||||
Avatar: netutil.MergeURL(cdn, user.Avatar),
|
||||
InviteCode: user.InviteCode,
|
||||
LoginType: pubdef.LoginTypeAccount,
|
||||
IsRegister: true,
|
||||
Recharge: user.Recharge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 未登录:直接注册新账号
|
||||
user := &usermod.User{
|
||||
Id: mongos.Lid.User.Get(),
|
||||
AccountNo: accountNo,
|
||||
Password: hashed,
|
||||
NickName: getName(),
|
||||
Avatar: randomAvatar(),
|
||||
InviteCode: generateUniqueInviteCode(ctx),
|
||||
AgentInviteCode: agentInviteCode,
|
||||
Status: pubdef.UserStatusNormal,
|
||||
IsRegister: true,
|
||||
LoginType: pubdef.LoginTypeAccount,
|
||||
Device: ua.Device,
|
||||
SysType: ua.SysType.String(),
|
||||
AppVer: ua.AppVer,
|
||||
OriVer: ua.AppVer,
|
||||
RegIp: ua.IP,
|
||||
LastIp: ua.IP,
|
||||
RegisterTime: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if serr := usermod.InsertOneUser(ctx, user); serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
if agentInviteCode != "" {
|
||||
_ = usermod.UpdateOneUser(ctx,
|
||||
bson.M{usermod.FN.User.InviteCode: agentInviteCode},
|
||||
bson.M{
|
||||
"$inc": bson.M{usermod.FN.User.Invites: 1},
|
||||
"$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()},
|
||||
},
|
||||
)
|
||||
}
|
||||
token, serr := generateAndSaveToken(user.Id)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
cdn := configser.ImageCdnFromCtx(ctx)
|
||||
return &LoginResp{
|
||||
Token: token,
|
||||
UserId: user.Id,
|
||||
AccountNo: user.AccountNo,
|
||||
NickName: user.NickName,
|
||||
Status: user.Status,
|
||||
Avatar: netutil.MergeURL(cdn, user.Avatar),
|
||||
InviteCode: user.InviteCode,
|
||||
LoginType: pubdef.LoginTypeAccount,
|
||||
IsRegister: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login 用户登录(账号均可)
|
||||
func Login(ctx context.Context, account, password string, ua UAInfo) (*LoginResp, *srvdef.SrvErr) {
|
||||
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.AccountNo: account})
|
||||
if user == nil {
|
||||
return nil, srvdef.New(srvdef.AccountIsNotExists, srvdef.AccountIsNotExists.Msg(), "")
|
||||
}
|
||||
|
||||
if user.Status == pubdef.UserStatusDisable {
|
||||
return nil, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
|
||||
}
|
||||
|
||||
if !checkPassword(password, user.Password) {
|
||||
return nil, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "")
|
||||
}
|
||||
|
||||
token, serr := generateAndSaveToken(user.Id)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
|
||||
// 更新最后登录时间、IP 及 UA(fire-and-forget)
|
||||
loginSet := bson.M{
|
||||
usermod.FN.User.LastLoginAt: time.Now(),
|
||||
usermod.FN.User.UpdatedAt: time.Now(),
|
||||
}
|
||||
if ua.IP != "" {
|
||||
loginSet[usermod.FN.User.LastIp] = ua.IP
|
||||
}
|
||||
if ua.Device != "" {
|
||||
loginSet[usermod.FN.User.Device] = ua.Device
|
||||
}
|
||||
if ua.SysType != "" {
|
||||
loginSet[usermod.FN.User.SysType] = ua.SysType.String()
|
||||
}
|
||||
if ua.AppVer != "" {
|
||||
loginSet[usermod.FN.User.AppVer] = ua.AppVer
|
||||
}
|
||||
_ = usermod.UpdateOneUser(ctx,
|
||||
bson.M{usermod.FN.User.Id: user.Id},
|
||||
bson.M{"$set": loginSet},
|
||||
)
|
||||
cdn := configser.ImageCdnFromCtx(ctx)
|
||||
return &LoginResp{
|
||||
Token: token,
|
||||
UserId: user.Id,
|
||||
AccountNo: user.AccountNo,
|
||||
NickName: user.NickName,
|
||||
Status: user.Status,
|
||||
Avatar: netutil.MergeURL(cdn, user.Avatar),
|
||||
InviteCode: user.InviteCode,
|
||||
LoginType: user.LoginType,
|
||||
IsRegister: user.IsRegister,
|
||||
Recharge: user.Recharge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateProfileReq 修改用户资料的参数(所有字段均为可选)
|
||||
type UpdateProfileReq struct {
|
||||
AccountNo string // 用户账号(用于定位用户,必传)
|
||||
Avatar *string // 新头像 URL(可选)
|
||||
NikeName *string // 昵称
|
||||
OldPassword *string // 旧密码明文(修改密码时必传)
|
||||
NewPassword *string // 新密码明文(修改密码时必传)
|
||||
}
|
||||
|
||||
// UpdateProfile 修改用户资料:支持修改头像、密码,或两者同时修改
|
||||
func UpdateProfile(ctx context.Context, req *UpdateProfileReq) *srvdef.SrvErr {
|
||||
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.AccountNo: req.AccountNo})
|
||||
if user == nil {
|
||||
return srvdef.New(srvdef.ErrUserNotExsit, srvdef.ErrUserNotExsit.Msg(), "")
|
||||
}
|
||||
|
||||
setDoc := bson.M{usermod.FN.User.UpdatedAt: time.Now()}
|
||||
|
||||
// 修改头像
|
||||
if req.Avatar != nil && *req.Avatar != "" {
|
||||
setDoc[usermod.FN.User.Avatar] = *req.Avatar
|
||||
}
|
||||
if req.NikeName != nil && *req.NikeName != "" {
|
||||
setDoc[usermod.FN.User.NickName] = *req.NikeName
|
||||
}
|
||||
|
||||
// 修改密码:两者必须同时提供
|
||||
if req.OldPassword != nil || req.NewPassword != nil {
|
||||
if req.OldPassword == nil || req.NewPassword == nil {
|
||||
return srvdef.New(srvdef.ErrParamError, "修改密码时旧密码和新密码均需提供", "")
|
||||
}
|
||||
if !checkPassword(*req.OldPassword, user.Password) {
|
||||
return srvdef.New(srvdef.AccountErrPasswordError, "旧密码错误", "")
|
||||
}
|
||||
hashed, err := hashPassword(*req.NewPassword)
|
||||
if err != nil {
|
||||
return srvdef.New(srvdef.Failure, "密码处理失败", "")
|
||||
}
|
||||
setDoc[usermod.FN.User.Password] = hashed
|
||||
}
|
||||
|
||||
// 没有任何需要更新的字段
|
||||
if len(setDoc) == 1 {
|
||||
return srvdef.New(srvdef.ErrParamError, "至少需要修改一项资料", "")
|
||||
}
|
||||
|
||||
return usermod.UpdateOneUser(ctx,
|
||||
bson.M{usermod.FN.User.Id: user.Id},
|
||||
bson.M{"$set": setDoc},
|
||||
)
|
||||
}
|
||||
|
||||
func getName() string {
|
||||
const limit = 300
|
||||
totalCnt, err := redis.SCard(redis.NameSource)
|
||||
if err != nil || totalCnt < limit {
|
||||
if len(appg.Name.FamilyNames) == 0 || len(appg.Name.AfterNames) == 0 {
|
||||
// fmt.Println(totalCnt, err, appg.Name.FamilyNames)
|
||||
return "游客" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
for i := 0; i < limit/100; i++ {
|
||||
go func() {
|
||||
var names []string
|
||||
for j := 0; j < limit/3; j++ {
|
||||
length := 2 + rand.Intn(2)
|
||||
if length == 2 {
|
||||
name := appg.Name.FamilyNames[utils.RandInt(0, len(appg.Name.FamilyNames))] +
|
||||
appg.Name.AfterNames[utils.RandInt(0, len(appg.Name.AfterNames))]
|
||||
names = append(names, name)
|
||||
|
||||
}
|
||||
if length == 3 {
|
||||
name := appg.Name.FamilyNames[utils.RandInt(0, len(appg.Name.FamilyNames))] +
|
||||
appg.Name.MiddleNames[utils.RandInt(0, len(appg.Name.MiddleNames))] +
|
||||
appg.Name.AfterNames[utils.RandInt(0, len(appg.Name.AfterNames))]
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
_ = redis.SAdd(redis.NameSource, names)
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_ = redis.Expire(redis.NameSource, redis.NameSourceExpire)
|
||||
}
|
||||
|
||||
name, err := redis.SPop(redis.NameSource)
|
||||
if name == "" || err != nil {
|
||||
return "游客" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/app/service/reportser"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/response"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/useragent"
|
||||
"videoweb/pub/models/commentlikemod"
|
||||
"videoweb/pub/models/commentmod"
|
||||
"videoweb/pub/models/playletchaptermod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/usermod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/mongos"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
commentListLimit int64 = 200
|
||||
subCommentListLimit int64 = 400
|
||||
commentTreeDefaultPageSize int64 = 10
|
||||
commentTreeMaxPageSize int64 = 100
|
||||
commentTreeMaxDepth int = 5
|
||||
)
|
||||
|
||||
type commentTreeNode struct {
|
||||
info proto.CommentInfo
|
||||
children []*commentTreeNode
|
||||
}
|
||||
|
||||
// ListCommentTree 分页查询指定对象评论列表,并按父子关系组装树形结构
|
||||
func ListCommentTree(ctx context.Context, userId int64, req *proto.CommentListReq) (*proto.CommentListResp, *srvdef.SrvErr) {
|
||||
if req.ObjectId <= 0 {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "对象ID不能为空")
|
||||
}
|
||||
if !pubdef.ValidCommentObjectType(req.ObjectType) {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "评论类型有误")
|
||||
}
|
||||
if req.SortOrder != 0 && req.SortOrder != 1 && req.SortOrder != 2 {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "排序方式有误")
|
||||
}
|
||||
pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, commentTreeDefaultPageSize, commentTreeMaxPageSize)
|
||||
filter := bson.M{
|
||||
commentmod.FN.Comment.ObjectId: req.ObjectId,
|
||||
commentmod.FN.Comment.ObjectType: req.ObjectType,
|
||||
commentmod.FN.Comment.ReviewStatus: 1,
|
||||
commentmod.FN.Comment.Level: 1,
|
||||
}
|
||||
total, err := commentmod.CountComments(ctx, filter)
|
||||
if err != nil {
|
||||
log.Warn("ListCommentTree.CountComments", log.Any("filter", filter), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
resp := &proto.CommentListResp{BasePageResp: response.BasePageResp{
|
||||
TotalCount: total,
|
||||
PageSize: int(pageSize),
|
||||
PageNum: int(pageNum),
|
||||
List: make([]proto.CommentInfo, 0),
|
||||
}}
|
||||
if total == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
imgCdn := configser.ImageCdnFromCtx(ctx)
|
||||
topList, err := commentmod.FindManyComment(ctx, filter, options.Find().
|
||||
SetProjection(commentListProjection()).
|
||||
SetSort(commentListSort(req.SortOrder)).
|
||||
SetSkip((pageNum-1)*pageSize).
|
||||
SetLimit(pageSize))
|
||||
if err != nil {
|
||||
log.Warn("ListCommentTree.FindManyComment", log.Any("filter", filter), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
children, err := commentTreeChildren(ctx, req.ObjectId, req.ObjectType, collectCommentIds(topList))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := append(collectCommentIds(topList), collectCommentIds(children)...)
|
||||
likes, err := commentlikemod.FindByUserAndComments(ctx, userId, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
liked := make(map[int64]bool, len(likes))
|
||||
for _, like := range likes {
|
||||
liked[like.CommentId] = true
|
||||
}
|
||||
resp.List = buildCommentTree(topList, children, imgCdn, liked)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// commentListSort 热门按点赞数排序,默认按发布时间排序
|
||||
func commentListSort(sortOrder int64) bson.D {
|
||||
if sortOrder == 1 {
|
||||
return bson.D{{Key: commentmod.FN.Comment.CountLike, Value: -1}, {Key: commentmod.FN.Comment.Id, Value: -1}}
|
||||
}
|
||||
return bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}, {Key: commentmod.FN.Comment.Id, Value: -1}}
|
||||
}
|
||||
|
||||
// GetCommentList 查询指定对象的首屏评论列表,并缓存短时间内重复读取
|
||||
func GetCommentList(ctx context.Context, objectId int64, objectType pubdef.CommentObjectType) (*proto.GetCommentListResp, *srvdef.SrvErr) {
|
||||
cacheKey := redisconst.CommentListCacheKey(objectId, objectType)
|
||||
cacheData, cacheErr := redis.Get(cacheKey)
|
||||
if cacheErr != nil {
|
||||
log.Warn("获取缓存失败", log.Any("cacheKey", cacheKey), log.E(cacheErr))
|
||||
}
|
||||
if cacheData != nil {
|
||||
var commentListResp *proto.GetCommentListResp
|
||||
if err := json.Unmarshal([]byte(*cacheData), &commentListResp); err != nil {
|
||||
log.Warn("解析缓存失败", log.Any("cacheKey", cacheKey), log.E(err))
|
||||
} else if commentListResp != nil {
|
||||
configser.RewriteImageCdn(ctx, commentListResp)
|
||||
return commentListResp, nil
|
||||
}
|
||||
}
|
||||
data, err := commentList(ctx, objectId, objectType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, marshalErr := json.Marshal(data)
|
||||
if marshalErr != nil {
|
||||
log.Warn("解析缓存失败", log.Any("cacheKey", cacheKey), log.E(marshalErr))
|
||||
configser.RewriteImageCdn(ctx, data)
|
||||
return data, nil
|
||||
}
|
||||
if setErr := redis.Set(cacheKey, string(bytes), redisconst.CommentListKeyExpire); setErr != nil {
|
||||
log.Warn("设置缓存失败", log.Any("cacheKey", cacheKey), log.E(setErr))
|
||||
}
|
||||
configser.RewriteImageCdn(ctx, data)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// commentList 查询顶级评论,并批量补齐二级回复,避免逐条查询子评论
|
||||
func commentList(ctx context.Context, objectId int64, objectType pubdef.CommentObjectType) (*proto.GetCommentListResp, *srvdef.SrvErr) {
|
||||
filter := bson.M{
|
||||
commentmod.FN.Comment.ObjectId: objectId,
|
||||
commentmod.FN.Comment.ObjectType: objectType,
|
||||
commentmod.FN.Comment.ReviewStatus: 1,
|
||||
commentmod.FN.Comment.Level: 1,
|
||||
}
|
||||
total, err := commentmod.CountComments(ctx, filter)
|
||||
if err != nil {
|
||||
log.Warn("commentList.CountComments", log.Any("filter", filter), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
resp := &proto.GetCommentListResp{
|
||||
ObjectId: objectId,
|
||||
Total: total,
|
||||
List: make([]proto.CommentInfo, 0),
|
||||
}
|
||||
if total == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
imgCdn := configser.GetConfigInfo().ImgCdn
|
||||
list, err := commentmod.FindManyComment(ctx, filter, options.Find().
|
||||
SetProjection(commentListProjection()).
|
||||
SetSort(bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}}).
|
||||
SetLimit(commentListLimit))
|
||||
if err != nil {
|
||||
log.Warn("commentList.FindManyComment", log.Any("filter", filter), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
parentIds := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
parentIds = append(parentIds, item.Id)
|
||||
}
|
||||
subCommentMap, err := subCommentList(ctx, parentIds, imgCdn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
info := commentInfoData(item, imgCdn)
|
||||
info.SubComment = subCommentMap[item.Id]
|
||||
resp.List = append(resp.List, info)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// subCommentList 批量查询二级评论,并按父评论ID分组
|
||||
func subCommentList(ctx context.Context, parentIds []int64, imgCdn string) (map[int64][]proto.CommentInfo, *srvdef.SrvErr) {
|
||||
data := make(map[int64][]proto.CommentInfo, len(parentIds))
|
||||
if len(parentIds) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
list, err := commentmod.FindManyComment(ctx, bson.M{
|
||||
commentmod.FN.Comment.ParentsId: bson.M{"$in": parentIds},
|
||||
commentmod.FN.Comment.ReviewStatus: 1,
|
||||
}, options.Find().
|
||||
SetProjection(commentListProjection()).
|
||||
SetSort(bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}}).
|
||||
SetLimit(subCommentListLimit))
|
||||
if err != nil {
|
||||
log.Warn("subCommentList.FindManyComment", log.Any("parentIds", parentIds), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
data[item.ParentsId] = append(data[item.ParentsId], commentInfoData(item, imgCdn))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// commentTreeChildren 按层批量查询当前页一级评论的子孙评论,避免 N+1 查询
|
||||
func commentTreeChildren(ctx context.Context, objectId int64, objectType pubdef.CommentObjectType, parentIds []int64) (commentmod.CommentModelList, *srvdef.SrvErr) {
|
||||
data := make(commentmod.CommentModelList, 0)
|
||||
currentParentIds := uniqueCommentIds(parentIds)
|
||||
if len(currentParentIds) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
seenIds := make(map[int64]struct{}, len(currentParentIds))
|
||||
for _, id := range currentParentIds {
|
||||
seenIds[id] = struct{}{}
|
||||
}
|
||||
for depth := 0; len(currentParentIds) > 0 && depth < commentTreeMaxDepth; depth++ {
|
||||
filter := bson.M{
|
||||
commentmod.FN.Comment.ObjectId: objectId,
|
||||
commentmod.FN.Comment.ObjectType: objectType,
|
||||
commentmod.FN.Comment.ParentsId: bson.M{"$in": currentParentIds},
|
||||
commentmod.FN.Comment.ReviewStatus: 1,
|
||||
}
|
||||
list, err := commentmod.FindManyComment(ctx, filter, options.Find().
|
||||
SetProjection(commentListProjection()).
|
||||
SetSort(bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}}))
|
||||
if err != nil {
|
||||
log.Warn("commentTreeChildren.FindManyComment", log.Any("filter", filter), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
nextParentIds := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenIds[item.Id]; ok {
|
||||
continue
|
||||
}
|
||||
seenIds[item.Id] = struct{}{}
|
||||
data = append(data, item)
|
||||
nextParentIds = append(nextParentIds, item.Id)
|
||||
}
|
||||
currentParentIds = nextParentIds
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// buildCommentTree 将一级评论和子孙评论按 parentsId 组装为树形结构
|
||||
func buildCommentTree(topList commentmod.CommentModelList, children commentmod.CommentModelList, imgCdn string, liked map[int64]bool) []proto.CommentInfo {
|
||||
nodes := make(map[int64]*commentTreeNode, len(topList)+len(children))
|
||||
roots := make([]*commentTreeNode, 0, len(topList))
|
||||
for _, item := range topList {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
info := commentInfoData(item, imgCdn)
|
||||
info.IsLike = liked[item.Id]
|
||||
node := &commentTreeNode{info: info}
|
||||
nodes[item.Id] = node
|
||||
roots = append(roots, node)
|
||||
}
|
||||
for _, item := range children {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
info := commentInfoData(item, imgCdn)
|
||||
info.IsLike = liked[item.Id]
|
||||
nodes[item.Id] = &commentTreeNode{info: info}
|
||||
}
|
||||
for _, item := range children {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
parentNode, ok := nodes[item.ParentsId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if node, ok := nodes[item.Id]; ok {
|
||||
parentNode.children = append(parentNode.children, node)
|
||||
}
|
||||
}
|
||||
return commentTreeNodesToInfos(roots)
|
||||
}
|
||||
|
||||
// commentTreeNodesToInfos 递归输出评论树响应
|
||||
func commentTreeNodesToInfos(nodes []*commentTreeNode) []proto.CommentInfo {
|
||||
list := make([]proto.CommentInfo, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
info := node.info
|
||||
info.SubComment = commentTreeNodesToInfos(node.children)
|
||||
list = append(list, info)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// collectCommentIds 收集评论ID集合
|
||||
func collectCommentIds(list commentmod.CommentModelList) []int64 {
|
||||
ids := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil || item.Id <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, item.Id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// uniqueCommentIds 去重评论ID集合
|
||||
func uniqueCommentIds(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
result := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// commentInfoData 将评论模型转换为 APP 评论响应结构
|
||||
func commentInfoData(item *commentmod.CommentModel, imgCdn string) proto.CommentInfo {
|
||||
return proto.CommentInfo{
|
||||
Id: item.Id,
|
||||
ObjectId: item.ObjectId,
|
||||
UserName: item.UserName,
|
||||
Avatar: netutil.MergeURL(imgCdn, item.Avatar),
|
||||
Level: item.Level,
|
||||
ParentsId: item.ParentsId,
|
||||
ReplyUserName: item.ReplyUserName,
|
||||
Text: item.Text,
|
||||
CommentCount: item.CommentCount,
|
||||
CountLike: item.CountLike,
|
||||
SubComment: make([]proto.CommentInfo, 0),
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// commentListProjection 限定评论列表字段,减少详情页评论查询的数据传输
|
||||
func commentListProjection() bson.M {
|
||||
return bson.M{
|
||||
commentmod.FN.Comment.Id: 1,
|
||||
commentmod.FN.Comment.ObjectId: 1,
|
||||
commentmod.FN.Comment.UserName: 1,
|
||||
commentmod.FN.Comment.Avatar: 1,
|
||||
commentmod.FN.Comment.Level: 1,
|
||||
commentmod.FN.Comment.ParentsId: 1,
|
||||
commentmod.FN.Comment.ReplyUserName: 1,
|
||||
commentmod.FN.Comment.Text: 1,
|
||||
commentmod.FN.Comment.CommentCount: 1,
|
||||
commentmod.FN.Comment.CountLike: 1,
|
||||
commentmod.FN.Comment.CreatedAt: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func SendComment(ctx context.Context, ip string, userId int64, req *proto.CommentSendReq, ua useragent.UA) *srvdef.SrvErr {
|
||||
if req == nil || req.ObjectId <= 0 || req.ParentsId < 0 || !pubdef.ValidCommentObjectType(req.ObjectType) {
|
||||
return srvdef.New(srvdef.ErrParamError, "评论参数有误")
|
||||
}
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.Text == "" {
|
||||
return srvdef.New(srvdef.ErrParamError, "评论内容不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(req.Text) > 200 {
|
||||
return srvdef.New(srvdef.ErrParamError, "评论内容不能超过200字")
|
||||
}
|
||||
curValue, err := redis.Incr(redisconst.CommentLimitKey(ip), redisconst.CommentLimitKeyExpire)
|
||||
if err != nil {
|
||||
log.Error("SendComment redis incr failed", log.E(err))
|
||||
}
|
||||
//15秒超3评论提示频繁操作
|
||||
if curValue > 1 {
|
||||
log.Error("SendComment user comment too frequently!",
|
||||
log.Any("ip", ip), log.Any("req", req), log.E(err))
|
||||
return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁")
|
||||
}
|
||||
|
||||
//操作频繁度判断
|
||||
dayCurValue, err := redis.Incr(redisconst.CommentOneDayLimitKey(ip), redisconst.CommentDayLimitKeyExpire)
|
||||
if err != nil {
|
||||
log.Error("SendComment redis incr failed", log.E(err))
|
||||
}
|
||||
// 每天20条
|
||||
if dayCurValue > 50 {
|
||||
log.Error("SendComment user comment too frequently!",
|
||||
log.Any("ip", ip), log.Any("req", req), log.E(err))
|
||||
return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁")
|
||||
}
|
||||
if !pubdef.ValidCommentObjectType(req.ObjectType) {
|
||||
return srvdef.New(srvdef.ErrParamError, "评论类型有误")
|
||||
}
|
||||
objectName := pubdef.CommentObjectTypeName(req.ObjectType)
|
||||
var videoInfo2 videoinformationmod.VideoInformation
|
||||
switch req.ObjectType {
|
||||
case pubdef.CommentObjectTypeVideo:
|
||||
videoInfo, err1 := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: req.ObjectId,
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
})
|
||||
if err1 != nil {
|
||||
log.Warn("SendComment.FindOneVideoInformation error", log.Any("objectId", req.ObjectId), log.E(err1))
|
||||
return err1
|
||||
}
|
||||
videoInfo2 = *videoInfo
|
||||
case pubdef.CommentObjectTypePlaylet:
|
||||
_, err1 := playletmod.FindOnePlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Id: req.ObjectId,
|
||||
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
})
|
||||
if err1 != nil {
|
||||
log.Warn("SendComment.FindOnePlaylet error", log.Any("objectId", req.ObjectId), log.E(err1))
|
||||
return err1
|
||||
}
|
||||
case pubdef.CommentObjectTypePlayletChapter:
|
||||
chapter, err1 := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
|
||||
playletchaptermod.FN.PlayletChapter.Id: req.ObjectId,
|
||||
})
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
_, err1 = playletmod.FindOnePlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Id: chapter.PlayletId,
|
||||
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
})
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
}
|
||||
// 通过 token 中的 userId 查询用户信息,补充用户名和头像
|
||||
userInfo, err1 := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
|
||||
if err1 != nil {
|
||||
log.Warn("SendComment.FindOneUser error", log.Any("userId", userId), log.E(err1))
|
||||
return err1
|
||||
}
|
||||
userName := userInfo.NickName
|
||||
if userName == "" {
|
||||
userName = userInfo.AccountNo
|
||||
}
|
||||
|
||||
level := int64(1)
|
||||
replyUserName := ""
|
||||
if req.ParentsId > 0 {
|
||||
// 回复评论
|
||||
parentsInfo, err1 := commentmod.FindOneComment(ctx, bson.M{
|
||||
commentmod.FN.Comment.Id: req.ParentsId,
|
||||
commentmod.FN.Comment.ObjectId: req.ObjectId,
|
||||
commentmod.FN.Comment.ObjectType: req.ObjectType,
|
||||
commentmod.FN.Comment.ReviewStatus: 1,
|
||||
})
|
||||
if err1 != nil {
|
||||
log.Warn("SendComment.FindOneComment error", log.Any("objectId", req.ObjectId), log.Any("parentsId", req.ParentsId), log.E(err1))
|
||||
return err1
|
||||
}
|
||||
level = parentsInfo.Level + 1
|
||||
if level > int64(commentTreeMaxDepth+1) {
|
||||
return srvdef.New(srvdef.ErrParamError, "回复层级过深")
|
||||
}
|
||||
replyUserName = parentsInfo.UserName
|
||||
}
|
||||
info := commentmod.CommentModel{
|
||||
Id: mongos.Lid.Comment.Get(),
|
||||
Ip: ip,
|
||||
ObjectId: req.ObjectId,
|
||||
ObjectName: objectName,
|
||||
ObjectType: req.ObjectType,
|
||||
UserId: userId,
|
||||
UserName: userName,
|
||||
Avatar: userInfo.Avatar,
|
||||
Level: level,
|
||||
ParentsId: req.ParentsId,
|
||||
ReplyUserName: replyUserName,
|
||||
Text: req.Text,
|
||||
CommentCount: 0,
|
||||
ReviewStatus: 0,
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err1 := commentmod.InsertOneComment(ctx, &info); err1 != nil {
|
||||
return err1
|
||||
}
|
||||
var reportErr error
|
||||
switch req.ObjectType {
|
||||
case pubdef.CommentObjectTypeVideo:
|
||||
reportErr = reportser.ReportVideoComment(userId, ua, ip, &videoInfo2, req.Text)
|
||||
}
|
||||
if reportErr != nil {
|
||||
log.Warn("SendComment.ReportCommentEvent error", log.Any("objectType", req.ObjectType), log.Any("objectId", req.ObjectId), log.E(reportErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/commentlikemod"
|
||||
"videoweb/pub/models/commentmod"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
// SetCommentLike 设置评论点赞状态
|
||||
func SetCommentLike(ctx context.Context, userId int64, req *proto.CommentLikeSetReq) (*proto.CommentLikeSetResp, *srvdef.SrvErr) {
|
||||
if userId <= 0 || req == nil || req.CommentId <= 0 || req.IsLike == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
comment, err := commentmod.FindOneComment(ctx, bson.M{
|
||||
commentmod.FN.Comment.Id: req.CommentId,
|
||||
commentmod.FN.Comment.ReviewStatus: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changed bool
|
||||
if *req.IsLike {
|
||||
changed, err = commentlikemod.Add(ctx, userId, req.CommentId)
|
||||
} else {
|
||||
changed, err = commentlikemod.Remove(ctx, userId, req.CommentId)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := comment.CountLike
|
||||
if changed {
|
||||
delta := int64(1)
|
||||
if !*req.IsLike {
|
||||
delta = -1
|
||||
}
|
||||
count, err = commentmod.ChangeLikeCount(ctx, req.CommentId, delta)
|
||||
if err != nil {
|
||||
if *req.IsLike {
|
||||
_, _ = commentlikemod.Remove(ctx, userId, req.CommentId)
|
||||
} else {
|
||||
_, _ = commentlikemod.Add(ctx, userId, req.CommentId)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
current, findErr := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: req.CommentId})
|
||||
if findErr != nil {
|
||||
return nil, findErr
|
||||
}
|
||||
count = current.CountLike
|
||||
}
|
||||
if cacheErr := redis.Del(redisconst.CommentListCacheKey(comment.ObjectId, comment.ObjectType)); cacheErr != nil {
|
||||
log.Warn("SetCommentLike clear cache", log.E(cacheErr))
|
||||
}
|
||||
return &proto.CommentLikeSetResp{CommentId: req.CommentId, IsLike: *req.IsLike, CountLike: count}, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/adcenter"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
func UpdateAnnouncementData(ctx context.Context) {
|
||||
config := GetConfigInfo()
|
||||
resp := proto.AnnouncementInfo{
|
||||
VideoInfo: make([]proto.VideoInfoData, 0),
|
||||
PdfAddr: config.PdfAddr,
|
||||
NewUrlAddr: config.DHNewUrlAddr1,
|
||||
NewUrlAddr1: config.DHNewUrlAddr2,
|
||||
NewUrlAddr2: config.DHNewUrlAddr3,
|
||||
ForeverUrlAddr: config.ForeverUrlAddr,
|
||||
AppAddr: config.AppAddr,
|
||||
TranslationAddr: config.TranslationAddr,
|
||||
}
|
||||
|
||||
videoFilter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
videoinformationmod.FN.VideoInformation.IsHot: true,
|
||||
}
|
||||
videoOpt := &options.FindOptions{}
|
||||
videoOpt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.Sort: -1})
|
||||
videoOpt.SetLimit(2)
|
||||
|
||||
videoList, serr := videoinformationmod.FindManyVideoInformations(ctx, videoFilter, videoOpt)
|
||||
if serr != nil {
|
||||
log.Error("VideoCategoryData", log.Any("videoFilter", videoFilter), log.E(serr))
|
||||
return
|
||||
}
|
||||
// 查询视频
|
||||
for _, videoInfo := range videoList {
|
||||
v := proto.VideoInfoToData(videoInfo, config.ImgCdn, GetCreateInfo(videoInfo.CreatorId, config.ImgCdn))
|
||||
for _, tagId := range videoInfo.Tags {
|
||||
tagInfo := GetVideoTag(tagId)
|
||||
if tagInfo != nil {
|
||||
v.Tags = append(v.Tags, proto.TagsInfoData{
|
||||
Id: tagInfo.Id,
|
||||
Name: tagInfo.Name,
|
||||
Slug: tagInfo.Slug,
|
||||
})
|
||||
}
|
||||
}
|
||||
resp.VideoInfo = append(resp.VideoInfo, v)
|
||||
}
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.Announcement = resp
|
||||
}
|
||||
|
||||
// UpdateAdvitiseDataFromJt 从广告中心拉取福利页广告
|
||||
func UpdateAdvitiseDataFromJt() {
|
||||
//config := GetConfigInfo()
|
||||
var resp proto.AdvitiseData
|
||||
jtAds, err := adcenter.JtAdvertiseThreeServer(appg.Conf.AdCenter.ApiDomain, appg.Conf.AdCenter.MerchantCode, appg.Conf.AdCenter.AppCode, appg.Conf.AdCenter.AesKey)
|
||||
if err != nil {
|
||||
log.Error("JtAdvertiseThreeServer error occur", log.E(err))
|
||||
return
|
||||
}
|
||||
for _, _ = range jtAds {
|
||||
//pos, err := strconv.Atoi(loc.AdvertiseLocationCode)
|
||||
//if err != nil {
|
||||
// log.Error("广告位置代码错误,必须为数字", log.Any("code", loc.AdvertiseLocationCode))
|
||||
// continue
|
||||
//}
|
||||
////if pos < 100001 {
|
||||
//// continue
|
||||
////}
|
||||
//ads := make([]proxy.AdvitiseInfo, 0)
|
||||
//for _, ad := range loc.AdDetailInfoList {
|
||||
// //extra := ad.GetExtraData()
|
||||
// ads = append(ads, proxy.AdvitiseInfo{
|
||||
// Id: ad.AdvertiseCode,
|
||||
// Location: int64(pos),
|
||||
// Title: ad.AdvertiseName,
|
||||
// Images: netutil.MergeURL(config.ImgCdn, ad.GetCoverLsj()),
|
||||
// Link: ad.GetRealLink(),
|
||||
// })
|
||||
//}
|
||||
//
|
||||
//switch pos {
|
||||
//case 1:
|
||||
// resp.HomeList = ads
|
||||
//case 2:
|
||||
// resp.DetailTopList = ads
|
||||
//case 3:
|
||||
// resp.NineGridList = ads
|
||||
//case 4:
|
||||
// resp.DetailbannerList = ads
|
||||
//case 5:
|
||||
// resp.LaunchScreenList = ads
|
||||
//case 6:
|
||||
// resp.MustAppList = ads
|
||||
//case 7:
|
||||
// resp.TitleAppList = ads
|
||||
//case 8:
|
||||
// resp.FriendList = ads
|
||||
//case 9:
|
||||
// resp.DialogList = ads
|
||||
//case 10:
|
||||
// resp.PostList = ads
|
||||
//case 11:
|
||||
// resp.FloatingList = ads
|
||||
//case 12:
|
||||
// resp.AList = ads
|
||||
//case 13:
|
||||
// resp.BList = ads
|
||||
//case 14:
|
||||
// resp.VideoList = ads
|
||||
//}
|
||||
}
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.Advitise = resp
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/dataReport"
|
||||
"videoweb/pub/models/cfg"
|
||||
"videoweb/pub/models/hotnavmod"
|
||||
"videoweb/pub/models/ipblackmod"
|
||||
"videoweb/pub/models/richtextcontentmod"
|
||||
"videoweb/pub/models/videocreatermod"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
CacheMgr CacheInfo //
|
||||
skdCron *cron.Cron
|
||||
)
|
||||
|
||||
// richTextImgSrcRe 匹配富文本HTML里 img 标签的 src 图片地址,兼容单引号、双引号和JSON转义后的双引号
|
||||
var richTextImgSrcRe = regexp.MustCompile(`(?i)(<img\b[^>]*?\s*)src\s*=\s*(\\?"([^"\\]*)\\?"|'([^']*)')`)
|
||||
|
||||
type CacheInfo struct {
|
||||
Lock sync.RWMutex // 读写锁
|
||||
Config proto.ConfigInfo // 系统配置缓存
|
||||
Announcement proto.AnnouncementInfo
|
||||
Advitise proto.AdvitiseData
|
||||
IpBlackMaps map[string]struct{}
|
||||
CreateInfo map[int64]*videocreatermod.VideoCreaterInfo // 作者信息
|
||||
HotNavs []proto.HotNavItem // 热门导航
|
||||
AppRichTexts []proto.AppRichTextItemResp // APP富文本内容列表
|
||||
}
|
||||
|
||||
type appRichTextCacheItem struct {
|
||||
Id int64 `bson:"_id,omitempty"`
|
||||
Title string `bson:"title"`
|
||||
Content string `bson:"content"`
|
||||
Desc string `bson:"desc"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
skdCron = cron.New(cron.WithSeconds()) // 秒级定时任务
|
||||
CacheMgr.Config = proto.ConfigInfo{}
|
||||
CacheMgr.CreateInfo = make(map[int64]*videocreatermod.VideoCreaterInfo)
|
||||
CacheMgr.IpBlackMaps = make(map[string]struct{})
|
||||
}
|
||||
|
||||
func Start() {
|
||||
ctx := context.Background()
|
||||
|
||||
dataReport.StartSDKConfigCron()
|
||||
UpdateConfigInfo(ctx)
|
||||
//UpdateAdvitiseData(ctx)
|
||||
UpdateAdvitiseDataFromJt()
|
||||
UpdateAnnouncementData(ctx)
|
||||
updateIpBlack()
|
||||
updateHotNavCache(ctx)
|
||||
updateAppRichTexts()
|
||||
StartTopicCache()
|
||||
go UpdateVideoRankCache()
|
||||
go UpdatePlayletRankCache()
|
||||
go UpdateAllFavoriteDataCount()
|
||||
_, _ = skdCron.AddFunc("0 */5 * * * ?", func() {
|
||||
UpdateConfigInfo(ctx)
|
||||
//UpdateAdvitiseData(ctx)
|
||||
UpdateAdvitiseDataFromJt()
|
||||
UpdateAnnouncementData(ctx)
|
||||
updateIpBlack()
|
||||
updateHotNavCache(ctx)
|
||||
updateAppRichTexts()
|
||||
})
|
||||
|
||||
_, _ = skdCron.AddFunc("* */14 * * * ?", func() {
|
||||
updateCreateCache(ctx)
|
||||
})
|
||||
// 每10分钟统计各标签关联数据量并刷新首页标签缓存
|
||||
_, _ = skdCron.AddFunc("0 */10 * * * ?", func() {
|
||||
UpdateAllTagDataCount()
|
||||
})
|
||||
// 每10分钟统计各内容收藏量(整点 10 分)
|
||||
_, _ = skdCron.AddFunc("0 */10 * * * ?", func() {
|
||||
UpdateAllFavoriteDataCount()
|
||||
})
|
||||
// 各内容榜单每 10 分钟刷新一次,错开整点避免任务扎堆
|
||||
_, _ = skdCron.AddFunc("0 1/10 * * * ?", func() {
|
||||
UpdateVideoRankCache()
|
||||
})
|
||||
_, _ = skdCron.AddFunc("0 5/10 * * * ?", func() {
|
||||
UpdatePlayletRankCache()
|
||||
})
|
||||
skdCron.Start()
|
||||
}
|
||||
|
||||
func UpdateConfigInfo(ctx context.Context) {
|
||||
config := proto.ConfigInfo{}
|
||||
|
||||
filter := bson.M{cfg.FN.SysCfg.Id: 1}
|
||||
sysCfg, _ := cfg.FindOneSysCfg(context.Background(), filter)
|
||||
if sysCfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
config.NewUrlAddr = sysCfg.NewUrlAddr
|
||||
config.NewUrlAddr1 = sysCfg.NewUrlAddr1
|
||||
config.NewUrlAddr2 = sysCfg.NewUrlAddr2
|
||||
config.ForeverUrlAddr = sysCfg.ForeverUrlAddr
|
||||
config.GmailAddr = sysCfg.GmailAddr
|
||||
config.AppAddr = sysCfg.AppAddr
|
||||
config.WeiChart = sysCfg.WeiChart
|
||||
config.QQ = sysCfg.QQ
|
||||
config.TgGroup = sysCfg.TgGroup
|
||||
config.PotaoGroup = sysCfg.PotaoGroup
|
||||
config.GitAddr = sysCfg.GitAddr
|
||||
config.TwitterAddr = sysCfg.TwitterAddr
|
||||
config.Telegram = sysCfg.Telegram
|
||||
config.TelegramAdd = sysCfg.TelegramAdd
|
||||
config.TranslationAddr = sysCfg.TranslationAddr
|
||||
config.ImgCdn = sysCfg.ImgAppCdn
|
||||
config.TsCdn = sysCfg.MediaAppCdn
|
||||
config.ImgAppUsCdn = sysCfg.ImgAppUsCdn
|
||||
config.MediaAppUsCdn = sysCfg.MediaAppUsCdn
|
||||
config.PdfAddr = sysCfg.PdfAddr
|
||||
|
||||
config.WebTitle = sysCfg.WebTitle
|
||||
config.WebKeyworld = sysCfg.WebKeyworld
|
||||
config.WebDescription = sysCfg.WebDescription
|
||||
config.WebSiteName = sysCfg.WebSiteName
|
||||
config.WebAddr = sysCfg.WebAddr
|
||||
config.DHNewUrlAddr1 = sysCfg.DHNewUrlAddr1
|
||||
config.DHNewUrlAddr2 = sysCfg.DHNewUrlAddr2
|
||||
config.DHNewUrlAddr3 = sysCfg.DHNewUrlAddr3
|
||||
config.UseIngAddress = sysCfg.UseAddress
|
||||
config.FreeVPN = sysCfg.FreeVPN
|
||||
config.DHKeyworld = sysCfg.DHKeyworld
|
||||
config.DHTitle = sysCfg.DHTitle
|
||||
config.DHDescription = sysCfg.DHDescription
|
||||
config.DHSiteName = sysCfg.DHSiteName
|
||||
config.ReportSDKConfig = dataReport.GetSDKDomainConfig()
|
||||
config.ChannelCode = sysCfg.ChannelCode
|
||||
config.EnableH265 = sysCfg.EnableH265
|
||||
cfg.CacheAppKey(sysCfg.AppKey)
|
||||
config.M3U8DayRateLimitMax = sysCfg.M3U8DayRateLimitMax
|
||||
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.Config = config
|
||||
|
||||
}
|
||||
|
||||
func GetConfigInfo() proto.ConfigInfo {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
|
||||
return CacheMgr.Config
|
||||
}
|
||||
|
||||
func GetAdvitise() proto.AdvitiseData {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
|
||||
return CacheMgr.Advitise
|
||||
}
|
||||
|
||||
func GetAnnouncement() proto.AnnouncementInfo {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
|
||||
return CacheMgr.Announcement
|
||||
}
|
||||
|
||||
func GetRandCDN() string {
|
||||
config := GetConfigInfo()
|
||||
return config.TsCdn
|
||||
}
|
||||
|
||||
func updateHotNavCache(ctx context.Context) {
|
||||
filter := bson.M{hotnavmod.FN.HotNav.Status: true}
|
||||
opts := options.Find()
|
||||
opts.SetSort(bson.D{
|
||||
{Key: hotnavmod.FN.HotNav.Sort, Value: -1},
|
||||
{Key: hotnavmod.FN.HotNav.CreatedAt, Value: -1},
|
||||
})
|
||||
opts.SetLimit(100)
|
||||
list, err := hotnavmod.FindManyHotNav(ctx, filter, opts)
|
||||
if err != nil {
|
||||
log.Warn("updateHotNavCache.FindManyHotNav", log.E(err))
|
||||
return
|
||||
}
|
||||
config := GetConfigInfo()
|
||||
tempList := make([]proto.HotNavItem, len(list))
|
||||
for i, item := range list {
|
||||
tempList[i] = proto.HotNavItem{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
Intro: item.Intro,
|
||||
Url: item.Url,
|
||||
Icon: netutil.MergeURL(config.ImgCdn, item.Icon),
|
||||
}
|
||||
}
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.HotNavs = tempList
|
||||
}
|
||||
|
||||
func GetHotNavs() []proto.HotNavItem {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
result := make([]proto.HotNavItem, len(CacheMgr.HotNavs))
|
||||
copy(result, CacheMgr.HotNavs)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetFooterInfo 查询APP底部信息
|
||||
func GetFooterInfo() proto.FooterInfo {
|
||||
config := GetConfigInfo()
|
||||
return proto.FooterInfo{
|
||||
Brand: buildFooterBrand(config),
|
||||
NavList: buildFooterNavList(),
|
||||
ContactList: buildFooterContactList(config),
|
||||
}
|
||||
}
|
||||
|
||||
// buildFooterBrand 构建APP底部品牌信息
|
||||
func buildFooterBrand(config proto.ConfigInfo) proto.FooterBrand {
|
||||
return proto.FooterBrand{
|
||||
Title: config.WebSiteName,
|
||||
Content: config.WebDescription,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFooterNavList 构建APP底部导航列表
|
||||
func buildFooterNavList() []proto.TranslationCategory {
|
||||
bottom := translationser.GetTranslationBottomList()
|
||||
return append([]proto.TranslationCategory{}, bottom.CategoryList...)
|
||||
}
|
||||
|
||||
// buildFooterContactList 构建APP底部联系方式,固定返回原型要求的四项
|
||||
func buildFooterContactList(config proto.ConfigInfo) []proto.FooterContact {
|
||||
return []proto.FooterContact{
|
||||
{Name: "E-mail", Value: config.GmailAddr},
|
||||
{Name: "商务合作", Value: config.Telegram},
|
||||
{Name: "官方群", Value: firstNotEmpty(config.TgGroup, config.TelegramAdd)},
|
||||
{Name: "官方推特", Value: config.TwitterAddr},
|
||||
}
|
||||
}
|
||||
|
||||
// firstNotEmpty 返回第一个非空字符串
|
||||
func firstNotEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetAppRichTextList 查询APP富文本内容列表
|
||||
func GetAppRichTextList(rawIds string, rawTitles string) proto.AppRichTextListResp {
|
||||
ids := parseInt64Set(rawIds)
|
||||
titles := parseStringSet(rawTitles)
|
||||
data := getCachedAppRichTexts()
|
||||
if len(data) == 0 {
|
||||
updateAppRichTexts()
|
||||
data = getCachedAppRichTexts()
|
||||
}
|
||||
return proto.AppRichTextListResp{List: filterAppRichTexts(data, ids, titles)}
|
||||
}
|
||||
|
||||
// getCachedAppRichTexts 读取APP富文本缓存副本
|
||||
func getCachedAppRichTexts() []proto.AppRichTextItemResp {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
return append([]proto.AppRichTextItemResp{}, CacheMgr.AppRichTexts...)
|
||||
}
|
||||
|
||||
// filterAppRichTexts 按富文本ID或标题过滤缓存数据
|
||||
func filterAppRichTexts(data []proto.AppRichTextItemResp, ids map[int64]struct{}, titles map[string]struct{}) []proto.AppRichTextItemResp {
|
||||
list := make([]proto.AppRichTextItemResp, 0, len(data))
|
||||
for _, item := range data {
|
||||
if len(ids) > 0 {
|
||||
if _, ok := ids[item.Id]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(titles) > 0 {
|
||||
if _, ok := titles[item.Title]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// parseInt64Set 解析英文逗号分隔的ID集合
|
||||
func parseInt64Set(raw string) map[int64]struct{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
resp := make(map[int64]struct{})
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(item), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
resp[id] = struct{}{}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// parseStringSet 解析英文逗号分隔的字符串集合
|
||||
func parseStringSet(raw string) map[string]struct{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
resp := make(map[string]struct{})
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
resp[item] = struct{}{}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// updateAppRichTexts 刷新APP富文本内容缓存
|
||||
func updateAppRichTexts() {
|
||||
resp, err := queryAppRichTexts(context.Background())
|
||||
if err != nil {
|
||||
log.Warn("查询APP富文本内容失败", log.E(err))
|
||||
return
|
||||
}
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.AppRichTexts = resp
|
||||
}
|
||||
|
||||
// queryAppRichTexts 查询启用状态的APP富文本内容,统一供定时缓存和接口兜底加载使用
|
||||
func queryAppRichTexts(ctx context.Context) ([]proto.AppRichTextItemResp, *srvdef.SrvErr) {
|
||||
filter := bson.M{
|
||||
richtextcontentmod.FN.RichTextContentModel.Status: true,
|
||||
}
|
||||
opts := options.Find().
|
||||
SetProjection(bson.M{
|
||||
richtextcontentmod.FN.RichTextContentModel.Id: 1,
|
||||
richtextcontentmod.FN.RichTextContentModel.Title: 1,
|
||||
richtextcontentmod.FN.RichTextContentModel.Content: 1,
|
||||
richtextcontentmod.FN.RichTextContentModel.Desc: 1,
|
||||
}).
|
||||
SetSort(bson.D{
|
||||
{Key: richtextcontentmod.FN.RichTextContentModel.Sort, Value: -1},
|
||||
{Key: richtextcontentmod.FN.RichTextContentModel.UpdatedAt, Value: -1},
|
||||
}).
|
||||
SetLimit(100)
|
||||
list := make([]appRichTextCacheItem, 0)
|
||||
err := richtextcontentmod.FindManyRichTextContentModelModelWithBind(ctx, &list, filter, opts)
|
||||
if err != nil {
|
||||
log.Warn("查询APP富文本内容失败", log.Any("filter", filter), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
imgCdn := GetConfigInfo().ImgCdn
|
||||
resp := make([]proto.AppRichTextItemResp, 0, len(list))
|
||||
for _, item := range list {
|
||||
resp = append(resp, proto.AppRichTextItemResp{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
Content: applyRichTextImgSrcCdn(imgCdn, item.Content),
|
||||
Desc: item.Desc,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// applyRichTextImgSrcCdn 给富文本 content 中 img 标签的 src 相对图片地址拼接图片CDN
|
||||
func applyRichTextImgSrcCdn(imgCdn string, content string) string {
|
||||
if imgCdn == "" || content == "" {
|
||||
return content
|
||||
}
|
||||
return richTextImgSrcRe.ReplaceAllStringFunc(content, func(match string) string {
|
||||
parts := richTextImgSrcRe.FindStringSubmatch(match)
|
||||
if len(parts) < 5 {
|
||||
return match
|
||||
}
|
||||
prefix := parts[1]
|
||||
quote := `"`
|
||||
src := parts[3]
|
||||
if src == "" {
|
||||
quote = `'`
|
||||
src = parts[4]
|
||||
} else if strings.HasPrefix(parts[2], `\"`) {
|
||||
quote = `\"`
|
||||
}
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" || isAbsoluteRichTextSrc(src) {
|
||||
return match
|
||||
}
|
||||
return prefix + "src=" + quote + netutil.MergeURL(imgCdn, src) + quote
|
||||
})
|
||||
}
|
||||
|
||||
// isAbsoluteRichTextSrc 判断图片地址是否已经是完整地址或特殊协议,避免重复拼接CDN
|
||||
func isAbsoluteRichTextSrc(src string) bool {
|
||||
lowerSrc := strings.ToLower(src)
|
||||
return strings.HasPrefix(lowerSrc, "http://") ||
|
||||
strings.HasPrefix(lowerSrc, "https://") ||
|
||||
strings.HasPrefix(lowerSrc, "data:") ||
|
||||
strings.HasPrefix(lowerSrc, "blob:") ||
|
||||
strings.HasPrefix(src, "//")
|
||||
}
|
||||
|
||||
func updateIpBlack() {
|
||||
ctx := context.Background()
|
||||
list, err := ipblackmod.FindManyIPBlackModel(ctx, bson.M{})
|
||||
if err != nil {
|
||||
log.Error("updateIpBlack FindManyIPBlackModel error", log.E(err))
|
||||
return
|
||||
}
|
||||
tempMaps := make(map[string]struct{})
|
||||
for _, v := range list {
|
||||
tempMaps[v.Ip] = struct{}{}
|
||||
}
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.IpBlackMaps = tempMaps
|
||||
}
|
||||
|
||||
func CheckIpBlack(ip string) bool {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
if _, ok := CacheMgr.IpBlackMaps[ip]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
dataReport.StopSDKConfigCron()
|
||||
skdCron.Stop()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/common/ipaddr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ClientIPFromCtx 从 gin 请求上下文解析客户端 IP。
|
||||
func ClientIPFromCtx(ctx context.Context) string {
|
||||
c, ok := ctx.(*gin.Context)
|
||||
if !ok || c == nil {
|
||||
return ""
|
||||
}
|
||||
return ipaddr.ResolveClientIP(c)
|
||||
}
|
||||
|
||||
// IsOverseasIP 与 m3u8 TS 域名判断一致:按 v4/v6 库识别是否海外。
|
||||
func IsOverseasIP(ip string) bool {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return false
|
||||
}
|
||||
detector := appg.DetectorV6
|
||||
if ipaddr.IsIPv4(ip) {
|
||||
detector = appg.DetectorV4
|
||||
}
|
||||
if detector == nil {
|
||||
return false
|
||||
}
|
||||
ok, _ := detector.IsOverseasIP(ip)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetAppImageCdn 海外 IP 且配置了 imgAppUsCdn 时用海外图床,否则用 imgAppCdn。
|
||||
func GetAppImageCdn(ip string) string {
|
||||
cfg := GetConfigInfo()
|
||||
if IsOverseasIP(ip) {
|
||||
if us := strings.TrimSpace(cfg.ImgAppUsCdn); us != "" {
|
||||
return us
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(cfg.ImgCdn)
|
||||
}
|
||||
|
||||
// ImageCdnFromCtx 按当前请求 IP 选择图片 CDN。
|
||||
func ImageCdnFromCtx(ctx context.Context) string {
|
||||
return GetAppImageCdn(ClientIPFromCtx(ctx))
|
||||
}
|
||||
|
||||
// GetRequestConfig 返回当前请求应使用的配置副本,海外 IP 且海外图床非空时覆盖 ImgCdn。
|
||||
func GetRequestConfig(ctx context.Context) proto.ConfigInfo {
|
||||
cfg := GetConfigInfo()
|
||||
if cdn := GetAppImageCdn(ClientIPFromCtx(ctx)); cdn != "" {
|
||||
cfg.ImgCdn = cdn
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// RewriteImageCdn 将已拼好的国内图床域名替换为当前请求应使用的图床,用于缓存数据。
|
||||
func RewriteImageCdn(ctx context.Context, data any) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
from := strings.TrimRight(strings.TrimSpace(GetConfigInfo().ImgCdn), "/")
|
||||
to := strings.TrimRight(GetAppImageCdn(ClientIPFromCtx(ctx)), "/")
|
||||
if from == "" || to == "" || from == to {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
replaced := bytes.ReplaceAll(raw, []byte(from), []byte(to))
|
||||
if bytes.Equal(raw, replaced) {
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(replaced, data)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// AppContentTagList 查询APP端有绑定内容数量的启用统一标签列表
|
||||
func AppContentTagList(ctx context.Context, req *proto.AppContentTagListReq) (*proto.AppContentTagListResp, *srvdef.SrvErr) {
|
||||
if req == nil {
|
||||
req = &proto.AppContentTagListReq{}
|
||||
}
|
||||
filter := bson.M{
|
||||
contenttagmod.FN.ContentTag.Status: true,
|
||||
}
|
||||
bindCountExpr := appContentTagTotalCountExpr()
|
||||
if req.ContentTagType != nil {
|
||||
if !isValidAppContentTagType(*req.ContentTagType) {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
|
||||
}
|
||||
countField := appContentTagCountField(*req.ContentTagType)
|
||||
filter[contenttagmod.FN.ContentTag.Types] = *req.ContentTagType
|
||||
filter[countField] = bson.M{"$gt": 0}
|
||||
bindCountExpr = bson.M{"$ifNull": bson.A{"$" + countField, 0}}
|
||||
} else {
|
||||
filter["$or"] = []bson.M{
|
||||
{contenttagmod.FN.ContentTag.VideoCount: bson.M{"$gt": 0}},
|
||||
{contenttagmod.FN.ContentTag.PlayletCount: bson.M{"$gt": 0}},
|
||||
}
|
||||
}
|
||||
list := make([]*proto.AppContentTagItem, 0)
|
||||
pipeline := []bson.M{
|
||||
{"$match": filter},
|
||||
{"$addFields": bson.M{"bindCount": bindCountExpr}},
|
||||
{"$sort": bson.D{
|
||||
{Key: "bindCount", Value: -1},
|
||||
{Key: contenttagmod.FN.ContentTag.Rank, Value: -1},
|
||||
{Key: contenttagmod.FN.ContentTag.Id, Value: -1},
|
||||
}},
|
||||
{"$project": bson.M{
|
||||
contenttagmod.FN.ContentTag.Id: 1,
|
||||
contenttagmod.FN.ContentTag.Name: 1,
|
||||
contenttagmod.FN.ContentTag.Slug: 1,
|
||||
contenttagmod.FN.ContentTag.Types: 1,
|
||||
contenttagmod.FN.ContentTag.Rank: 1,
|
||||
contenttagmod.FN.ContentTag.VideoCount: 1,
|
||||
contenttagmod.FN.ContentTag.PlayletCount: 1,
|
||||
"seoInfo": 1,
|
||||
"bindCount": 1,
|
||||
}},
|
||||
}
|
||||
if err := contenttagmod.AggregateContentTag(ctx, &list, pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item != nil && item.SeoInfo.Keyworld == nil {
|
||||
item.SeoInfo.Keyworld = make([]string, 0)
|
||||
}
|
||||
}
|
||||
return &proto.AppContentTagListResp{List: list}, nil
|
||||
}
|
||||
|
||||
// HasEnabledContentTagBind 判断统一标签是否启用、支持当前内容类型且已有绑定内容
|
||||
func HasEnabledContentTagBind(ctx context.Context, tagId int64, tagType contenttagmod.ContentTagType) (bool, *srvdef.SrvErr) {
|
||||
if tagId <= 0 {
|
||||
return false, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "")
|
||||
}
|
||||
if !isValidAppContentTagType(tagType) {
|
||||
return false, srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
|
||||
}
|
||||
countField := appContentTagCountField(tagType)
|
||||
_, err := contenttagmod.FindOneContentTag(ctx, bson.M{
|
||||
contenttagmod.FN.ContentTag.Id: tagId,
|
||||
contenttagmod.FN.ContentTag.Status: true,
|
||||
contenttagmod.FN.ContentTag.Types: tagType,
|
||||
countField: bson.M{"$gt": 0},
|
||||
}, options.FindOne().SetProjection(bson.M{contenttagmod.FN.ContentTag.Id: 1}))
|
||||
if err != nil {
|
||||
if err.Code == srvdef.ErrNoData {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// appContentTagTotalCountExpr 计算统一标签视频和短剧绑定数量合计,只用于查询排序不落库
|
||||
func appContentTagTotalCountExpr() bson.M {
|
||||
return bson.M{"$add": bson.A{
|
||||
bson.M{"$ifNull": bson.A{"$" + contenttagmod.FN.ContentTag.VideoCount, 0}},
|
||||
bson.M{"$ifNull": bson.A{"$" + contenttagmod.FN.ContentTag.PlayletCount, 0}},
|
||||
}}
|
||||
}
|
||||
|
||||
// isValidAppContentTagType 判断APP端统一标签类型是否有效
|
||||
func isValidAppContentTagType(tagType contenttagmod.ContentTagType) bool {
|
||||
return tagType == contenttagmod.ContentTagTypeVideo ||
|
||||
tagType == contenttagmod.ContentTagTypePlaylet
|
||||
}
|
||||
|
||||
// appContentTagCountField 返回当前内容类型对应的绑定数量字段
|
||||
func appContentTagCountField(tagType contenttagmod.ContentTagType) string {
|
||||
switch tagType {
|
||||
case contenttagmod.ContentTagTypeVideo:
|
||||
return contenttagmod.FN.ContentTag.VideoCount
|
||||
case contenttagmod.ContentTagTypePlaylet:
|
||||
return contenttagmod.FN.ContentTag.PlayletCount
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/models/videocreatermod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
func updateCreateCache(ctx context.Context) {
|
||||
// 更新作者相关缓存
|
||||
createList, err := videocreatermod.FindManyVideoCreaterInfos(ctx, bson.M{
|
||||
videocreatermod.FN.VideoCreaterInfo.Status: true,
|
||||
}, options.Find().SetLimit(200).SetSort(bson.M{videocreatermod.FN.VideoCreaterInfo.Sort: -1}))
|
||||
if err != nil {
|
||||
log.Warn("updateCreateCache FindManyVideoCreaterIinfos", log.E(err))
|
||||
return
|
||||
}
|
||||
tempCreateMaps := make(map[int64]*videocreatermod.VideoCreaterInfo)
|
||||
for _, createInfo := range createList {
|
||||
tempCreateMaps[createInfo.Id] = createInfo
|
||||
}
|
||||
CacheMgr.Lock.Lock()
|
||||
CacheMgr.CreateInfo = tempCreateMaps
|
||||
CacheMgr.Lock.Unlock()
|
||||
}
|
||||
|
||||
func GetCreateInfo(createId int64, imageCdn string) proto.PostCreateInfo {
|
||||
if createId <= 0 {
|
||||
return proto.PostCreateInfo{}
|
||||
}
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
v, ok := CacheMgr.CreateInfo[createId]
|
||||
if !ok {
|
||||
return proto.PostCreateInfo{}
|
||||
}
|
||||
return proto.PostCreateInfo{
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
Slug: v.Slug,
|
||||
Image: netutil.MergeURL(imageCdn, v.Image),
|
||||
Works: v.Works,
|
||||
CreateType: v.CreateType,
|
||||
Desc: v.Desc,
|
||||
Background: v.Background,
|
||||
CreatedAt: v.CreatedAt,
|
||||
SeoInfo: v.SeoInfo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
)
|
||||
|
||||
// ApplyVideoH265Policy 根据 sys_cfg.enableH265 决定是否向客户端暴露 H265 地址。
|
||||
func ApplyVideoH265Policy(data *proto.VideoInfoData) {
|
||||
if data == nil || GetConfigInfo().EnableH265 {
|
||||
return
|
||||
}
|
||||
proto.StripH265Url(data)
|
||||
}
|
||||
|
||||
// ApplyVideoH265PolicyList 批量处理视频列表。
|
||||
func ApplyVideoH265PolicyList(list []proto.VideoInfoData) []proto.VideoInfoData {
|
||||
if GetConfigInfo().EnableH265 {
|
||||
return list
|
||||
}
|
||||
for i := range list {
|
||||
proto.StripH265Url(&list[i])
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// GetM3U8DayRateLimitMax 返回每 IP 每天最多可播放的不同正片数。
|
||||
// sys_cfg.m3u8DayRateLimitMax <= 0 时使用默认 80。
|
||||
func GetM3U8DayRateLimitMax() int64 {
|
||||
max := GetConfigInfo().M3U8DayRateLimitMax
|
||||
if max <= 0 {
|
||||
return redisconst.M3U8DayRateLimitMax
|
||||
}
|
||||
return max
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/playletcache"
|
||||
"videoweb/pub/common/utils"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/playletthememod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
PlayletRankMgr periodRankCache[proto.PlayletRankData]
|
||||
playletRankRefreshLock sync.Mutex
|
||||
playletRankCacheVersion atomic.Int64
|
||||
)
|
||||
|
||||
func UpdatePlayletRankCache() {
|
||||
version := playletcache.RankCacheVersion()
|
||||
ctx := context.Background()
|
||||
imgCdn := GetConfigInfo().ImgCdn
|
||||
PlayletRankMgr.replace(
|
||||
buildPlayletRankList(ctx, imgCdn, RankPeriodTotal),
|
||||
buildPlayletRankList(ctx, imgCdn, RankPeriodWeek),
|
||||
buildPlayletRankList(ctx, imgCdn, RankPeriodMonth),
|
||||
buildPlayletRankList(ctx, imgCdn, RankPeriodYear),
|
||||
)
|
||||
playletRankCacheVersion.Store(version)
|
||||
}
|
||||
|
||||
func refreshPlayletRankCacheIfEmpty() {
|
||||
if PlayletRankMgr.hasAny() {
|
||||
return
|
||||
}
|
||||
playletRankRefreshLock.Lock()
|
||||
defer playletRankRefreshLock.Unlock()
|
||||
if PlayletRankMgr.hasAny() {
|
||||
return
|
||||
}
|
||||
UpdatePlayletRankCache()
|
||||
}
|
||||
|
||||
func GetPlayletRankList(p RankPeriod, limit int) []proto.PlayletRankData {
|
||||
version := playletcache.RankCacheVersion()
|
||||
if version != playletRankCacheVersion.Load() {
|
||||
playletRankRefreshLock.Lock()
|
||||
if version != playletRankCacheVersion.Load() {
|
||||
UpdatePlayletRankCache()
|
||||
}
|
||||
playletRankRefreshLock.Unlock()
|
||||
}
|
||||
return PlayletRankMgr.get(p, limit, refreshPlayletRankCacheIfEmpty)
|
||||
}
|
||||
|
||||
func playletPeriodWatchField(p RankPeriod) string {
|
||||
switch p {
|
||||
case RankPeriodWeek:
|
||||
return playletmod.FN.Playlet.WeekWatchs
|
||||
case RankPeriodMonth:
|
||||
return playletmod.FN.Playlet.MonthWatchs
|
||||
case RankPeriodYear:
|
||||
return playletmod.FN.Playlet.YearWatchs
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildPlayletRankList(ctx context.Context, imgCdn string, p RankPeriod) []proto.PlayletRankData {
|
||||
var ranked playletmod.PlayletList
|
||||
if p == RankPeriodTotal {
|
||||
if err := playletmod.AggregatePlaylet(ctx, &ranked, contentLikeRankPipeline(
|
||||
playletmod.FN.Playlet.Status, playletmod.StatusUp,
|
||||
playletmod.FN.Playlet.CountLike, playletmod.FN.Playlet.VirtualCountLike,
|
||||
playletmod.FN.Playlet.UpdatedAt, playletmod.FN.Playlet.Id,
|
||||
rankLimit,
|
||||
)); err != nil {
|
||||
log.Warn("buildPlayletRankList like rank query failed", log.E(err))
|
||||
ranked = playletmod.PlayletList{}
|
||||
}
|
||||
} else {
|
||||
watchField := playletPeriodWatchField(p)
|
||||
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
watchField: bson.M{"$gt": 0},
|
||||
}, contentWatchRankOpts(watchField, playletmod.FN.Playlet.UpdatedAt, playletmod.FN.Playlet.Id, rankLimit))
|
||||
if err != nil {
|
||||
log.Warn("buildPlayletRankList watch rank query failed", log.Any("period", p), log.E(err))
|
||||
ranked = playletmod.PlayletList{}
|
||||
} else {
|
||||
ranked = list
|
||||
}
|
||||
}
|
||||
if int64(len(ranked)) < rankLimit {
|
||||
needFill := rankLimit - int64(len(ranked))
|
||||
excludeIds := make([]int64, 0, len(ranked))
|
||||
for _, v := range ranked {
|
||||
if v != nil {
|
||||
excludeIds = append(excludeIds, v.Id)
|
||||
}
|
||||
}
|
||||
var filled playletmod.PlayletList
|
||||
if err := playletmod.AggregatePlaylet(ctx, &filled, contentRankFillPipeline(
|
||||
playletmod.FN.Playlet.Id, playletmod.FN.Playlet.Status,
|
||||
playletmod.StatusUp, excludeIds, needFill,
|
||||
)); err != nil {
|
||||
log.Warn("buildPlayletRankList fill sample failed", log.Any("period", p), log.E(err))
|
||||
} else {
|
||||
ranked = append(ranked, filled...)
|
||||
}
|
||||
}
|
||||
tagNameMap, themeNameMap := loadPlayletRankNameMaps(ctx, ranked)
|
||||
result := make([]proto.PlayletRankData, 0, len(ranked))
|
||||
for _, item := range ranked {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, playletToRankData(item, imgCdn, tagNameMap, themeNameMap, p))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func playletToRankData(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string, p RankPeriod) proto.PlayletRankData {
|
||||
return proto.PlayletRankData{
|
||||
PlayletData: proto.PlayletData{
|
||||
Id: item.Id,
|
||||
Status: item.Status,
|
||||
IsRecommend: item.IsRecommend,
|
||||
IsNew: !item.OnlineAt.IsZero() && item.OnlineAt.After(utils.BeginOfWeekByTime(utils.Now())),
|
||||
IsFeatured: item.IsFeatured,
|
||||
IsOriginal: item.IsOriginal || item.MediaCenterId == 0,
|
||||
ShowOriginal: item.ShowOriginal,
|
||||
OnlineAt: item.OnlineAt,
|
||||
MediaCreatedAt: item.MediaCreatedAt,
|
||||
MediaUpdatedAt: item.MediaUpdatedAt,
|
||||
Title: item.Title,
|
||||
Slug: item.Slug,
|
||||
Desc: item.Desc,
|
||||
Anchor: item.Anchor,
|
||||
Cover: netutil.MergeURL(imgCdn, item.DisplayCover()),
|
||||
Preview: netutil.MergeURL(imgCdn, strings.TrimSpace(item.Preview)),
|
||||
TagIds: item.TagIds,
|
||||
TagNames: playletRankTagNamesByIDs(item.TagIds, tagNameMap),
|
||||
TagSlugs: playletRankTagSlugsByIDs(item.TagIds, tagNameMap),
|
||||
ThemeIds: item.ThemeIds,
|
||||
ThemeNames: playletNamesByIDs(item.ThemeIds, themeNameMap),
|
||||
ContentType: item.ContentType,
|
||||
PayType: item.PayType,
|
||||
SingleCoins: item.SingleCoins,
|
||||
TotalEpisode: item.TotalEpisode,
|
||||
UpdateStatus: item.UpdateStatus,
|
||||
CountBrowse: item.DisplayCountBrowse(),
|
||||
HeatValue: item.DisplayHeatValue(),
|
||||
CountCollect: item.CountCollect,
|
||||
CountLike: item.CountLike,
|
||||
VirtualCountLike: item.VirtualCountLike,
|
||||
DisplayCountLike: item.CountLike + item.VirtualCountLike,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
SeoInfo: playletRankSeo(item.SeoInfo),
|
||||
},
|
||||
RankScore: periodWatchNum(p, item.WeekWatchs, item.MonthWatchs, item.YearWatchs, item.CountLike+item.VirtualCountLike),
|
||||
}
|
||||
}
|
||||
|
||||
func playletRankSeo(seo pubdef.SeoValue) pubdef.SeoValue {
|
||||
seo.Title = strings.TrimSpace(seo.Title)
|
||||
seo.Description = strings.TrimSpace(seo.Description)
|
||||
if seo.Keyworld == nil {
|
||||
seo.Keyworld = make([]string, 0)
|
||||
return seo
|
||||
}
|
||||
words := make([]string, 0, len(seo.Keyworld))
|
||||
for _, word := range seo.Keyworld {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
words = append(words, word)
|
||||
}
|
||||
}
|
||||
seo.Keyworld = words
|
||||
return seo
|
||||
}
|
||||
|
||||
func playletNamesByIDs(ids []int64, nameMap map[int64]string) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if name := nameMap[id]; name != "" {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadPlayletRankNameMaps(ctx context.Context, list playletmod.PlayletList) (contenttagmod.ContentTagMap, map[int64]string) {
|
||||
tagIDs := make([]int64, 0)
|
||||
themeIDs := make([]int64, 0)
|
||||
seenTag := make(map[int64]struct{})
|
||||
seenTheme := make(map[int64]struct{})
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range item.TagIds {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenTag[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenTag[id] = struct{}{}
|
||||
tagIDs = append(tagIDs, id)
|
||||
}
|
||||
for _, id := range item.ThemeIds {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenTheme[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenTheme[id] = struct{}{}
|
||||
themeIDs = append(themeIDs, id)
|
||||
}
|
||||
}
|
||||
tagNameMap := make(contenttagmod.ContentTagMap, len(tagIDs))
|
||||
if len(tagIDs) > 0 {
|
||||
if tags, err := contenttagmod.GetMapTags(ctx, tagIDs); err != nil {
|
||||
log.Warn("loadPlayletRankNameMaps tags failed", log.E(err))
|
||||
} else {
|
||||
tagNameMap = tags
|
||||
}
|
||||
}
|
||||
themeNameMap := make(map[int64]string, len(themeIDs))
|
||||
if len(themeIDs) > 0 {
|
||||
themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{
|
||||
playletthememod.FN.PlayletTheme.Id: bson.M{"$in": themeIDs},
|
||||
}, options.Find().SetProjection(bson.M{
|
||||
playletthememod.FN.PlayletTheme.Id: 1,
|
||||
playletthememod.FN.PlayletTheme.Name: 1,
|
||||
}))
|
||||
if err != nil {
|
||||
log.Warn("loadPlayletRankNameMaps themes failed", log.E(err))
|
||||
} else {
|
||||
for _, theme := range themes {
|
||||
if theme != nil {
|
||||
themeNameMap[theme.Id] = theme.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tagNameMap, themeNameMap
|
||||
}
|
||||
|
||||
func playletRankTagNamesByIDs(ids []int64, tags contenttagmod.ContentTagMap) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if tag := tags[id]; tag != nil && tag.Name != "" {
|
||||
result = append(result, tag.Name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func playletRankTagSlugsByIDs(ids []int64, tags contenttagmod.ContentTagMap) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if tag := tags[id]; tag != nil && tag.Name != "" {
|
||||
result = append(result, tag.Slug)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const rankLimit = 48
|
||||
|
||||
// NormalizeRankPage 榜单分页:页码从 1 起,pageSize 不是 rankLimit 时强制改为 rankLimit。
|
||||
func NormalizeRankPage(pageNum, pageSize int64) (int64, int64) {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize != int64(rankLimit) {
|
||||
pageSize = int64(rankLimit)
|
||||
}
|
||||
return pageNum, pageSize
|
||||
}
|
||||
|
||||
type periodRankCache[T any] struct {
|
||||
Lock sync.RWMutex
|
||||
Total []T
|
||||
Week []T
|
||||
Month []T
|
||||
Year []T
|
||||
}
|
||||
|
||||
func (c *periodRankCache[T]) hasAny() bool {
|
||||
c.Lock.RLock()
|
||||
defer c.Lock.RUnlock()
|
||||
return len(c.Total) > 0 || len(c.Week) > 0 || len(c.Month) > 0 || len(c.Year) > 0
|
||||
}
|
||||
|
||||
func (c *periodRankCache[T]) pick(p RankPeriod) []T {
|
||||
switch p {
|
||||
case RankPeriodWeek:
|
||||
return c.Week
|
||||
case RankPeriodMonth:
|
||||
return c.Month
|
||||
case RankPeriodYear:
|
||||
return c.Year
|
||||
default:
|
||||
return c.Total
|
||||
}
|
||||
}
|
||||
|
||||
func (c *periodRankCache[T]) replace(total, week, month, year []T) {
|
||||
c.Lock.Lock()
|
||||
c.Total, c.Week, c.Month, c.Year = total, week, month, year
|
||||
c.Lock.Unlock()
|
||||
}
|
||||
|
||||
func (c *periodRankCache[T]) get(p RankPeriod, limit int, refreshIfEmpty func()) []T {
|
||||
c.Lock.RLock()
|
||||
list := c.pick(p)
|
||||
empty := len(list) == 0
|
||||
c.Lock.RUnlock()
|
||||
if empty {
|
||||
refreshIfEmpty()
|
||||
c.Lock.RLock()
|
||||
list = c.pick(p)
|
||||
c.Lock.RUnlock()
|
||||
}
|
||||
return sliceLimit(list, limit)
|
||||
}
|
||||
|
||||
func sliceLimit[T any](s []T, limit int) []T {
|
||||
if limit <= 0 || limit >= len(s) {
|
||||
return s
|
||||
}
|
||||
return s[:limit]
|
||||
}
|
||||
|
||||
// SliceRankPage 对榜单缓存做分页切片
|
||||
func SliceRankPage[T any](all []T, pageNum, pageSize int64) []T {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
return []T{}
|
||||
}
|
||||
start := (pageNum - 1) * pageSize
|
||||
if start >= int64(len(all)) {
|
||||
return []T{}
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > int64(len(all)) {
|
||||
end = int64(len(all))
|
||||
}
|
||||
return append([]T{}, all[start:end]...)
|
||||
}
|
||||
|
||||
func contentLikeRankPipeline(statusField string, statusVal interface{}, realLikes, fakeLikes, updatedAt, idField string, limit int64) []bson.M {
|
||||
return []bson.M{
|
||||
{"$match": bson.M{statusField: statusVal}},
|
||||
{"$addFields": bson.M{"rankScore": bson.M{"$add": []interface{}{
|
||||
bson.M{"$ifNull": []interface{}{"$" + realLikes, 0}},
|
||||
bson.M{"$ifNull": []interface{}{"$" + fakeLikes, 0}},
|
||||
}}}},
|
||||
{"$match": bson.M{"rankScore": bson.M{"$gt": 0}}},
|
||||
{"$sort": bson.D{
|
||||
{Key: "rankScore", Value: -1},
|
||||
{Key: updatedAt, Value: -1},
|
||||
{Key: idField, Value: -1},
|
||||
}},
|
||||
{"$limit": limit},
|
||||
}
|
||||
}
|
||||
|
||||
func contentWatchRankOpts(watchField, updatedAt, idField string, limit int64) *options.FindOptions {
|
||||
return options.Find().
|
||||
SetSort(bson.D{
|
||||
{Key: watchField, Value: -1},
|
||||
{Key: updatedAt, Value: -1},
|
||||
{Key: idField, Value: -1},
|
||||
}).
|
||||
SetLimit(limit)
|
||||
}
|
||||
|
||||
func contentRankFillPipeline(idField, statusField string, statusVal interface{}, excludeIds []int64, needFill int64) []bson.M {
|
||||
return []bson.M{
|
||||
{"$match": bson.M{
|
||||
idField: bson.M{"$nin": excludeIds},
|
||||
statusField: statusVal,
|
||||
}},
|
||||
{"$sample": bson.M{"size": needFill}},
|
||||
}
|
||||
}
|
||||
|
||||
func periodWatchNum(p RankPeriod, week, month, year, total int64) int64 {
|
||||
switch p {
|
||||
case RankPeriodWeek:
|
||||
return week
|
||||
case RankPeriodMonth:
|
||||
return month
|
||||
case RankPeriodYear:
|
||||
return year
|
||||
default:
|
||||
return total
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/videofavoritemod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
// favoriteCountResult 收藏聚合结果
|
||||
type favoriteCountResult struct {
|
||||
Id int64 `bson:"_id"`
|
||||
Count int64 `bson:"count"`
|
||||
}
|
||||
|
||||
// UpdateAllFavoriteDataCount 统计视频收藏量并写回DB
|
||||
func UpdateAllFavoriteDataCount() {
|
||||
updateVideoFavoriteDataCount(context.Background())
|
||||
}
|
||||
|
||||
// buildFavoritePipeline 构建按时间窗口统计收藏量的聚合 pipeline
|
||||
// contentIdField: favorite表中内容ID字段名(如 videoId)
|
||||
// since: 统计起始时间,零值表示统计全量
|
||||
func buildFavoritePipeline(contentIdField string, since time.Time) []bson.M {
|
||||
matchStage := bson.M{}
|
||||
if !since.IsZero() {
|
||||
matchStage["createdAt"] = bson.M{"$gte": since}
|
||||
}
|
||||
pipeline := []bson.M{}
|
||||
if len(matchStage) > 0 {
|
||||
pipeline = append(pipeline, bson.M{"$match": matchStage})
|
||||
}
|
||||
pipeline = append(pipeline,
|
||||
bson.M{"$group": bson.M{
|
||||
"_id": "$" + contentIdField,
|
||||
"count": bson.M{"$sum": 1},
|
||||
}},
|
||||
)
|
||||
return pipeline
|
||||
}
|
||||
|
||||
// updateVideoFavoriteDataCount 统计视频收藏量,更新 Likes/WeekLikes/MonthLikes/YearLikes
|
||||
func updateVideoFavoriteDataCount(ctx context.Context) {
|
||||
now := time.Now()
|
||||
type period struct {
|
||||
field string
|
||||
since time.Time
|
||||
}
|
||||
periods := []period{
|
||||
{videoinformationmod.FN.VideoInformation.Real.Likes, time.Time{}},
|
||||
{videoinformationmod.FN.VideoInformation.Real.WeekLikes, now.AddDate(0, 0, -7)},
|
||||
{videoinformationmod.FN.VideoInformation.Real.MonthLikes, now.AddDate(0, 0, -30)},
|
||||
{videoinformationmod.FN.VideoInformation.Real.YearLikes, now.AddDate(0, 0, -365)},
|
||||
}
|
||||
idField := videofavoritemod.FN.VideoFavorite.VideoID
|
||||
for _, p := range periods {
|
||||
var results []favoriteCountResult
|
||||
pipeline := buildFavoritePipeline(idField, p.since)
|
||||
if serr := videofavoritemod.AggregateVideoFavorite(ctx, &results, pipeline); serr != nil {
|
||||
log.Warn("updateVideoFavoriteDataCount aggregate failed", log.Any("field", p.field))
|
||||
continue
|
||||
}
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
writes := make([]mongo.WriteModel, 0, len(results))
|
||||
for _, r := range results {
|
||||
writes = append(writes, mongo.NewUpdateOneModel().
|
||||
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: r.Id}).
|
||||
SetUpdate(bson.M{"$set": bson.M{p.field: r.Count}}),
|
||||
)
|
||||
}
|
||||
if _, serr := videoinformationmod.BulkWriteVideoInformation(ctx, writes); serr != nil {
|
||||
log.Warn("updateVideoFavoriteDataCount bulk update failed", log.Any("field", p.field))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RankPeriod 排行榜时间维度
|
||||
type RankPeriod int
|
||||
|
||||
const (
|
||||
RankPeriodTotal RankPeriod = 0 // 总榜
|
||||
RankPeriodWeek RankPeriod = 1 // 周榜
|
||||
RankPeriodMonth RankPeriod = 2 // 月榜
|
||||
RankPeriodYear RankPeriod = 3 // 年榜
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/cfg"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
// GetSystemConfig 读取系统配置(MongoDB id=1)。
|
||||
func GetSystemConfig(ctx context.Context) (*cfg.SysCfg, *srvdef.SrvErr) {
|
||||
filter := bson.M{
|
||||
cfg.FN.SysCfg.Id: 1,
|
||||
}
|
||||
return cfg.FindOneSysCfg(ctx, filter)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
// tagCountResult 聚合统计结果
|
||||
type tagCountResult struct {
|
||||
Id int64 `bson:"_id"`
|
||||
Count int64 `bson:"count"`
|
||||
}
|
||||
|
||||
// UpdateAllTagDataCount 并发统计视频和短剧各标签的关联数据量并写回统一标签表
|
||||
func UpdateAllTagDataCount() {
|
||||
ctx := context.Background()
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); updateVideoTagDataCount(ctx) }()
|
||||
go func() { defer wg.Done(); updatePlayletTagDataCount(ctx) }()
|
||||
wg.Wait()
|
||||
updateTagCache()
|
||||
}
|
||||
|
||||
// updateVideoTagDataCount 统计视频标签的关联视频数量,更新 content_tag.videoCount 字段
|
||||
func updateVideoTagDataCount(ctx context.Context) {
|
||||
pipeline := []bson.M{
|
||||
{"$match": bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
}},
|
||||
{"$unwind": "$" + videoinformationmod.FN.VideoInformation.Tags},
|
||||
{"$group": bson.M{
|
||||
"_id": "$" + videoinformationmod.FN.VideoInformation.Tags,
|
||||
"count": bson.M{"$sum": 1},
|
||||
}},
|
||||
}
|
||||
var results []tagCountResult
|
||||
if err := videoinformationmod.AggregateVideoInformations(ctx, &results, pipeline); err != nil {
|
||||
log.Warn("updateVideoTagDataCount aggregate failed", log.E(err))
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = make([]tagCountResult, 0)
|
||||
}
|
||||
syncContentTagDataCount(ctx, contenttagmod.ContentTagTypeVideo, contenttagmod.FN.ContentTag.VideoCount, results, "updateVideoTagDataCount")
|
||||
}
|
||||
|
||||
// updatePlayletTagDataCount 统计短剧标签的关联短剧数量,更新 content_tag.playletCount 字段
|
||||
func updatePlayletTagDataCount(ctx context.Context) {
|
||||
pipeline := []bson.M{
|
||||
{"$match": bson.M{
|
||||
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
}},
|
||||
{"$unwind": "$" + playletmod.FN.Playlet.TagIds},
|
||||
{"$group": bson.M{
|
||||
"_id": "$" + playletmod.FN.Playlet.TagIds,
|
||||
"count": bson.M{"$sum": 1},
|
||||
}},
|
||||
}
|
||||
var results []tagCountResult
|
||||
if err := playletmod.AggregatePlaylet(ctx, &results, pipeline); err != nil {
|
||||
log.Warn("updatePlayletTagDataCount aggregate failed", log.E(err))
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = make([]tagCountResult, 0)
|
||||
}
|
||||
syncContentTagDataCount(ctx, contenttagmod.ContentTagTypePlaylet, contenttagmod.FN.ContentTag.PlayletCount, results, "updatePlayletTagDataCount")
|
||||
}
|
||||
|
||||
func syncContentTagDataCount(ctx context.Context, tagType contenttagmod.ContentTagType, countField string, results []tagCountResult, logName string) {
|
||||
if err := contenttagmod.UpdateManyContentTag(ctx, bson.M{
|
||||
contenttagmod.FN.ContentTag.Types: tagType,
|
||||
}, bson.M{"$set": bson.M{countField: 0}}); err != nil {
|
||||
log.Warn(logName+" reset failed", log.E(err))
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return
|
||||
}
|
||||
writes := make([]mongo.WriteModel, 0, len(results))
|
||||
for _, r := range results {
|
||||
writes = append(writes, mongo.NewUpdateOneModel().
|
||||
SetFilter(bson.M{
|
||||
contenttagmod.FN.ContentTag.Id: r.Id,
|
||||
contenttagmod.FN.ContentTag.Types: tagType,
|
||||
}).
|
||||
SetUpdate(bson.M{"$set": bson.M{countField: r.Count}}),
|
||||
)
|
||||
}
|
||||
if _, err := contenttagmod.BulkWriteContentTag(ctx, writes); err != nil {
|
||||
log.Warn(logName+" bulk update failed", log.E(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/actress"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
type TopicCache struct {
|
||||
Lock sync.RWMutex // 读写锁
|
||||
|
||||
ActressWorks []proto.ActressInfoData
|
||||
ActressModelMaps map[int64]*actress.Actress
|
||||
|
||||
AllTagMaps map[int64]proto.TagsInfoData
|
||||
VideoTagMaps map[int64]proto.TagsInfoData
|
||||
|
||||
// 首页展示的标签,按当前类型绑定数量降序
|
||||
HomeVideoTagList []proto.TagsInfoData
|
||||
HomePlayletTagList []proto.TagsInfoData
|
||||
}
|
||||
|
||||
var TopicMgr TopicCache
|
||||
|
||||
func StartTopicCache() {
|
||||
TopicMgr = TopicCache{}
|
||||
TopicMgr.Lock = sync.RWMutex{}
|
||||
TopicMgr.ActressWorks = make([]proto.ActressInfoData, 0)
|
||||
TopicMgr.ActressModelMaps = make(map[int64]*actress.Actress)
|
||||
TopicMgr.HomeVideoTagList = make([]proto.TagsInfoData, 0)
|
||||
TopicMgr.HomePlayletTagList = make([]proto.TagsInfoData, 0)
|
||||
TopicMgr.AllTagMaps = make(map[int64]proto.TagsInfoData)
|
||||
TopicMgr.VideoTagMaps = make(map[int64]proto.TagsInfoData)
|
||||
|
||||
updateTagCache()
|
||||
updateCreateCache(context.Background())
|
||||
updateCategoryCache()
|
||||
_, _ = skdCron.AddFunc("0 */5 * * * ?", func() {
|
||||
updateTagCache()
|
||||
updateCreateCache(context.Background())
|
||||
updateCategoryCache()
|
||||
})
|
||||
}
|
||||
func updateTagCache() {
|
||||
updateContentTagCache()
|
||||
}
|
||||
func updateCategoryCache() {
|
||||
updateActressCache()
|
||||
}
|
||||
|
||||
func updateActressCache() {
|
||||
ctx := context.Background()
|
||||
filter := bson.M{actress.FN.Actress.Status: true}
|
||||
config := GetConfigInfo()
|
||||
otps := &options.FindOptions{}
|
||||
otps.SetSort(bson.D{
|
||||
{Key: actress.FN.Actress.HotValue, Value: -1},
|
||||
{Key: actress.FN.Actress.Id, Value: -1},
|
||||
})
|
||||
list, err := actress.FindManyActresss(ctx, filter, otps)
|
||||
if err != nil {
|
||||
log.Warn("updateActressCache", log.E(err))
|
||||
return
|
||||
}
|
||||
tempWorks := make([]proto.ActressInfoData, len(list))
|
||||
tempModel := make(map[int64]*actress.Actress)
|
||||
for i := 0; i < len(list); i++ {
|
||||
info := proto.ActressInfoToData(*list[i], config.ImgCdn)
|
||||
tempWorks[i] = info
|
||||
tempModel[info.Id] = list[i]
|
||||
}
|
||||
sort.SliceStable(tempWorks, func(i, j int) bool {
|
||||
if tempWorks[i].HotValue == tempWorks[j].HotValue {
|
||||
return tempWorks[i].Id > tempWorks[j].Id
|
||||
}
|
||||
return tempWorks[i].HotValue > tempWorks[j].HotValue
|
||||
})
|
||||
TopicMgr.Lock.Lock()
|
||||
defer TopicMgr.Lock.Unlock()
|
||||
TopicMgr.ActressWorks = tempWorks
|
||||
TopicMgr.ActressModelMaps = tempModel
|
||||
}
|
||||
|
||||
func GetActressInfo(actressId int64) *actress.Actress {
|
||||
TopicMgr.Lock.RLock()
|
||||
defer TopicMgr.Lock.RUnlock()
|
||||
v, ok := TopicMgr.ActressModelMaps[actressId]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
func updateContentTagCache() {
|
||||
ctx := context.Background()
|
||||
filter := bson.M{
|
||||
contenttagmod.FN.ContentTag.Status: true,
|
||||
"$or": []bson.M{
|
||||
{contenttagmod.FN.ContentTag.VideoCount: bson.M{"$gt": 0}},
|
||||
{contenttagmod.FN.ContentTag.PlayletCount: bson.M{"$gt": 0}},
|
||||
},
|
||||
}
|
||||
list, err := contenttagmod.FindManyContentTag(ctx, filter, options.Find().
|
||||
SetSort(bson.D{
|
||||
{Key: contenttagmod.FN.ContentTag.Rank, Value: -1},
|
||||
{Key: contenttagmod.FN.ContentTag.Id, Value: -1},
|
||||
}).
|
||||
SetProjection(bson.M{
|
||||
contenttagmod.FN.ContentTag.Id: 1,
|
||||
contenttagmod.FN.ContentTag.Name: 1,
|
||||
contenttagmod.FN.ContentTag.Slug: 1,
|
||||
contenttagmod.FN.ContentTag.Types: 1,
|
||||
contenttagmod.FN.ContentTag.Rank: 1,
|
||||
contenttagmod.FN.ContentTag.VideoCount: 1,
|
||||
contenttagmod.FN.ContentTag.PlayletCount: 1,
|
||||
"seoInfo": 1,
|
||||
}))
|
||||
if err != nil {
|
||||
log.Warn("updateContentTagCache", log.E(err))
|
||||
return
|
||||
}
|
||||
allMaps := make(map[int64]proto.TagsInfoData, len(list))
|
||||
videoMaps := make(map[int64]proto.TagsInfoData)
|
||||
videoHomeList := make([]proto.TagsInfoData, 0)
|
||||
playletHomeList := make([]proto.TagsInfoData, 0)
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
for _, tagType := range item.Types {
|
||||
info := contentTagInfoByType(item, tagType)
|
||||
allMaps[item.Id] = info
|
||||
switch tagType {
|
||||
case contenttagmod.ContentTagTypeVideo:
|
||||
videoMaps[item.Id] = info
|
||||
if info.Works > 0 {
|
||||
videoHomeList = append(videoHomeList, info)
|
||||
}
|
||||
case contenttagmod.ContentTagTypePlaylet:
|
||||
if info.Works > 0 {
|
||||
playletHomeList = append(playletHomeList, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sortHomeContentTagList(videoHomeList)
|
||||
sortHomeContentTagList(playletHomeList)
|
||||
TopicMgr.Lock.Lock()
|
||||
defer TopicMgr.Lock.Unlock()
|
||||
TopicMgr.AllTagMaps = allMaps
|
||||
TopicMgr.VideoTagMaps = videoMaps
|
||||
TopicMgr.HomeVideoTagList = videoHomeList
|
||||
TopicMgr.HomePlayletTagList = playletHomeList
|
||||
}
|
||||
|
||||
func contentTagInfoByType(item *contenttagmod.ContentTag, tagType contenttagmod.ContentTagType) proto.TagsInfoData {
|
||||
return proto.TagsInfoData{
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Slug: item.Slug,
|
||||
SeoInfo: item.SeoInfo,
|
||||
Works: contentTagWorksByType(item, tagType),
|
||||
}
|
||||
}
|
||||
|
||||
func contentTagWorksByType(item *contenttagmod.ContentTag, tagType contenttagmod.ContentTagType) int64 {
|
||||
switch tagType {
|
||||
case contenttagmod.ContentTagTypeVideo:
|
||||
return item.VideoCount
|
||||
case contenttagmod.ContentTagTypePlaylet:
|
||||
return item.PlayletCount
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func sortHomeContentTagList(list []proto.TagsInfoData) {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Works == list[j].Works {
|
||||
return list[i].Id > list[j].Id
|
||||
}
|
||||
return list[i].Works > list[j].Works
|
||||
})
|
||||
}
|
||||
|
||||
func GetActressWorks() []proto.ActressInfoData {
|
||||
TopicMgr.Lock.RLock()
|
||||
defer TopicMgr.Lock.RUnlock()
|
||||
return TopicMgr.ActressWorks
|
||||
}
|
||||
|
||||
func GetVideoTag(tagId int64) *proto.TagsInfoData {
|
||||
TopicMgr.Lock.RLock()
|
||||
defer TopicMgr.Lock.RUnlock()
|
||||
tag, ok := TopicMgr.VideoTagMaps[tagId]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &tag
|
||||
}
|
||||
|
||||
// VideoTagNamesAndSlugs 按视频标签ID顺序返回名称和slug
|
||||
func VideoTagNamesAndSlugs(tagIDs []int64) ([]string, []string) {
|
||||
names := make([]string, 0, len(tagIDs))
|
||||
slugs := make([]string, 0, len(tagIDs))
|
||||
TopicMgr.Lock.RLock()
|
||||
defer TopicMgr.Lock.RUnlock()
|
||||
for _, id := range tagIDs {
|
||||
if tag, ok := TopicMgr.VideoTagMaps[id]; ok {
|
||||
names = append(names, tag.Name)
|
||||
slugs = append(slugs, tag.Slug)
|
||||
}
|
||||
}
|
||||
return names, slugs
|
||||
}
|
||||
|
||||
func GetHomeVideoTagList() []proto.TagsInfoData {
|
||||
TopicMgr.Lock.RLock()
|
||||
defer TopicMgr.Lock.RUnlock()
|
||||
return TopicMgr.HomeVideoTagList
|
||||
}
|
||||
|
||||
func GetHomePlayletTagList() []proto.TagsInfoData {
|
||||
TopicMgr.Lock.RLock()
|
||||
defer TopicMgr.Lock.RUnlock()
|
||||
return TopicMgr.HomePlayletTagList
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
var (
|
||||
VideoRankMgr periodRankCache[proto.ContentData]
|
||||
videoRankRefreshLock sync.Mutex
|
||||
)
|
||||
|
||||
func UpdateVideoRankCache() {
|
||||
ctx := context.Background()
|
||||
imgCdn := GetConfigInfo().ImgCdn
|
||||
VideoRankMgr.replace(
|
||||
buildVideoRankList(ctx, imgCdn, RankPeriodTotal),
|
||||
buildVideoRankList(ctx, imgCdn, RankPeriodWeek),
|
||||
buildVideoRankList(ctx, imgCdn, RankPeriodMonth),
|
||||
buildVideoRankList(ctx, imgCdn, RankPeriodYear),
|
||||
)
|
||||
}
|
||||
|
||||
func refreshVideoRankCacheIfEmpty() {
|
||||
if VideoRankMgr.hasAny() {
|
||||
return
|
||||
}
|
||||
videoRankRefreshLock.Lock()
|
||||
defer videoRankRefreshLock.Unlock()
|
||||
if VideoRankMgr.hasAny() {
|
||||
return
|
||||
}
|
||||
UpdateVideoRankCache()
|
||||
}
|
||||
|
||||
func GetVideoRankList(p RankPeriod, limit int) []proto.ContentData {
|
||||
return VideoRankMgr.get(p, limit, refreshVideoRankCacheIfEmpty)
|
||||
}
|
||||
|
||||
func videoPeriodWatchField(p RankPeriod) string {
|
||||
switch p {
|
||||
case RankPeriodWeek:
|
||||
return videoinformationmod.FN.VideoInformation.Real.WeekWatchs
|
||||
case RankPeriodMonth:
|
||||
return videoinformationmod.FN.VideoInformation.Real.MonthWatchs
|
||||
case RankPeriodYear:
|
||||
return videoinformationmod.FN.VideoInformation.Real.YearWatchs
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func videoToContentData(item *videoinformationmod.VideoInformation, imgCdn string) proto.ContentData {
|
||||
tagNames, tagSlugs := VideoTagNamesAndSlugs(item.Tags)
|
||||
return proto.ContentData{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
Slug: item.Slug,
|
||||
AddedTime: item.AddedTime,
|
||||
CoverImg: netutil.MergeURL(imgCdn, item.CoverImg),
|
||||
Tags: item.Tags,
|
||||
TagsName: tagNames,
|
||||
TagSlugs: tagSlugs,
|
||||
WatchNum: item.Real.WatchTimes + item.Fake.WatchTimes,
|
||||
LikeNum: item.Real.Likes + item.Fake.Likes,
|
||||
}
|
||||
}
|
||||
|
||||
func videoToRankContentData(item *videoinformationmod.VideoInformation, imgCdn string, p RankPeriod) proto.ContentData {
|
||||
data := videoToContentData(item, imgCdn)
|
||||
data.WatchNum = periodWatchNum(p, item.Real.WeekWatchs, item.Real.MonthWatchs, item.Real.YearWatchs, data.WatchNum)
|
||||
return data
|
||||
}
|
||||
|
||||
func buildVideoRankList(ctx context.Context, imgCdn string, p RankPeriod) []proto.ContentData {
|
||||
var ranked videoinformationmod.VideoInformationList
|
||||
if p == RankPeriodTotal {
|
||||
if err := videoinformationmod.AggregateVideoInformations(ctx, &ranked, contentLikeRankPipeline(
|
||||
videoinformationmod.FN.VideoInformation.Status, pubdef.InformationStatusVerifyOK,
|
||||
videoinformationmod.FN.VideoInformation.Real.Likes, videoinformationmod.FN.VideoInformation.Fake.Likes,
|
||||
videoinformationmod.FN.VideoInformation.UpdatedAt, videoinformationmod.FN.VideoInformation.Id,
|
||||
rankLimit,
|
||||
)); err != nil {
|
||||
log.Warn("buildVideoRankList like rank query failed", log.E(err))
|
||||
ranked = videoinformationmod.VideoInformationList{}
|
||||
}
|
||||
} else {
|
||||
watchField := videoPeriodWatchField(p)
|
||||
list, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
watchField: bson.M{"$gt": 0},
|
||||
}, contentWatchRankOpts(watchField, videoinformationmod.FN.VideoInformation.UpdatedAt, videoinformationmod.FN.VideoInformation.Id, rankLimit))
|
||||
if err != nil {
|
||||
log.Warn("buildVideoRankList watch rank query failed", log.Any("period", p), log.E(err))
|
||||
ranked = videoinformationmod.VideoInformationList{}
|
||||
} else {
|
||||
ranked = list
|
||||
}
|
||||
}
|
||||
if int64(len(ranked)) < rankLimit {
|
||||
needFill := rankLimit - int64(len(ranked))
|
||||
excludeIds := make([]int64, 0, len(ranked))
|
||||
for _, v := range ranked {
|
||||
excludeIds = append(excludeIds, v.Id)
|
||||
}
|
||||
var filled videoinformationmod.VideoInformationList
|
||||
if err := videoinformationmod.AggregateVideoInformations(ctx, &filled, contentRankFillPipeline(
|
||||
videoinformationmod.FN.VideoInformation.Id, videoinformationmod.FN.VideoInformation.Status,
|
||||
pubdef.InformationStatusVerifyOK, excludeIds, needFill,
|
||||
)); err != nil {
|
||||
log.Warn("buildVideoRankList fill sample failed", log.Any("period", p), log.E(err))
|
||||
} else {
|
||||
ranked = append(ranked, filled...)
|
||||
}
|
||||
}
|
||||
result := make([]proto.ContentData, 0, len(ranked))
|
||||
for _, item := range ranked {
|
||||
result = append(result, videoToRankContentData(item, imgCdn, p))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package displaycount
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"strings"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/videofavoritemod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/models/videolikemod"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
type countRow struct {
|
||||
Id int64 `bson:"_id"`
|
||||
Count int64 `bson:"count"`
|
||||
}
|
||||
|
||||
type videoCounts struct {
|
||||
Watch int64
|
||||
Like int64
|
||||
Favorite int64
|
||||
}
|
||||
|
||||
// Value 真实数大于零时放大十倍,零值按内容和指标生成稳定展示数
|
||||
func Value(real, id int64, kind string) int64 {
|
||||
if real > 0 {
|
||||
if real > math.MaxInt64/10 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return real * 10
|
||||
}
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(kind))
|
||||
var key [8]byte
|
||||
binary.LittleEndian.PutUint64(key[:], uint64(id))
|
||||
_, _ = h.Write(key[:])
|
||||
return 100 + int64(h.Sum64()%901)
|
||||
}
|
||||
|
||||
// Transform 将 App 内容接口中的现有计数字段转换为展示值
|
||||
func Transform(c *gin.Context, data interface{}) interface{} {
|
||||
path := c.Request.URL.Path
|
||||
if !contentPath(path) || data == nil {
|
||||
return data
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var body interface{}
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
return data
|
||||
}
|
||||
ids := make(map[int64]struct{})
|
||||
collectVideoIDs(body, path, ids)
|
||||
counts, err := loadVideoCounts(c, ids)
|
||||
if err != nil {
|
||||
log.Warn("displaycount: load video counts failed", log.E(err))
|
||||
return data
|
||||
}
|
||||
apply(body, path, c.Query("rankPeriod"), counts)
|
||||
return body
|
||||
}
|
||||
|
||||
func contentPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/app/playlet/") ||
|
||||
strings.HasPrefix(path, "/api/app/video/") ||
|
||||
strings.HasPrefix(path, "/api/app/home") ||
|
||||
strings.HasPrefix(path, "/api/app/actress/work/") ||
|
||||
strings.HasPrefix(path, "/search")
|
||||
}
|
||||
|
||||
func collectVideoIDs(node interface{}, path string, ids map[int64]struct{}) {
|
||||
switch item := node.(type) {
|
||||
case map[string]interface{}:
|
||||
if isVideo(item, path) {
|
||||
if id := number(item["id"]); id > 0 {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, child := range item {
|
||||
collectVideoIDs(child, path, ids)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, child := range item {
|
||||
collectVideoIDs(child, path, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isVideo(item map[string]interface{}, path string) bool {
|
||||
if item["type"] != nil && number(item["type"]) == 2 {
|
||||
return false
|
||||
}
|
||||
if _, ok := item["watchNum"]; ok {
|
||||
_, hasLike := item["likeNum"]
|
||||
return hasLike
|
||||
}
|
||||
if _, ok := item["watchTimes"]; ok {
|
||||
_, hasVideo := item["video"]
|
||||
return hasVideo && number(item["type"]) != 2
|
||||
}
|
||||
return path == "/api/app/video/like" && item["likeNum"] != nil && item["id"] != nil
|
||||
}
|
||||
|
||||
func loadVideoCounts(c *gin.Context, idSet map[int64]struct{}) (map[int64]videoCounts, error) {
|
||||
result := make(map[int64]videoCounts, len(idSet))
|
||||
if len(idSet) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
videos, err := videoinformationmod.FindManyVideoInformations(c, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids},
|
||||
}, options.Find().SetProjection(bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: 1,
|
||||
videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, video := range videos {
|
||||
result[video.Id] = videoCounts{Watch: video.Real.WatchTimes}
|
||||
}
|
||||
likes := make([]countRow, 0)
|
||||
if err := videolikemod.AggregateVideoLike(c, &likes, countPipeline(videolikemod.FN.VideoLike.VideoID, ids)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range likes {
|
||||
value := result[row.Id]
|
||||
value.Like = row.Count
|
||||
result[row.Id] = value
|
||||
}
|
||||
favorites := make([]countRow, 0)
|
||||
if err := videofavoritemod.AggregateVideoFavorite(c, &favorites, countPipeline(videofavoritemod.FN.VideoFavorite.VideoID, ids)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range favorites {
|
||||
value := result[row.Id]
|
||||
value.Favorite = row.Count
|
||||
result[row.Id] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func countPipeline(field string, ids []int64) []bson.M {
|
||||
return []bson.M{
|
||||
{"$match": bson.M{field: bson.M{"$in": ids}}},
|
||||
{"$group": bson.M{"_id": "$" + field, "count": bson.M{"$sum": 1}}},
|
||||
}
|
||||
}
|
||||
|
||||
func apply(node interface{}, path, rankPeriod string, counts map[int64]videoCounts) {
|
||||
switch item := node.(type) {
|
||||
case map[string]interface{}:
|
||||
id := number(item["id"])
|
||||
if id > 0 && isVideo(item, path) {
|
||||
count := counts[id]
|
||||
if _, ok := item["watchNum"]; ok {
|
||||
item["watchNum"] = Value(count.Watch, id, "video-watch")
|
||||
}
|
||||
if _, ok := item["watchTimes"]; ok {
|
||||
item["watchTimes"] = Value(count.Watch, id, "video-watch")
|
||||
}
|
||||
item["likeNum"] = Value(count.Like, id, "video-like")
|
||||
item["countCollect"] = Value(count.Favorite, id, "video-favorite")
|
||||
} else if id > 0 && item["countBrowse"] != nil && item["countCollect"] != nil {
|
||||
kind := "playlet"
|
||||
if item["currentEpisode"] != nil {
|
||||
kind = "playlet-chapter"
|
||||
}
|
||||
if item["rankScore"] != nil && path == "/api/app/playlet/rank/list" {
|
||||
if rankPeriod == "" || rankPeriod == "0" {
|
||||
item["rankScore"] = Value(number(item["countLike"]), id, kind+"-like")
|
||||
} else {
|
||||
item["rankScore"] = Value(number(item["rankScore"]), id, kind+"-period-watch-"+rankPeriod)
|
||||
}
|
||||
}
|
||||
item["countBrowse"] = Value(number(item["countBrowse"]), id, kind+"-watch")
|
||||
item["countCollect"] = Value(number(item["countCollect"]), id, kind+"-favorite")
|
||||
if item["displayCountLike"] != nil {
|
||||
like := Value(number(item["countLike"]), id, kind+"-like")
|
||||
item["displayCountLike"] = like
|
||||
item["countLike"] = like
|
||||
} else if item["countLike"] != nil {
|
||||
item["countLike"] = Value(number(item["countLike"]), id, kind+"-like")
|
||||
}
|
||||
} else if id > 0 && path == "/api/app/playlet/favorite" && item["countCollect"] != nil {
|
||||
item["countCollect"] = Value(number(item["countCollect"]), id, "playlet-favorite")
|
||||
} else if id > 0 && path == "/api/app/playlet/like" && item["likeNum"] != nil {
|
||||
item["likeNum"] = Value(number(item["likeNum"]), id, "playlet-like")
|
||||
}
|
||||
for _, child := range item {
|
||||
apply(child, path, rankPeriod, counts)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, child := range item {
|
||||
apply(child, path, rankPeriod, counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func number(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
value, _ := n.Int64()
|
||||
return value
|
||||
case int64:
|
||||
return n
|
||||
case float64:
|
||||
return int64(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package healthser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/mongos"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
timestampTolerance = 5 * time.Minute
|
||||
bytesInGB = 1024 * 1024 * 1024
|
||||
healthGroupID = "69840d508591cdac5a3defcb"
|
||||
healthSecretKey = "5e47c292e775ebe8bb0d79057cd17778"
|
||||
)
|
||||
|
||||
func HealthCheck(ctx context.Context, req *proto.HealthCheckRequest) (*proto.HealthCheckData, *srvdef.SrvErr) {
|
||||
if req.GroupID != healthGroupID {
|
||||
log.Warn("health check invalid groupId", log.Any("groupId", req.GroupID))
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "invalid groupId")
|
||||
}
|
||||
if !isTimestampValid(req.Timestamp) {
|
||||
log.Warn("health check timestamp expired", log.Any("timestamp", req.Timestamp))
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "timestamp expired")
|
||||
}
|
||||
if !verifySign(req.GroupID, req.Timestamp, healthSecretKey, req.Sign) {
|
||||
log.Warn("health check invalid sign", log.Any("groupId", req.GroupID))
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "invalid sign")
|
||||
}
|
||||
|
||||
data := collectServerMetrics()
|
||||
data.Timestamp = time.Now().Unix()
|
||||
data.Mysql = disabledStatus("mysql not used")
|
||||
data.Kafka = disabledStatus("kafka not used")
|
||||
data.Redis = pingRedis()
|
||||
data.Mongodb = pingMongo(ctx)
|
||||
data.Elasticsearch = pingElasticsearch(ctx)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func verifySign(groupID string, ts int64, secret string, sign string) bool {
|
||||
payload := fmt.Sprintf("%s:%d", groupID, ts)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected))
|
||||
}
|
||||
|
||||
func isTimestampValid(ts int64) bool {
|
||||
now := time.Now().Unix()
|
||||
diff := now - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
return diff <= int64(timestampTolerance.Seconds())
|
||||
}
|
||||
|
||||
func disabledStatus(msg string) *proto.ComponentStatus {
|
||||
return &proto.ComponentStatus{Status: "disabled", Msg: msg}
|
||||
}
|
||||
|
||||
func okStatus() *proto.ComponentStatus {
|
||||
return &proto.ComponentStatus{Status: "ok", Msg: "success"}
|
||||
}
|
||||
|
||||
func pingRedis() *proto.ComponentStatus {
|
||||
cli := redis.Client()
|
||||
if cli == nil {
|
||||
return &proto.ComponentStatus{Status: "err", Msg: "redis client not initialized"}
|
||||
}
|
||||
if err := cli.Ping().Err(); err != nil {
|
||||
log.Warn("health check redis ping failed", log.E(err))
|
||||
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
|
||||
}
|
||||
status := okStatus()
|
||||
status.Master = appg.Conf.Redis.Addr
|
||||
return status
|
||||
}
|
||||
|
||||
func pingMongo(ctx context.Context) *proto.ComponentStatus {
|
||||
if mongos.Shop == nil {
|
||||
return &proto.ComponentStatus{Status: "err", Msg: "mongo client not initialized"}
|
||||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if err := mongos.Shop.Database().RunCommand(pingCtx, bson.D{{Key: "ping", Value: 1}}).Err(); err != nil {
|
||||
log.Warn("health check mongo ping failed", log.E(err))
|
||||
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
|
||||
}
|
||||
return okStatus()
|
||||
}
|
||||
|
||||
func pingElasticsearch(ctx context.Context) *proto.ComponentStatus {
|
||||
addr := strings.TrimSpace(appg.Conf.Es.Addr)
|
||||
if addr == "" {
|
||||
return disabledStatus("elasticsearch not configured")
|
||||
}
|
||||
url := strings.TrimSuffix(addr, "/") + "/_cluster/health"
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
log.Warn("health check es request build failed", log.E(err))
|
||||
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
|
||||
}
|
||||
if appg.Conf.Es.User != "" || appg.Conf.Es.Pass != "" {
|
||||
req.SetBasicAuth(appg.Conf.Es.User, appg.Conf.Es.Pass)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Warn("health check es request failed", log.E(err))
|
||||
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &proto.ComponentStatus{Status: "err", Msg: fmt.Sprintf("es status %d", resp.StatusCode)}
|
||||
}
|
||||
return okStatus()
|
||||
}
|
||||
|
||||
func collectServerMetrics() *proto.HealthCheckData {
|
||||
name, err := os.Hostname()
|
||||
if err != nil || name == "" {
|
||||
name = "app-server"
|
||||
}
|
||||
ip := netutil.GetLocalIP()
|
||||
if ip == "" {
|
||||
ip = "127.0.0.1"
|
||||
}
|
||||
|
||||
cores := runtime.NumCPU()
|
||||
usedCores := calcUsedCores(cores)
|
||||
memMetrics := memoryMetrics()
|
||||
diskMetrics := diskMetrics()
|
||||
|
||||
return &proto.HealthCheckData{
|
||||
ServerName: name,
|
||||
ServerIP: ip,
|
||||
CPU: proto.HealthCPUMetrics{
|
||||
Cores: cores,
|
||||
Used: usedCores,
|
||||
},
|
||||
Memory: memMetrics,
|
||||
Disk: diskMetrics,
|
||||
}
|
||||
}
|
||||
|
||||
func calcUsedCores(cores int) float64 {
|
||||
usage, err := cpu.Percent(200*time.Millisecond, false)
|
||||
if err != nil || len(usage) == 0 {
|
||||
if err != nil {
|
||||
log.Warn("health check cpu percent failed", log.E(err))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
used := float64(cores) * usage[0] / 100
|
||||
return math.Round(used*10) / 10
|
||||
}
|
||||
|
||||
func memoryMetrics() proto.HealthMemoryMetrics {
|
||||
vm, err := mem.VirtualMemory()
|
||||
if err != nil {
|
||||
log.Warn("health check memory stats failed", log.E(err))
|
||||
return proto.HealthMemoryMetrics{}
|
||||
}
|
||||
return proto.HealthMemoryMetrics{
|
||||
Total: bytesToGB(vm.Total),
|
||||
Used: bytesToGB(vm.Used),
|
||||
}
|
||||
}
|
||||
|
||||
func diskMetrics() proto.HealthMemoryMetrics {
|
||||
path := rootPath()
|
||||
du, err := disk.Usage(path)
|
||||
if err != nil {
|
||||
log.Warn("health check disk stats failed", log.E(err))
|
||||
return proto.HealthMemoryMetrics{}
|
||||
}
|
||||
return proto.HealthMemoryMetrics{
|
||||
Total: bytesToGB(du.Total),
|
||||
Used: bytesToGB(du.Used),
|
||||
}
|
||||
}
|
||||
|
||||
func bytesToGB(value uint64) int64 {
|
||||
if value == 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(float64(value) / bytesInGB))
|
||||
}
|
||||
|
||||
func rootPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "C:\\"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package homeser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/redis"
|
||||
)
|
||||
|
||||
const (
|
||||
appHomeCacheKey = "app:home:data"
|
||||
appHomeCacheTTL = time.Minute * 5
|
||||
)
|
||||
|
||||
// getHomeDataCache 读取 APP 首页 Redis 缓存,缓存异常时降级查库
|
||||
func getHomeDataCache() *proto.DataResp {
|
||||
cacheData, err1 := redis.Get(appHomeCacheKey)
|
||||
if err1 != nil {
|
||||
log.Warn("读取APP首页缓存失败", log.Any("cacheKey", appHomeCacheKey), log.E(err1))
|
||||
return nil
|
||||
}
|
||||
if cacheData == nil || *cacheData == "" {
|
||||
return nil
|
||||
}
|
||||
resp := &proto.DataResp{}
|
||||
if err1 := json.Unmarshal([]byte(*cacheData), resp); err1 != nil {
|
||||
log.Warn("解析APP首页缓存失败", log.Any("cacheKey", appHomeCacheKey), log.E(err1))
|
||||
return nil
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// setHomeDataCache 写入 APP 首页 Redis 缓存,缓存异常不影响主流程
|
||||
func setHomeDataCache(resp proto.DataResp) {
|
||||
bytes, err1 := json.Marshal(resp)
|
||||
if err1 != nil {
|
||||
log.Warn("序列化APP首页缓存失败", log.Any("cacheKey", appHomeCacheKey), log.E(err1))
|
||||
return
|
||||
}
|
||||
if err1 := redis.Set(appHomeCacheKey, string(bytes), appHomeCacheTTL); err1 != nil {
|
||||
log.Warn("写入APP首页缓存失败", log.Any("cacheKey", appHomeCacheKey), log.E(err1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package homeser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/actressser"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/app/service/videoser"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/homebannermod"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const homeBannerListLimit int64 = 30
|
||||
|
||||
var homeBannerAppProjection = bson.D{
|
||||
{Key: homebannermod.FN.HomeBanner.Id, Value: 1},
|
||||
{Key: homebannermod.FN.HomeBanner.Name, Value: 1},
|
||||
{Key: homebannermod.FN.HomeBanner.Image, Value: 1},
|
||||
{Key: homebannermod.FN.HomeBanner.VerticalImage, Value: 1},
|
||||
{Key: homebannermod.FN.HomeBanner.Url, Value: 1},
|
||||
{Key: homebannermod.FN.HomeBanner.Rank, Value: 1},
|
||||
{Key: homebannermod.FN.HomeBanner.Status, Value: 1},
|
||||
}
|
||||
|
||||
// HomeData 获取首页数据,包含公共配置、公告、广告和各业务首页数据
|
||||
func HomeData(ctx context.Context) (proto.DataResp, *srvdef.SrvErr) {
|
||||
if cacheData := getHomeDataCache(); cacheData != nil {
|
||||
configser.RewriteImageCdn(ctx, cacheData)
|
||||
return *cacheData, nil
|
||||
}
|
||||
// 数据组装
|
||||
config := configser.GetConfigInfo()
|
||||
data := proto.DataResp{
|
||||
Path: homeRequestPath(ctx),
|
||||
Config: config,
|
||||
Advitise: configser.GetAdvitise(),
|
||||
Announcement: configser.GetAnnouncement(),
|
||||
HomeBannerList: make([]proto.HomeBannerInfo, 0),
|
||||
PlayletData: proto.HomePlayletDataResp{
|
||||
RecommendList: make([]proto.PlayletData, 0),
|
||||
NewestList: make([]proto.PlayletData, 0),
|
||||
HotList: make([]proto.PlayletData, 0),
|
||||
},
|
||||
VideoData: proto.HomeVideoDataResp{
|
||||
RecommendList: make([]proto.VideoInfo, 0),
|
||||
NewestList: make([]proto.VideoInfo, 0),
|
||||
HotList: make([]proto.VideoInfo, 0),
|
||||
},
|
||||
ActressInfoList: make([]proto.ActressInfo, 0),
|
||||
}
|
||||
// 并分查询
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var err1 *srvdef.SrvErr
|
||||
setErr := func(err *srvdef.SrvErr) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err1 == nil {
|
||||
err1 = err
|
||||
}
|
||||
}
|
||||
wg.Add(4)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
list, err := HomeBannerList(ctx, config.ImgCdn)
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
data.HomeBannerList = list
|
||||
mu.Unlock()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
playletData, err := playletser.HomePlayletData(ctx, config.ImgCdn)
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
data.PlayletData = playletData
|
||||
mu.Unlock()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
videoData, err := videoser.HomeVideoDataForHome(ctx, config.ImgCdn)
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
data.VideoData = videoData
|
||||
mu.Unlock()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
list, err := actressser.HomeActressData(ctx)
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
data.ActressInfoList = list
|
||||
mu.Unlock()
|
||||
}()
|
||||
wg.Wait()
|
||||
if err1 != nil {
|
||||
return data, err1
|
||||
}
|
||||
setHomeDataCache(data)
|
||||
configser.RewriteImageCdn(ctx, &data)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// HomeBannerList 查询启用的首页banner信息列表
|
||||
func HomeBannerList(ctx context.Context, imgCdn string) ([]proto.HomeBannerInfo, *srvdef.SrvErr) {
|
||||
list, err := homebannermod.FindManyHomeBanner(ctx, bson.M{
|
||||
homebannermod.FN.HomeBanner.Status: true,
|
||||
}, options.Find().
|
||||
SetProjection(homeBannerAppProjection).
|
||||
SetSort(bson.D{
|
||||
{Key: homebannermod.FN.HomeBanner.Rank, Value: -1},
|
||||
{Key: homebannermod.FN.HomeBanner.CreatedAt, Value: -1},
|
||||
{Key: homebannermod.FN.HomeBanner.Id, Value: -1},
|
||||
}).
|
||||
SetLimit(homeBannerListLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]proto.HomeBannerInfo, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, proto.HomeBannerInfo{
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Image: netutil.MergeURL(imgCdn, item.Image),
|
||||
VerticalImage: netutil.MergeURL(imgCdn, item.VerticalImage),
|
||||
Url: item.Url,
|
||||
Rank: item.Rank,
|
||||
Status: item.Status,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HomeBaseData 获取首页公共配置数据
|
||||
func HomeBaseData(ctx context.Context) proto.HomeBaseResp {
|
||||
resp := proto.HomeBaseResp{
|
||||
Translations: translationser.GetTranslationData(),
|
||||
Config: configser.GetRequestConfig(ctx),
|
||||
BottonTranslations: translationser.GetTranslationBottomList(),
|
||||
Advitise: configser.GetAdvitise(),
|
||||
Announcement: configser.GetAnnouncement(),
|
||||
}
|
||||
configser.RewriteImageCdn(ctx, &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// HomeTagsData 获取首页标签数据
|
||||
func HomeTagsData(ctx context.Context) proto.HomeTagsResp {
|
||||
resp := proto.HomeTagsResp{
|
||||
HomeBaseResp: HomeBaseData(ctx),
|
||||
VideoTagsList: configser.GetHomeVideoTagList(),
|
||||
PlayletTagsList: configser.GetHomePlayletTagList(),
|
||||
}
|
||||
configser.RewriteImageCdn(ctx, &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// homeRequestPath 从 gin 上下文中提取当前请求路径,避免 API 层重复组装首页响应字段
|
||||
func homeRequestPath(ctx context.Context) string {
|
||||
ginCtx, ok := ctx.(*gin.Context)
|
||||
if !ok || ginCtx.Request == nil || ginCtx.Request.URL == nil {
|
||||
return ""
|
||||
}
|
||||
return ginCtx.Request.URL.Path
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package moreser
|
||||
|
||||
// NormalizePage 规范化分页参数,限制单次请求数量避免大分页拖垮 MongoDB。
|
||||
func NormalizePage(pageNum, pageSize, defaultPageSize, maxPageSize int64) (int64, int64) {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if pageSize > maxPageSize {
|
||||
pageSize = maxPageSize
|
||||
}
|
||||
return pageNum, pageSize
|
||||
}
|
||||
|
||||
// InsertedAdsCount 计算实际可穿插的广告数量,每 interval 条内容后插入 1 条广告。
|
||||
func InsertedAdsCount(contentTotal, adsTotal, interval int64) int64 {
|
||||
if contentTotal <= 0 || adsTotal <= 0 || interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
insertSlots := contentTotal / interval
|
||||
if insertSlots > adsTotal {
|
||||
return adsTotal
|
||||
}
|
||||
return insertSlots
|
||||
}
|
||||
|
||||
// ContentPosition 计算内容原始下标在混排列表中的全局位置。
|
||||
func ContentPosition(contentIndex, adsTotal, interval int64) int64 {
|
||||
if interval <= 0 {
|
||||
return contentIndex
|
||||
}
|
||||
adsBefore := contentIndex / interval
|
||||
if adsBefore > adsTotal {
|
||||
adsBefore = adsTotal
|
||||
}
|
||||
return contentIndex + adsBefore
|
||||
}
|
||||
|
||||
// FirstContentIndexAtOrAfter 找到指定混排位置之后第一条内容下标。
|
||||
func FirstContentIndexAtOrAfter(pos, contentTotal, adsTotal, interval int64) int64 {
|
||||
left, right := int64(0), contentTotal
|
||||
for left < right {
|
||||
mid := left + (right-left)/2
|
||||
if ContentPosition(mid, adsTotal, interval) < pos {
|
||||
left = mid + 1
|
||||
continue
|
||||
}
|
||||
right = mid
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// ContentRange 计算当前混排分页需要读取的内容下标范围。
|
||||
func ContentRange(start, end, contentTotal, adsTotal, interval int64) (int64, int64) {
|
||||
contentStart := FirstContentIndexAtOrAfter(start, contentTotal, adsTotal, interval)
|
||||
contentEnd := FirstContentIndexAtOrAfter(end, contentTotal, adsTotal, interval)
|
||||
if contentStart >= contentEnd {
|
||||
return 0, 0
|
||||
}
|
||||
return contentStart, contentEnd - contentStart
|
||||
}
|
||||
|
||||
// ContentRangeWithoutTotal 计算当前混排分页需要读取的内容范围,不依赖内容总数。
|
||||
// 适用于列表接口不返回 totalCount 的场景;广告位仍按“每 interval 条内容后插入 1 条广告”计算。
|
||||
func ContentRangeWithoutTotal(start, end, adsTotal, interval int64) (int64, int64) {
|
||||
if start < 0 || end <= start {
|
||||
return 0, 0
|
||||
}
|
||||
contentStart := contentIndexAtMixedPosition(start, adsTotal, interval)
|
||||
contentEnd := contentIndexAtMixedPosition(end, adsTotal, interval)
|
||||
if contentStart >= contentEnd {
|
||||
return 0, 0
|
||||
}
|
||||
return contentStart, contentEnd - contentStart
|
||||
}
|
||||
|
||||
// ContentIndexAtPositionWithoutTotal 计算混排位置对应的内容下标,不判断该位置是否为广告。
|
||||
func ContentIndexAtPositionWithoutTotal(pos, adsTotal, interval int64) int64 {
|
||||
return contentIndexAtMixedPosition(pos, adsTotal, interval)
|
||||
}
|
||||
|
||||
// AdIndexAtPositionWithoutTotal 计算混排位置对应的广告下标,返回 -1 表示该位置不是广告。
|
||||
func AdIndexAtPositionWithoutTotal(pos, adsTotal, interval int64) int64 {
|
||||
if adsTotal <= 0 || interval <= 0 || pos < 0 {
|
||||
return -1
|
||||
}
|
||||
cycle := interval + 1
|
||||
if pos >= adsTotal*cycle {
|
||||
return -1
|
||||
}
|
||||
if pos%cycle != interval {
|
||||
return -1
|
||||
}
|
||||
return pos / cycle
|
||||
}
|
||||
|
||||
// contentIndexAtMixedPosition 返回混排位置之前已经出现的内容数量。
|
||||
func contentIndexAtMixedPosition(pos, adsTotal, interval int64) int64 {
|
||||
if pos <= 0 {
|
||||
return 0
|
||||
}
|
||||
if adsTotal <= 0 || interval <= 0 {
|
||||
return pos
|
||||
}
|
||||
adCycle := interval + 1
|
||||
adAreaSize := adsTotal * adCycle
|
||||
if pos >= adAreaSize {
|
||||
return pos - adsTotal
|
||||
}
|
||||
fullCycles := pos / adCycle
|
||||
remain := pos % adCycle
|
||||
return fullCycles*interval + minInt64(remain, interval)
|
||||
}
|
||||
|
||||
func minInt64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// AdIndexAtPosition 计算混排位置对应的广告下标,返回 -1 表示该位置不是广告。
|
||||
func AdIndexAtPosition(pos, contentTotal, adsTotal, interval int64) int64 {
|
||||
if interval <= 0 {
|
||||
return -1
|
||||
}
|
||||
nextContent := FirstContentIndexAtOrAfter(pos+1, contentTotal, adsTotal, interval)
|
||||
prevContent := nextContent - 1
|
||||
if prevContent >= 0 && (prevContent+1)%interval == 0 && (prevContent+1)/interval-1 < adsTotal {
|
||||
if ContentPosition(prevContent, adsTotal, interval)+1 == pos {
|
||||
return (prevContent+1)/interval - 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package navigationser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/translationcategorymod"
|
||||
"videoweb/pub/models/translationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
headerNavigationCategoryLimit int64 = 30
|
||||
headerNavigationInfoLimit int64 = 50
|
||||
)
|
||||
|
||||
var (
|
||||
headerNavigationCategoryProjection = bson.D{
|
||||
{Key: translationcategorymod.FN.TranslationCategory.Id, Value: 1},
|
||||
{Key: translationcategorymod.FN.TranslationCategory.Name, Value: 1},
|
||||
{Key: translationcategorymod.FN.TranslationCategory.Icon, Value: 1},
|
||||
{Key: translationcategorymod.FN.TranslationCategory.CategoryType, Value: 1},
|
||||
{Key: translationcategorymod.FN.TranslationCategory.UrlValue, Value: 1},
|
||||
}
|
||||
headerNavigationInfoProjection = bson.D{
|
||||
{Key: translationmod.FN.TranslationInfo.Id, Value: 1},
|
||||
{Key: translationmod.FN.TranslationInfo.Name, Value: 1},
|
||||
{Key: translationmod.FN.TranslationInfo.TransCategoryId, Value: 1},
|
||||
{Key: translationmod.FN.TranslationInfo.IsFriendAds, Value: 1},
|
||||
{Key: translationmod.FN.TranslationInfo.Link, Value: 1},
|
||||
}
|
||||
)
|
||||
|
||||
// HeaderNavigation 查询头部导航配置
|
||||
func HeaderNavigation(ctx context.Context) (proto.AppTranslationInfo, *srvdef.SrvErr) {
|
||||
categoryList, err := translationcategorymod.FindManyTranslationCategorys(ctx, bson.M{
|
||||
translationcategorymod.FN.TranslationCategory.Status: true,
|
||||
translationcategorymod.FN.TranslationCategory.CategoryType: bson.M{
|
||||
"$in": []pubdef.TranslationType{
|
||||
pubdef.TranslationTypeUrl,
|
||||
pubdef.TranslationTypeSub,
|
||||
},
|
||||
},
|
||||
}, options.Find().
|
||||
SetProjection(headerNavigationCategoryProjection).
|
||||
SetSort(bson.D{
|
||||
{Key: translationcategorymod.FN.TranslationCategory.Rank, Value: -1},
|
||||
{Key: translationcategorymod.FN.TranslationCategory.Id, Value: -1},
|
||||
}).
|
||||
SetLimit(headerNavigationCategoryLimit))
|
||||
if err != nil {
|
||||
return proto.AppTranslationInfo{}, err
|
||||
}
|
||||
resp := proto.AppTranslationInfo{CategoryList: make([]proto.TranslationCategory, 0, len(categoryList))}
|
||||
if len(categoryList) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
categoryIds := make([]int64, 0, len(categoryList))
|
||||
for _, item := range categoryList {
|
||||
if item != nil && item.Id > 0 {
|
||||
categoryIds = append(categoryIds, item.Id)
|
||||
}
|
||||
}
|
||||
translationListByCategory, err := queryHeaderTranslationList(ctx, categoryIds)
|
||||
if err != nil {
|
||||
return proto.AppTranslationInfo{}, err
|
||||
}
|
||||
imgCdn := configser.ImageCdnFromCtx(ctx)
|
||||
for _, item := range categoryList {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
resp.CategoryList = append(resp.CategoryList, proto.TranslationCategory{
|
||||
ID: item.Id,
|
||||
Name: item.Name,
|
||||
Icon: netutil.MergeURL(imgCdn, item.Icon),
|
||||
UrlValue: item.UrlValue,
|
||||
CategoryType: item.CategoryType,
|
||||
TranslationList: translationListByCategory[item.Id],
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// queryHeaderTranslationList 批量查询头部导航二级配置并按分类ID分组
|
||||
func queryHeaderTranslationList(ctx context.Context, categoryIds []int64) (map[int64][]proto.TranslationInfo, *srvdef.SrvErr) {
|
||||
result := make(map[int64][]proto.TranslationInfo, len(categoryIds))
|
||||
if len(categoryIds) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
list, err := translationmod.FindManyTranslationInfos(ctx, bson.M{
|
||||
translationmod.FN.TranslationInfo.Status: true,
|
||||
translationmod.FN.TranslationInfo.TransCategoryId: bson.M{"$in": categoryIds},
|
||||
}, options.Find().
|
||||
SetProjection(headerNavigationInfoProjection).
|
||||
SetSort(bson.D{
|
||||
{Key: translationmod.FN.TranslationInfo.Rank, Value: -1},
|
||||
{Key: translationmod.FN.TranslationInfo.Id, Value: -1},
|
||||
}).
|
||||
SetLimit(headerNavigationInfoLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item == nil || item.IsFriendAds {
|
||||
continue
|
||||
}
|
||||
info := proto.TranslationInfo{
|
||||
ID: item.Id,
|
||||
Name: item.Name,
|
||||
TransCategoryId: item.TransCategoryId,
|
||||
UrlValue: headerTranslationURL(item),
|
||||
}
|
||||
if info.UrlValue == "" {
|
||||
continue
|
||||
}
|
||||
result[item.TransCategoryId] = append(result[item.TransCategoryId], info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// headerTranslationURL 解析导航明细跳转地址
|
||||
func headerTranslationURL(item *translationmod.TranslationInfo) string {
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(item.Link)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/app/service/reportser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/response"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/useragent"
|
||||
"videoweb/pub/models/playletfavoritemod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// FavoritePlaylet 收藏或取消收藏上架短剧,重复请求不重复变更收藏数
|
||||
func FavoritePlaylet(ctx context.Context, userID int64, ip string, ua useragent.UA, req *proto.FavoritePlayletReq) (*proto.FavoritePlayletResp, *srvdef.SrvErr) {
|
||||
if req == nil || userID <= 0 || req.Id <= 0 || req.IsFavorite == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Id: req.Id, playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
}, options.FindOne().SetProjection(bson.M{
|
||||
playletmod.FN.Playlet.Id: 1, playletmod.FN.Playlet.Title: 1,
|
||||
playletmod.FN.Playlet.TagIds: 1, playletmod.FN.Playlet.ThemeIds: 1,
|
||||
playletmod.FN.Playlet.CountCollect: 1,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed := false
|
||||
flag := int64(1)
|
||||
if *req.IsFavorite {
|
||||
changed, err = playletfavoritemod.Upsert(ctx, userID, req.Id, time.Now())
|
||||
} else {
|
||||
flag = 2
|
||||
changed, err = playletfavoritemod.Delete(ctx, bson.M{
|
||||
playletfavoritemod.FieldUserID: userID, playletfavoritemod.FieldPlayletID: req.Id,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changed {
|
||||
delta := int64(1)
|
||||
if !*req.IsFavorite {
|
||||
delta = -1
|
||||
}
|
||||
if delta > 0 || playlet.CountCollect > 0 {
|
||||
if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}, bson.M{
|
||||
"$inc": bson.M{playletmod.FN.Playlet.CountCollect: delta},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
playlet.CountCollect += delta
|
||||
}
|
||||
if err := reportser.ReportPlayletCollect(userID, ua, ip, playlet, flag); err != nil {
|
||||
log.Warn("ReportPlayletCollect failed", log.Any("playletId", req.Id), log.Any("userId", userID), log.E(err))
|
||||
}
|
||||
}
|
||||
return &proto.FavoritePlayletResp{Id: req.Id, IsFavorite: *req.IsFavorite, CountCollect: playlet.CountCollect}, nil
|
||||
}
|
||||
|
||||
// QueryFavoritePlayletList 按收藏时间倒序查询当前用户收藏的上架短剧
|
||||
func QueryFavoritePlayletList(ctx context.Context, userID int64, req *proto.QueryFavoritePlayletListReq) (*proto.QueryFavoritePlayletListResp, *srvdef.SrvErr) {
|
||||
if userID <= 0 || req == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, 20, 100)
|
||||
pipeline := []bson.M{
|
||||
{"$match": bson.M{playletfavoritemod.FieldUserID: userID}},
|
||||
{"$lookup": bson.M{
|
||||
"from": playletmod.TN.Playlet,
|
||||
"localField": playletfavoritemod.FieldPlayletID,
|
||||
"foreignField": playletmod.FN.Playlet.Id,
|
||||
"as": "playlet",
|
||||
}},
|
||||
{"$unwind": "$playlet"},
|
||||
{"$match": bson.M{"playlet." + playletmod.FN.Playlet.Status: playletmod.StatusUp}},
|
||||
}
|
||||
countRows := make([]struct {
|
||||
Total int64 `bson:"total"`
|
||||
}, 0)
|
||||
countPipeline := append(append([]bson.M(nil), pipeline...), bson.M{"$count": "total"})
|
||||
if err := playletfavoritemod.Aggregate(ctx, &countRows, countPipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total := int64(0)
|
||||
if len(countRows) > 0 {
|
||||
total = countRows[0].Total
|
||||
}
|
||||
resp := &proto.QueryFavoritePlayletListResp{BasePageResp: response.BasePageResp{
|
||||
TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletData, 0),
|
||||
}, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}
|
||||
if total == 0 || pageNum > (total-1)/pageSize+1 {
|
||||
return resp, nil
|
||||
}
|
||||
pagePipeline := append(append([]bson.M(nil), pipeline...),
|
||||
bson.M{"$sort": bson.D{{Key: playletfavoritemod.FieldCreatedAt, Value: -1}, {Key: playletfavoritemod.FieldID, Value: -1}}},
|
||||
bson.M{"$skip": (pageNum - 1) * pageSize}, bson.M{"$limit": pageSize},
|
||||
bson.M{"$replaceRoot": bson.M{"newRoot": "$playlet"}},
|
||||
bson.M{"$project": appPlayletRecommendProjection},
|
||||
)
|
||||
list := make(playletmod.PlayletList, 0)
|
||||
if err := playletfavoritemod.Aggregate(ctx, &list, pagePipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdn := configser.GetRequestConfig(ctx).ImgCdn
|
||||
items := make([]*proto.PlayletData, 0, len(list))
|
||||
for _, item := range list {
|
||||
data := playletToAppData(item, cdn, tagNames, themeNames)
|
||||
items = append(items, &data)
|
||||
}
|
||||
resp.List = items
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/pub/common/response"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/playletlikemod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
// QueryLikePlayletList 按点赞时间倒序查询当前用户点赞的上架短剧
|
||||
func QueryLikePlayletList(ctx context.Context, userID int64, req *proto.QueryLikePlayletListReq) (*proto.QueryLikePlayletListResp, *srvdef.SrvErr) {
|
||||
if userID <= 0 || req == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, 20, 100)
|
||||
pipeline := []bson.M{
|
||||
{"$match": bson.M{playletlikemod.FN.PlayletLike.UserID: userID}},
|
||||
{"$lookup": bson.M{
|
||||
"from": playletmod.TN.Playlet,
|
||||
"localField": playletlikemod.FN.PlayletLike.PlayletID,
|
||||
"foreignField": playletmod.FN.Playlet.Id,
|
||||
"as": "playlet",
|
||||
}},
|
||||
{"$unwind": "$playlet"},
|
||||
{"$match": bson.M{"playlet." + playletmod.FN.Playlet.Status: playletmod.StatusUp}},
|
||||
}
|
||||
countRows := make([]struct {
|
||||
Total int64 `bson:"total"`
|
||||
}, 0)
|
||||
if err := playletlikemod.AggregatePlayletLike(ctx, &countRows, append(append([]bson.M(nil), pipeline...), bson.M{"$count": "total"})); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total := int64(0)
|
||||
if len(countRows) > 0 {
|
||||
total = countRows[0].Total
|
||||
}
|
||||
resp := &proto.QueryLikePlayletListResp{BasePageResp: response.BasePageResp{
|
||||
TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletData, 0),
|
||||
}, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}
|
||||
if total == 0 || pageNum > (total-1)/pageSize+1 {
|
||||
return resp, nil
|
||||
}
|
||||
pagePipeline := append([]bson.M{pipeline[0], {"$sort": bson.D{{Key: playletlikemod.FN.PlayletLike.CreatedAt, Value: -1}, {Key: playletlikemod.FN.PlayletLike.Id, Value: -1}}}}, pipeline[1:]...)
|
||||
pagePipeline = append(pagePipeline,
|
||||
bson.M{"$skip": (pageNum - 1) * pageSize},
|
||||
bson.M{"$limit": pageSize},
|
||||
bson.M{"$replaceRoot": bson.M{"newRoot": "$playlet"}},
|
||||
bson.M{"$project": appPlayletRecommendProjection},
|
||||
)
|
||||
list := make(playletmod.PlayletList, 0)
|
||||
if err := playletlikemod.AggregatePlayletLike(ctx, &list, pagePipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdn := configser.GetRequestConfig(ctx).ImgCdn
|
||||
items := make([]*proto.PlayletData, 0, len(list))
|
||||
for _, item := range list {
|
||||
data := playletToAppData(item, cdn, tagNames, themeNames)
|
||||
items = append(items, &data)
|
||||
}
|
||||
resp.List = items
|
||||
return resp, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/storage/proxy"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func TestPlayletContentListRejectsInvalidDataType(t *testing.T) {
|
||||
for _, dataType := range []int64{0, 3, -1} {
|
||||
if _, err := ContentList(context.Background(), &proto.PlayletContentListReq{DataType: dataType}); err == nil {
|
||||
t.Errorf("dataType %d 未返回参数错误", dataType)
|
||||
}
|
||||
}
|
||||
if _, err := ContentList(context.Background(), nil); err == nil {
|
||||
t.Fatal("空请求未返回参数错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletContentWatchSort(t *testing.T) {
|
||||
playletSort := playletContentPlayletSort()
|
||||
if playletSort[0].Key != playletmod.FN.Playlet.CountBrowse || playletSort[0].Value != -1 {
|
||||
t.Fatalf("短剧未优先按观看量排序: %v", playletSort)
|
||||
}
|
||||
pipeline := playletContentVideoPipeline(7, 10)
|
||||
if pipeline[0]["$match"].(bson.M)[videoinformationmod.FN.VideoInformation.Status] == nil {
|
||||
t.Fatalf("视频未限制上架状态: %v", pipeline[0])
|
||||
}
|
||||
fields := pipeline[1]["$addFields"].(bson.M)
|
||||
watchSum := fields["watchScore"].(bson.M)["$add"].([]interface{})
|
||||
if len(watchSum) != 2 {
|
||||
t.Fatalf("视频观看量未包含真实数和后台配置数: %v", fields)
|
||||
}
|
||||
for i, field := range []string{
|
||||
videoinformationmod.FN.VideoInformation.Real.WatchTimes,
|
||||
videoinformationmod.FN.VideoInformation.Fake.WatchTimes,
|
||||
} {
|
||||
value := watchSum[i].(bson.M)["$ifNull"].([]interface{})
|
||||
if value[0] != "$"+field || value[1] != 0 {
|
||||
t.Fatalf("视频观看量字段 %d 错误: %v", i, value)
|
||||
}
|
||||
}
|
||||
sort := pipeline[2]["$sort"].(bson.D)
|
||||
if sort[0].Key != "watchScore" || sort[0].Value != -1 ||
|
||||
sort[1].Key != videoinformationmod.FN.VideoInformation.UpdatedAt ||
|
||||
sort[2].Key != videoinformationmod.FN.VideoInformation.Id {
|
||||
t.Fatalf("视频未优先按展示观看量排序: %v", sort)
|
||||
}
|
||||
if pipeline[3]["$skip"] != int64(7) || pipeline[4]["$limit"] != int64(10) {
|
||||
t.Fatalf("视频分页位置错误: %v", pipeline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletContentListMixedPagination(t *testing.T) {
|
||||
for _, dataType := range []int64{1, 2} {
|
||||
const contentCount int64 = 14
|
||||
ads := []proxy.AdvitiseInfo{{Id: "ad-1"}, {Id: "ad-2"}}
|
||||
interval := playletContentAdInterval(dataType)
|
||||
adCount := playletContentInsertedAdsCount(contentCount, len(ads), interval)
|
||||
wantAdCount := int64(3)
|
||||
if dataType == 2 {
|
||||
wantAdCount = 2
|
||||
}
|
||||
if adCount != wantAdCount {
|
||||
t.Fatalf("dataType %d 广告位数量 = %d, want %d", dataType, adCount, wantAdCount)
|
||||
}
|
||||
content := make([]*proto.PlayletContentListItem, 0, contentCount)
|
||||
for id := int64(1); id <= contentCount; id++ {
|
||||
item := &proto.PlayletContentListItem{Type: 0}
|
||||
if dataType == 1 {
|
||||
item.Playlet = &proto.PlayletData{Id: id}
|
||||
} else {
|
||||
item.Video = &proto.VideoInfo{ContentData: proto.ContentData{Id: id}}
|
||||
}
|
||||
content = append(content, item)
|
||||
}
|
||||
var got []*proto.PlayletContentListItem
|
||||
for start := int64(0); start < contentCount+adCount; start += 6 {
|
||||
end := start + 6
|
||||
if end > contentCount+adCount {
|
||||
end = contentCount + adCount
|
||||
}
|
||||
contentStart, contentLimit := moreser.ContentRange(start, end, contentCount, adCount, interval)
|
||||
page := buildPlayletContentMixedPage(content[contentStart:contentStart+contentLimit], ads, "",
|
||||
start, end, contentStart, adCount, interval)
|
||||
if int64(len(page)) != end-start {
|
||||
t.Fatalf("dataType %d 分页 [%d,%d) 返回 %d 条", dataType, start, end, len(page))
|
||||
}
|
||||
got = append(got, page...)
|
||||
}
|
||||
var nextID int64 = 1
|
||||
for pos, item := range got {
|
||||
cycle := int(interval + 1)
|
||||
if pos%cycle == int(interval) && int64(pos/cycle) < adCount {
|
||||
if item.Type != 2 || item.Advertise == nil || item.Advertise.Id != ads[(pos/cycle)%len(ads)].Id {
|
||||
t.Fatalf("dataType %d 位置 %d 广告错误: %+v", dataType, pos, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if item.Type != 0 || item.Advertise != nil {
|
||||
t.Fatalf("dataType %d 位置 %d 内容类型错误: %+v", dataType, pos, item)
|
||||
}
|
||||
if dataType == 1 && (item.Playlet == nil || item.Playlet.Id != nextID || item.Video != nil) {
|
||||
t.Fatalf("位置 %d 短剧错误: %+v", pos, item)
|
||||
}
|
||||
if dataType == 2 && (item.Video == nil || item.Video.Id != nextID || item.Playlet != nil) {
|
||||
t.Fatalf("位置 %d 视频错误: %+v", pos, item)
|
||||
}
|
||||
nextID++
|
||||
}
|
||||
if nextID != contentCount+1 {
|
||||
t.Fatalf("dataType %d 内容末尾 ID = %d", dataType, nextID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletContentAdSlots(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
dataType int64
|
||||
content int64
|
||||
ads int
|
||||
want int64
|
||||
}{
|
||||
{1, 0, 2, 0}, {1, 4, 2, 0}, {1, 5, 2, 1}, {1, 8, 2, 1}, {1, 9, 2, 2}, {1, 13, 1, 3}, {1, 13, 0, 0},
|
||||
{2, 0, 2, 0}, {2, 5, 2, 0}, {2, 6, 2, 1}, {2, 10, 2, 1}, {2, 11, 2, 2}, {2, 16, 1, 3}, {2, 16, 0, 0},
|
||||
} {
|
||||
if got := playletContentInsertedAdsCount(tc.content, tc.ads, playletContentAdInterval(tc.dataType)); got != tc.want {
|
||||
t.Errorf("dataType %d 内容 %d 条、广告 %d 条得到 %d 个广告位,want %d", tc.dataType, tc.content, tc.ads, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/storage/proxy"
|
||||
)
|
||||
|
||||
func TestPlayletListSortOptions(t *testing.T) {
|
||||
for _, sortType := range []int64{0, 1} {
|
||||
sort := playletListSort(sortType)
|
||||
if len(sort) == 0 || sort[0].Key != playletmod.FN.Playlet.HeatValue {
|
||||
t.Fatalf("sortType %d 未按热度优先排序: %v", sortType, sort)
|
||||
}
|
||||
}
|
||||
sort := playletListSort(2)
|
||||
if len(sort) == 0 || sort[0].Key != playletmod.FN.Playlet.UpdatedAt || sort[0].Value != -1 {
|
||||
t.Fatalf("sortType 2 未按最近更新降序排序: %v", sort)
|
||||
}
|
||||
filter := playletListFilter()
|
||||
if filter[playletmod.FN.Playlet.Status] != playletmod.StatusUp {
|
||||
t.Fatalf("未限制上架状态: %v", filter)
|
||||
}
|
||||
if _, ok := filter[playletmod.FN.Playlet.UpdatedAt]; ok {
|
||||
t.Fatalf("最近更新不应限制更新时间: %v", filter)
|
||||
}
|
||||
if _, err := PlayletList(context.Background(), &proto.PlayletListReq{SortType: 3}); err == nil {
|
||||
t.Fatal("未拒绝无效排序方式")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialEpisodeRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
count int64
|
||||
open []int64
|
||||
closed []int64
|
||||
}{
|
||||
{0, []int64{1, 2}, []int64{0, 3}},
|
||||
{1, []int64{1}, []int64{2}},
|
||||
{3, []int64{1, 2, 3}, []int64{4}},
|
||||
{-1, []int64{1, 1001}, []int64{0}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
playlet := &playletmod.Playlet{Status: playletmod.StatusUp, FreeEpisodeCount: tt.count}
|
||||
for _, episode := range tt.open {
|
||||
if !playlet.IsOfficialEpisodeOpen(episode) {
|
||||
t.Fatalf("开放范围 %d 应允许第 %d 集", tt.count, episode)
|
||||
}
|
||||
}
|
||||
for _, episode := range tt.closed {
|
||||
if playlet.IsOfficialEpisodeOpen(episode) {
|
||||
t.Fatalf("开放范围 %d 不应允许第 %d 集", tt.count, episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (&playletmod.Playlet{Status: playletmod.StatusDown, FreeEpisodeCount: -1}).IsOfficialEpisodeOpen(1) {
|
||||
t.Fatal("下架短剧不应开放")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletListMixedPagination(t *testing.T) {
|
||||
const playletCount int64 = 14
|
||||
ads := []proxy.AdvitiseInfo{{Id: "ad-1"}, {Id: "ad-2"}}
|
||||
adCount := playletListInsertedAdsCount(playletCount, len(ads))
|
||||
if adCount != 3 {
|
||||
t.Fatalf("广告位数量 = %d, want 3", adCount)
|
||||
}
|
||||
all := make(playletmod.PlayletList, 0, playletCount)
|
||||
for id := int64(1); id <= playletCount; id++ {
|
||||
all = append(all, &playletmod.Playlet{Id: id})
|
||||
}
|
||||
var got []*proto.PlayletRecommendListItem
|
||||
for start := int64(0); start < playletCount+adCount; start += 6 {
|
||||
end := start + 6
|
||||
if end > playletCount+adCount {
|
||||
end = playletCount + adCount
|
||||
}
|
||||
contentStart, contentLimit := moreser.ContentRange(start, end, playletCount, adCount, playletListAdInterval)
|
||||
page := buildPlayletListMixedPage(all[contentStart:contentStart+contentLimit], ads, "", nil, nil,
|
||||
start, end, contentStart, adCount)
|
||||
if int64(len(page)) != end-start {
|
||||
t.Fatalf("分页 [%d,%d) 返回 %d 条", start, end, len(page))
|
||||
}
|
||||
got = append(got, page...)
|
||||
}
|
||||
var playletID int64 = 1
|
||||
for pos, item := range got {
|
||||
if pos%5 == 4 && pos < 15 {
|
||||
if item.Type != proto.PlayletRecommendItemTypeAdvertise || item.AdvertiseId != ads[(pos/5)%len(ads)].Id {
|
||||
t.Fatalf("位置 %d 广告错误: %+v", pos, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if item.Type != proto.PlayletRecommendItemTypePlaylet || item.Id != playletID {
|
||||
t.Fatalf("位置 %d 短剧错误: %+v, want ID %d", pos, item, playletID)
|
||||
}
|
||||
playletID++
|
||||
}
|
||||
if playletID != playletCount+1 {
|
||||
t.Fatalf("短剧末尾 ID = %d, want %d", playletID, playletCount+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletListAdSlots(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
playlets int64
|
||||
ads int
|
||||
want int64
|
||||
}{
|
||||
{0, 2, 0}, {4, 2, 0}, {5, 2, 1}, {8, 2, 1}, {9, 2, 2}, {13, 1, 3}, {13, 0, 0},
|
||||
} {
|
||||
if got := playletListInsertedAdsCount(tc.playlets, tc.ads); got != tc.want {
|
||||
t.Errorf("短剧 %d 条、广告 %d 条得到 %d 个广告位,want %d", tc.playlets, tc.ads, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package questionnaireser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/questionnairemod"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFiles = 3
|
||||
)
|
||||
|
||||
// Submit 提交问卷(App端)
|
||||
// ip:客户端真实 IP;req.UserId 为 0 表示匿名用户,仅按 IP 限流
|
||||
func Submit(ctx context.Context, ip string, req *proto.QuestionnaireSubmitReq) *srvdef.SrvErr {
|
||||
// IP 维度:同一 IP 每分钟只能提交一次
|
||||
ipCount, err := redis.Incr(redisconst.QuestionnaireLimitIpKey(ip), redisconst.QuestionnaireLimitExpire)
|
||||
if err != nil {
|
||||
log.Error("问卷提交IP限频Redis异常", log.E(err))
|
||||
}
|
||||
if ipCount > 1 {
|
||||
return srvdef.New(srvdef.ErrOperationTooFrequently, "user operation is too frequent", "提交太频繁,请稍后再试")
|
||||
}
|
||||
|
||||
// 用户维度:登录用户(userId > 0)每分钟只能提交一次
|
||||
if req.UserId > 0 {
|
||||
userCount, err := redis.Incr(redisconst.QuestionnaireLimitUserKey(req.UserId), redisconst.QuestionnaireLimitExpire)
|
||||
if err != nil {
|
||||
log.Error("问卷提交用户限频Redis异常", log.E(err))
|
||||
}
|
||||
if userCount > 1 {
|
||||
return srvdef.New(srvdef.ErrOperationTooFrequently, "user operation is too frequent", "提交太频繁,请稍后再试")
|
||||
}
|
||||
}
|
||||
|
||||
// 校验附件数量限制
|
||||
fileCount := 0
|
||||
if req.Attachments != nil && len(req.Attachments) > 0 {
|
||||
fileCount = len(req.Attachments)
|
||||
}
|
||||
if fileCount > maxFiles {
|
||||
return srvdef.New(srvdef.ErrParamError, fmt.Sprintf("文件不超过%d个", fileCount), "")
|
||||
}
|
||||
attachments := req.Attachments
|
||||
if attachments == nil {
|
||||
attachments = []questionnairemod.Attachment{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
doc := &questionnairemod.QuestionnaireModel{
|
||||
Id: primitive.NewObjectID(),
|
||||
UserId: req.UserId,
|
||||
QuestionType: req.QuestionType,
|
||||
Frequency: req.Frequency,
|
||||
Province: req.Province,
|
||||
City: req.City,
|
||||
Operator: req.Operator,
|
||||
NetworkType: req.NetworkType,
|
||||
DeviceType: req.DeviceType,
|
||||
DeviceModel: req.DeviceModel,
|
||||
Description: req.Description,
|
||||
Contact: req.Contact,
|
||||
Status: questionnairemod.StatusPending,
|
||||
Attachments: attachments,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return questionnairemod.InsertOne(ctx, doc)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package reportser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/useragent"
|
||||
"videoweb/pub/dataReport"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
"videoweb/pub/models/eventmod"
|
||||
"videoweb/pub/models/playletchaptermod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/playletthememod"
|
||||
"videoweb/pub/models/videocategorymod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
func ReportVideoComment(userId int64, ua useragent.UA, ip string, media *videoinformationmod.VideoInformation, content string) error {
|
||||
if media == nil {
|
||||
return srvdef.New(srvdef.Failure, "invalid user or media")
|
||||
}
|
||||
typeID, typeName := resolveMediaType(media)
|
||||
event := dataReport.NewCommonField(dataReport.EventTypeVideoComment, "", ua, ip, userId)
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCommentEvent{
|
||||
VideoEventPub: dataReport.VideoEventPub{
|
||||
MediaId: "",
|
||||
VideoID: fmt.Sprintf("%v", media.Id),
|
||||
VideoTitle: media.Title,
|
||||
VideoTypeID: typeID,
|
||||
VideoTypeName: typeName,
|
||||
VideoContentType: videoContentType(media),
|
||||
RecommendTraceId: "",
|
||||
},
|
||||
CommentContent: content,
|
||||
})
|
||||
return insertEvent(event)
|
||||
}
|
||||
|
||||
func ReportVideoCollect(userId int64, ua useragent.UA, ip string, media *videoinformationmod.VideoInformation, flag int64) error {
|
||||
if media == nil {
|
||||
return srvdef.New(srvdef.Failure, "invalid user or media")
|
||||
}
|
||||
typeID, typeName := resolveMediaType(media)
|
||||
event := dataReport.NewCommonField(dataReport.EventTypeVideoCollect, "", ua, ip, userId)
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCollectEvent{
|
||||
VideoEventPub: dataReport.VideoEventPub{
|
||||
MediaId: "",
|
||||
VideoID: fmt.Sprintf("%v", media.Id),
|
||||
VideoTitle: media.Title,
|
||||
VideoTypeID: typeID,
|
||||
VideoTypeName: typeName,
|
||||
VideoContentType: videoContentType(media),
|
||||
RecommendTraceId: "",
|
||||
},
|
||||
Flag: dataReport.JsonNumber(flag),
|
||||
})
|
||||
return insertEvent(event)
|
||||
}
|
||||
|
||||
func ReportVideoLike(userId int64, ua useragent.UA, ip string, media *videoinformationmod.VideoInformation, flag int64) error {
|
||||
if media == nil {
|
||||
return srvdef.New(srvdef.Failure, "invalid user or media")
|
||||
}
|
||||
typeID, typeName := resolveMediaType(media)
|
||||
event := dataReport.NewCommonField(dataReport.EventTypeVideoLike, "", ua, ip, userId)
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoLikeEvent{
|
||||
VideoEventPub: dataReport.VideoEventPub{
|
||||
MediaId: "",
|
||||
VideoID: fmt.Sprintf("%v", media.Id),
|
||||
VideoTitle: media.Title,
|
||||
VideoTypeID: typeID,
|
||||
VideoTypeName: typeName,
|
||||
VideoContentType: videoContentType(media),
|
||||
RecommendTraceId: "",
|
||||
},
|
||||
Flag: dataReport.JsonNumber(flag),
|
||||
})
|
||||
return insertEvent(event)
|
||||
}
|
||||
|
||||
func ReportPlayletLike(userId int64, ua useragent.UA, ip string, playlet *playletmod.Playlet, flag int64) error {
|
||||
if playlet == nil {
|
||||
return srvdef.New(srvdef.Failure, "invalid user or playlet")
|
||||
}
|
||||
pub := playletToDramaPub(context.Background(), playlet, 0)
|
||||
if pub.DramaID == "" {
|
||||
return nil
|
||||
}
|
||||
event := dataReport.NewCommonField(dataReport.EventTypeDramaLike, "", ua, ip, userId)
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaLikeEvent{
|
||||
DramaEventPub: pub,
|
||||
Flag: dataReport.JsonNumber(flag),
|
||||
})
|
||||
return insertEvent(event)
|
||||
}
|
||||
|
||||
// ReportPlayletCollect 上报短剧收藏状态变化
|
||||
func ReportPlayletCollect(userId int64, ua useragent.UA, ip string, playlet *playletmod.Playlet, flag int64) error {
|
||||
if playlet == nil {
|
||||
return srvdef.New(srvdef.Failure, "invalid user or playlet")
|
||||
}
|
||||
pub := playletToDramaPub(context.Background(), playlet, 0)
|
||||
if pub.DramaID == "" {
|
||||
return nil
|
||||
}
|
||||
event := dataReport.NewCommonField(dataReport.EventTypeDramaCollect, "", ua, ip, userId)
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCollectEvent{
|
||||
DramaEventPub: pub,
|
||||
Flag: dataReport.JsonNumber(flag),
|
||||
})
|
||||
return insertEvent(event)
|
||||
}
|
||||
|
||||
func playletToDramaPub(ctx context.Context, playlet *playletmod.Playlet, chapterEpisode int64) dataReport.DramaEventPub {
|
||||
if playlet == nil {
|
||||
return dataReport.DramaEventPub{}
|
||||
}
|
||||
return playletChapterToDramaPub(ctx, playlet, findDramaReportChapter(ctx, playlet.Id, chapterEpisode))
|
||||
}
|
||||
|
||||
func playletChapterToDramaPub(ctx context.Context, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) dataReport.DramaEventPub {
|
||||
if playlet == nil {
|
||||
return dataReport.DramaEventPub{}
|
||||
}
|
||||
typeID, typeName := resolvePlayletType(ctx, playlet)
|
||||
tagKey, tagName := resolvePlayletTags(ctx, playlet.TagIds)
|
||||
episode := int64(1)
|
||||
dramaID := ""
|
||||
videoID := ""
|
||||
if chapter != nil && chapter.Id > 0 {
|
||||
if chapter.CurrentEpisode > 0 {
|
||||
episode = chapter.CurrentEpisode
|
||||
}
|
||||
dramaID = strconv.FormatInt(chapter.Id, 10)
|
||||
if chapter.MediaContentId > 0 {
|
||||
videoID = strconv.FormatUint(uint64(chapter.MediaContentId), 10)
|
||||
}
|
||||
}
|
||||
return dataReport.DramaEventPub{
|
||||
DramaID: dramaID,
|
||||
DramaTitle: playlet.Title,
|
||||
Episode: dataReport.JsonNumber(episode),
|
||||
VideoID: videoID,
|
||||
TotalEpisode: dataReport.JsonNumber(playlet.TotalEpisode),
|
||||
DramaTypeID: typeID,
|
||||
DramaTypeName: typeName,
|
||||
DramaContentType: dataReport.DramaContentTypeShortDrama,
|
||||
DramaTagKey: tagKey,
|
||||
DramaTagName: tagName,
|
||||
}
|
||||
}
|
||||
|
||||
// findDramaReportChapter 取上报用剧集。指定集数就用该集,未指定时用第一集。
|
||||
func findDramaReportChapter(ctx context.Context, playletId, chapterEpisode int64) *playletchaptermod.PlayletChapter {
|
||||
if playletId <= 0 {
|
||||
return nil
|
||||
}
|
||||
projection := options.FindOne().SetProjection(bson.D{
|
||||
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
|
||||
{Key: playletchaptermod.FN.PlayletChapter.MediaContentId, Value: 1},
|
||||
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
|
||||
})
|
||||
filter := bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: playletId}
|
||||
if chapterEpisode > 0 {
|
||||
filter[playletchaptermod.FN.PlayletChapter.CurrentEpisode] = chapterEpisode
|
||||
chapter, _ := playletchaptermod.FindOnePlayletChapter(ctx, filter, projection)
|
||||
return chapter
|
||||
}
|
||||
chapter, _ := playletchaptermod.FindOnePlayletChapter(ctx, filter, projection.SetSort(bson.D{
|
||||
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
|
||||
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
|
||||
}))
|
||||
return chapter
|
||||
}
|
||||
|
||||
func resolvePlayletType(ctx context.Context, playlet *playletmod.Playlet) (string, string) {
|
||||
if playlet == nil || len(playlet.ThemeIds) == 0 {
|
||||
return "default", "默认分类"
|
||||
}
|
||||
id := playlet.ThemeIds[0]
|
||||
if id <= 0 {
|
||||
return "default", "默认分类"
|
||||
}
|
||||
name := "默认分类"
|
||||
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
themes, err := playletthememod.FindManyPlayletTheme(queryCtx, bson.M{
|
||||
playletthememod.FN.PlayletTheme.Id: id,
|
||||
}, options.Find().SetProjection(bson.M{
|
||||
playletthememod.FN.PlayletTheme.Id: 1,
|
||||
playletthememod.FN.PlayletTheme.Name: 1,
|
||||
}))
|
||||
if err == nil {
|
||||
for _, theme := range themes {
|
||||
if theme != nil && theme.Id == id && strings.TrimSpace(theme.Name) != "" {
|
||||
name = strings.TrimSpace(theme.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return strconv.FormatInt(id, 10), name
|
||||
}
|
||||
|
||||
func resolvePlayletTags(ctx context.Context, ids []int64) (string, string) {
|
||||
keys := make([]string, 0, len(ids))
|
||||
validIDs := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
keys = append(keys, strconv.FormatInt(id, 10))
|
||||
validIDs = append(validIDs, id)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return "default", "默认标签"
|
||||
}
|
||||
nameMap := map[int64]string{}
|
||||
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if tags, err := contenttagmod.GetMapTags(queryCtx, validIDs); err == nil {
|
||||
for _, tag := range tags {
|
||||
if tag != nil {
|
||||
nameMap[tag.Id] = tag.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(keys))
|
||||
for _, id := range validIDs {
|
||||
name := strings.TrimSpace(nameMap[id])
|
||||
if name == "" {
|
||||
name = strconv.FormatInt(id, 10)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return strings.Join(keys, ","), strings.Join(names, ",")
|
||||
}
|
||||
|
||||
func insertEvent(event dataReport.CommonField) error {
|
||||
b, _ := json.Marshal(event)
|
||||
return eventmod.InsertOneEvent(context.Background(), &eventmod.Event{
|
||||
EventId: event.EventID,
|
||||
EventType: event.Event,
|
||||
Data: string(b),
|
||||
Status: eventmod.Default,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func resolveMediaType(media *videoinformationmod.VideoInformation) (string, string) {
|
||||
var videoTypeID = "default"
|
||||
var videoTypeName = "默认分类"
|
||||
if media.CategoryId > 0 {
|
||||
// 复用 reportser 中的分类解析逻辑
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer cancel()
|
||||
category, _ := videocategorymod.FindOneVideoCategory(ctx, bson.M{
|
||||
videocategorymod.FN.VideoCategory.Id: media.CategoryId,
|
||||
})
|
||||
if category != nil {
|
||||
videoTypeID = fmt.Sprintf("%v", category.Id)
|
||||
videoTypeName = category.Name
|
||||
}
|
||||
}
|
||||
return videoTypeID, videoTypeName
|
||||
}
|
||||
|
||||
func videoContentType(media *videoinformationmod.VideoInformation) string {
|
||||
//if media != nil && media.ContentType == pubdef.ContentTypeVideoDsp {
|
||||
// return "short_video"
|
||||
//}
|
||||
return "video"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package richtextser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/richtextcontentmod"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
richTextCron *cron.Cron
|
||||
richTextCache = struct {
|
||||
sync.RWMutex
|
||||
byTitle map[string]proto.RichTextInfo
|
||||
}{
|
||||
byTitle: make(map[string]proto.RichTextInfo),
|
||||
}
|
||||
)
|
||||
|
||||
// Start 启动富文本缓存,每10分钟刷新一次。
|
||||
func Start() {
|
||||
richTextCron = cron.New(cron.WithSeconds())
|
||||
UpdateCache(context.Background())
|
||||
_, _ = richTextCron.AddFunc("0 */10 * * * ?", func() {
|
||||
UpdateCache(context.Background())
|
||||
})
|
||||
richTextCron.Start()
|
||||
}
|
||||
|
||||
// Stop 停止富文本缓存刷新任务。
|
||||
func Stop() {
|
||||
if richTextCron != nil {
|
||||
richTextCron.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCache 更新全部启用的富文本;查询失败时保留原缓存。
|
||||
func UpdateCache(ctx context.Context) {
|
||||
list, serr := richtextcontentmod.FindManyRichTextContentModelModel(ctx,
|
||||
bson.M{richtextcontentmod.FN.RichTextContentModel.Status: true},
|
||||
options.Find().SetSort(bson.D{{Key: richtextcontentmod.FN.RichTextContentModel.UpdatedAt, Value: 1}}))
|
||||
if serr != nil {
|
||||
log.Warn("richtextser.UpdateCache failed, keep old cache", log.E(serr))
|
||||
return
|
||||
}
|
||||
|
||||
next := make(map[string]proto.RichTextInfo, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
next[title] = proto.RichTextInfo{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
Desc: item.Desc,
|
||||
SeoInfo: item.SeoInfo,
|
||||
}
|
||||
}
|
||||
|
||||
richTextCache.Lock()
|
||||
richTextCache.byTitle = next
|
||||
richTextCache.Unlock()
|
||||
}
|
||||
|
||||
// GetByTitle 从缓存按 title 获取富文本。
|
||||
func GetByTitle(title string) (proto.RichTextInfo, *srvdef.SrvErr) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return proto.RichTextInfo{}, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
richTextCache.RLock()
|
||||
data, ok := richTextCache.byTitle[title]
|
||||
richTextCache.RUnlock()
|
||||
if !ok {
|
||||
return proto.RichTextInfo{}, srvdef.New(srvdef.ErrNoData, "", "")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package searchser
|
||||
@@ -0,0 +1,487 @@
|
||||
package searchser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
"videoweb/pub/models/esmod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/playletthememod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
Cache *cache.Cache
|
||||
)
|
||||
|
||||
func init() {
|
||||
Cache = cache.New(5*time.Minute, 5*time.Minute)
|
||||
}
|
||||
func GetHotSearchItem(ctx context.Context) []*proto.HotSearchItem {
|
||||
cacheKey := fmt.Sprintf("mdnet_hotsearchitem")
|
||||
var resp []*proto.HotSearchItem
|
||||
if cacheData, exists := Cache.Get(cacheKey); exists {
|
||||
if item, ok := cacheData.([]*proto.HotSearchItem); ok {
|
||||
resp = item
|
||||
}
|
||||
}
|
||||
if resp == nil || len(resp) == 0 {
|
||||
opts := &options.FindOptions{}
|
||||
opts.SetSort(bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Sort: -1,
|
||||
})
|
||||
opts.SetLimit(15)
|
||||
list, _ := videoinformationmod.FindManyVideoInformations(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.AddedTime: bson.M{
|
||||
"$gte": time.Now().AddDate(0, 0, -7),
|
||||
},
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
}, opts)
|
||||
data := make([]*proto.HotSearchItem, 0)
|
||||
if len(list) > 0 {
|
||||
for _, item := range list {
|
||||
data = append(data, &proto.HotSearchItem{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
HotValue: item.Real.WatchTimes * 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
resp = data
|
||||
Cache.Set(cacheKey, data, 5*time.Minute)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Search 查询搜索结果
|
||||
func Search(ctx context.Context, title string, mediaType int64, page int64, sort string) *proto.SearchResp {
|
||||
resp := &proto.SearchResp{
|
||||
TotalSize: 0,
|
||||
Sort: make([]proto.SearchSortItem, 0),
|
||||
SearchRecommend: make([]string, 0),
|
||||
Media: make([]proto.VideoInfoData, 0),
|
||||
Playlets: make([]proto.PlayletData, 0),
|
||||
PageNum: page,
|
||||
PageSize: pubdef.PageSize,
|
||||
}
|
||||
resp.InTitle = title
|
||||
resp.InType = mediaType
|
||||
resp.Sort = getSearchSort()
|
||||
resp.SearchRecommend = getSearchRecommend(title)
|
||||
resp.Config = configser.GetRequestConfig(ctx)
|
||||
var err error
|
||||
if len(title) > 0 {
|
||||
if mediaType < 0 {
|
||||
searchAll(ctx, resp, title, page, sort)
|
||||
} else if mediaType == 0 {
|
||||
resp.TotalSize, resp.Media, err = searchVideo(ctx, title, page, sort)
|
||||
if err != nil {
|
||||
log.Warn("Search searchVideo Failed", log.Any("mediaType", mediaType), log.Any("title", title), log.E(err))
|
||||
return resp
|
||||
}
|
||||
} else if mediaType == 6 {
|
||||
resp.TotalSize, resp.Playlets, err = searchPlaylet(ctx, title, page, sort)
|
||||
}
|
||||
}
|
||||
resp.TotalSize = pubdef.GetTotalPage(resp.TotalSize, pubdef.PageSize)
|
||||
return resp
|
||||
}
|
||||
|
||||
// searchAll 并发搜索全部资源类型
|
||||
func searchAll(ctx context.Context, resp *proto.SearchResp, title string, page int64, sort string) {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var (
|
||||
mediaList []proto.VideoInfoData
|
||||
playletList []proto.PlayletData
|
||||
)
|
||||
addTotal := func(total int64) {
|
||||
mu.Lock()
|
||||
resp.TotalSize += total
|
||||
mu.Unlock()
|
||||
}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
total, data, err := searchVideo(ctx, title, page, sort)
|
||||
if err != nil {
|
||||
log.Warn("SearchAll searchVideo Failed", log.Any("title", title), log.E(err))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
mediaList = data
|
||||
mu.Unlock()
|
||||
addTotal(total)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
total, data, err := searchPlaylet(ctx, title, page, sort)
|
||||
if err != nil {
|
||||
log.Warn("SearchAll searchPlaylet Failed", log.Any("title", title), log.E(err))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
playletList = data
|
||||
mu.Unlock()
|
||||
addTotal(total)
|
||||
}()
|
||||
wg.Wait()
|
||||
resp.Media = mediaList
|
||||
resp.Playlets = playletList
|
||||
}
|
||||
|
||||
// searchVideo 搜索视频数据
|
||||
func searchVideo(ctx context.Context, title string, page int64, sort string) (int64, []proto.VideoInfoData, error) {
|
||||
data := make([]proto.VideoInfoData, 0)
|
||||
total := int64(0)
|
||||
skip := (page - 1) * pubdef.PageSize
|
||||
total, list, err := esmod.SearchVideo(title, skip, pubdef.PageSize, sort, false)
|
||||
if err != nil {
|
||||
log.Warn("Search SearchVideo", log.Any("title", title), log.Any("sort", sort), log.E(err))
|
||||
return total, data, err
|
||||
}
|
||||
if list != nil && len(list) > 0 {
|
||||
config := configser.GetRequestConfig(ctx)
|
||||
ids := make([]int64, len(list))
|
||||
for i, v := range list {
|
||||
ids[i] = v.Id
|
||||
}
|
||||
medias, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids},
|
||||
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
||||
}, options.Find().SetProjection(searchVideoProjection()))
|
||||
if err != nil {
|
||||
log.Warn("Search FindManyVideoInformations", log.Any("title", title), log.E(err))
|
||||
return total, data, err
|
||||
}
|
||||
mediaMap := make(map[int64]*videoinformationmod.VideoInformation, len(medias))
|
||||
for _, media := range medias {
|
||||
mediaMap[media.Id] = media
|
||||
}
|
||||
for _, id := range ids {
|
||||
media := mediaMap[id]
|
||||
if media == nil {
|
||||
continue
|
||||
}
|
||||
info := proto.VideoInfoToData(media, config.ImgCdn, configser.GetCreateInfo(media.CreatorId, config.ImgCdn))
|
||||
for _, tagId := range media.Tags {
|
||||
tagInfo := configser.GetVideoTag(tagId)
|
||||
if tagInfo != nil {
|
||||
info.Tags = append(info.Tags, proto.TagsInfoData{
|
||||
Id: tagInfo.Id,
|
||||
Name: tagInfo.Name,
|
||||
Slug: tagInfo.Slug,
|
||||
})
|
||||
}
|
||||
}
|
||||
data = append(data, info)
|
||||
}
|
||||
|
||||
}
|
||||
return total, data, nil
|
||||
}
|
||||
|
||||
// searchPlaylet 搜索短剧数据
|
||||
func searchPlaylet(ctx context.Context, title string, page int64, sort string) (int64, []proto.PlayletData, error) {
|
||||
data := make([]proto.PlayletData, 0)
|
||||
total := int64(0)
|
||||
skip := (page - 1) * pubdef.PageSize
|
||||
total, list, err := esmod.SearchPlaylet(title, skip, pubdef.PageSize, sort)
|
||||
if err != nil {
|
||||
log.Warn("Search SearchPlaylet", log.Any("title", title), log.Any("sort", sort), log.E(err))
|
||||
return total, data, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return total, data, nil
|
||||
}
|
||||
config := configser.GetRequestConfig(ctx)
|
||||
ids := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item != nil && item.Id > 0 {
|
||||
ids = append(ids, item.Id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return total, data, nil
|
||||
}
|
||||
playlets, err := playletmod.FindManyPlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
|
||||
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
}, options.Find().SetProjection(searchPlayletProjection()))
|
||||
if err != nil {
|
||||
log.Warn("Search FindManyPlaylet", log.Any("title", title), log.E(err))
|
||||
return total, data, err
|
||||
}
|
||||
playletMap := make(map[int64]*playletmod.Playlet, len(playlets))
|
||||
for _, item := range playlets {
|
||||
if item != nil {
|
||||
playletMap[item.Id] = item
|
||||
}
|
||||
}
|
||||
tagNameMap, themeNameMap, err := buildSearchPlayletTagThemeNameMaps(ctx, playlets)
|
||||
if err != nil {
|
||||
return total, data, err
|
||||
}
|
||||
data = make([]proto.PlayletData, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
item := playletMap[id]
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
data = append(data, searchPlayletToData(item, config.ImgCdn, tagNameMap, themeNameMap))
|
||||
}
|
||||
return total, data, nil
|
||||
}
|
||||
|
||||
// searchVideoProjection 返回搜索视频列表需要的Mongo字段
|
||||
func searchVideoProjection() bson.M {
|
||||
return bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: 1,
|
||||
videoinformationmod.FN.VideoInformation.MediaType: 1,
|
||||
videoinformationmod.FN.VideoInformation.Title: 1,
|
||||
videoinformationmod.FN.VideoInformation.Slug: 1,
|
||||
videoinformationmod.FN.VideoInformation.Keyworld: 1,
|
||||
videoinformationmod.FN.VideoInformation.AddedTime: 1,
|
||||
videoinformationmod.FN.VideoInformation.CreatorId: 1,
|
||||
videoinformationmod.FN.VideoInformation.CoverImg: 1,
|
||||
videoinformationmod.FN.VideoInformation.Video.Cover: 1,
|
||||
videoinformationmod.FN.VideoInformation.Video.Url: 1,
|
||||
videoinformationmod.FN.VideoInformation.Video.H265Url: 1,
|
||||
videoinformationmod.FN.VideoInformation.Video.VideoTime: 1,
|
||||
videoinformationmod.FN.VideoInformation.IsHot: 1,
|
||||
videoinformationmod.FN.VideoInformation.Real.ShowTimes: 1,
|
||||
videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1,
|
||||
videoinformationmod.FN.VideoInformation.Desc: 1,
|
||||
videoinformationmod.FN.VideoInformation.Bango: 1,
|
||||
videoinformationmod.FN.VideoInformation.IssuedBrand: 1,
|
||||
videoinformationmod.FN.VideoInformation.IssuedAt: 1,
|
||||
videoinformationmod.FN.VideoInformation.UpdatedAt: 1,
|
||||
videoinformationmod.FN.VideoInformation.PageUpdatedAt: 1,
|
||||
videoinformationmod.FN.VideoInformation.Tags: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// searchPlayletProjection 返回搜索短剧列表需要的Mongo字段
|
||||
func searchPlayletProjection() bson.M {
|
||||
return bson.M{
|
||||
playletmod.FN.Playlet.Id: 1,
|
||||
playletmod.FN.Playlet.Status: 1,
|
||||
playletmod.FN.Playlet.IsRecommend: 1,
|
||||
playletmod.FN.Playlet.IsFeatured: 1,
|
||||
playletmod.FN.Playlet.IsOriginal: 1,
|
||||
playletmod.FN.Playlet.ShowOriginal: 1,
|
||||
playletmod.FN.Playlet.MediaCenterId: 1,
|
||||
playletmod.FN.Playlet.OnlineAt: 1,
|
||||
playletmod.FN.Playlet.MediaCreatedAt: 1,
|
||||
playletmod.FN.Playlet.MediaUpdatedAt: 1,
|
||||
playletmod.FN.Playlet.Title: 1,
|
||||
playletmod.FN.Playlet.Slug: 1,
|
||||
playletmod.FN.Playlet.Desc: 1,
|
||||
playletmod.FN.Playlet.Anchor: 1,
|
||||
playletmod.FN.Playlet.Cover: 1,
|
||||
playletmod.FN.Playlet.CompressedCover: 1,
|
||||
playletmod.FN.Playlet.Preview: 1,
|
||||
playletmod.FN.Playlet.TagIds: 1,
|
||||
playletmod.FN.Playlet.ThemeIds: 1,
|
||||
playletmod.FN.Playlet.ContentType: 1,
|
||||
playletmod.FN.Playlet.PayType: 1,
|
||||
playletmod.FN.Playlet.SingleCoins: 1,
|
||||
playletmod.FN.Playlet.TotalEpisode: 1,
|
||||
playletmod.FN.Playlet.UpdateStatus: 1,
|
||||
playletmod.FN.Playlet.CountBrowse: 1,
|
||||
playletmod.FN.Playlet.VirtualCountBrowse: 1,
|
||||
playletmod.FN.Playlet.HeatValue: 1,
|
||||
playletmod.FN.Playlet.VirtualHeatValue: 1,
|
||||
playletmod.FN.Playlet.CountCollect: 1,
|
||||
playletmod.FN.Playlet.CountLike: 1,
|
||||
playletmod.FN.Playlet.VirtualCountLike: 1,
|
||||
playletmod.FN.Playlet.SeoInfo.Title: 1,
|
||||
playletmod.FN.Playlet.SeoInfo.Description: 1,
|
||||
playletmod.FN.Playlet.SeoInfo.Keyworld: 1,
|
||||
playletmod.FN.Playlet.CreatedAt: 1,
|
||||
playletmod.FN.Playlet.UpdatedAt: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func searchPlayletToData(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) proto.PlayletData {
|
||||
if item == nil {
|
||||
return proto.PlayletData{}
|
||||
}
|
||||
return proto.PlayletData{
|
||||
Id: item.Id,
|
||||
Status: item.Status,
|
||||
IsRecommend: item.IsRecommend,
|
||||
IsNew: searchPlayletIsNew(item.OnlineAt),
|
||||
IsFeatured: item.IsFeatured,
|
||||
IsOriginal: item.IsOriginal || item.MediaCenterId == 0,
|
||||
ShowOriginal: item.ShowOriginal,
|
||||
OnlineAt: item.OnlineAt,
|
||||
MediaCreatedAt: item.MediaCreatedAt,
|
||||
MediaUpdatedAt: item.MediaUpdatedAt,
|
||||
Title: item.Title,
|
||||
Slug: item.Slug,
|
||||
Desc: item.Desc,
|
||||
Anchor: item.Anchor,
|
||||
Cover: netutil.MergeURL(imgCdn, item.DisplayCover()),
|
||||
Preview: netutil.MergeURL(imgCdn, item.Preview),
|
||||
TagIds: item.TagIds,
|
||||
TagNames: searchPlayletTagNamesFromMap(item.TagIds, tagNameMap),
|
||||
TagSlugs: searchPlayletTagSlugsFromMap(item.TagIds, tagNameMap),
|
||||
ThemeIds: item.ThemeIds,
|
||||
ThemeNames: searchPlayletNamesFromMap(item.ThemeIds, themeNameMap),
|
||||
ContentType: item.ContentType,
|
||||
PayType: item.PayType,
|
||||
SingleCoins: item.SingleCoins,
|
||||
TotalEpisode: item.TotalEpisode,
|
||||
UpdateStatus: item.UpdateStatus,
|
||||
CountBrowse: item.DisplayCountBrowse(),
|
||||
HeatValue: item.DisplayHeatValue(),
|
||||
CountCollect: item.CountCollect,
|
||||
CountLike: item.CountLike,
|
||||
VirtualCountLike: item.VirtualCountLike,
|
||||
DisplayCountLike: item.CountLike + item.VirtualCountLike,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
SeoInfo: item.SeoInfo,
|
||||
}
|
||||
}
|
||||
|
||||
func buildSearchPlayletTagThemeNameMaps(ctx context.Context, list playletmod.PlayletList) (contenttagmod.ContentTagMap, map[int64]string, *srvdef.SrvErr) {
|
||||
tagIds := searchPlayletRelationIds(list, func(item *playletmod.Playlet) []int64 {
|
||||
return item.TagIds
|
||||
})
|
||||
themeIds := searchPlayletRelationIds(list, func(item *playletmod.Playlet) []int64 {
|
||||
return item.ThemeIds
|
||||
})
|
||||
tagNameMap, err := buildSearchPlayletTagNameMap(ctx, tagIds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
themeNameMap, err := buildSearchPlayletThemeNameMap(ctx, themeIds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return tagNameMap, themeNameMap, nil
|
||||
}
|
||||
|
||||
func buildSearchPlayletTagNameMap(ctx context.Context, ids []int64) (contenttagmod.ContentTagMap, *srvdef.SrvErr) {
|
||||
result := make(contenttagmod.ContentTagMap, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
return contenttagmod.GetMapTags(ctx, ids)
|
||||
}
|
||||
|
||||
func searchPlayletTagNamesFromMap(ids []int64, tags contenttagmod.ContentTagMap) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if tag := tags[id]; tag != nil && tag.Name != "" {
|
||||
result = append(result, tag.Name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchPlayletTagSlugsFromMap(ids []int64, tags contenttagmod.ContentTagMap) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if tag := tags[id]; tag != nil && tag.Name != "" {
|
||||
result = append(result, tag.Slug)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildSearchPlayletThemeNameMap(ctx context.Context, ids []int64) (map[int64]string, *srvdef.SrvErr) {
|
||||
result := make(map[int64]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
list, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{
|
||||
playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids},
|
||||
}, options.Find().SetProjection(bson.D{
|
||||
{Key: playletthememod.FN.PlayletTheme.Id, Value: 1},
|
||||
{Key: playletthememod.FN.PlayletTheme.Name, Value: 1},
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
if item != nil {
|
||||
result[item.Id] = item.Name
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func searchPlayletRelationIds(list playletmod.PlayletList, pick func(item *playletmod.Playlet) []int64) []int64 {
|
||||
result := make([]int64, 0)
|
||||
seen := make(map[int64]struct{})
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range pick(item) {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchPlayletNamesFromMap(ids []int64, nameMap map[int64]string) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if name := nameMap[id]; name != "" {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchPlayletIsNew(onlineAt time.Time) bool {
|
||||
if onlineAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return onlineAt.After(time.Now().AddDate(0, 0, -7))
|
||||
}
|
||||
|
||||
// getSearchRecommend 返回搜索推荐词
|
||||
func getSearchRecommend(title string) []string {
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
func getSearchSort() []proto.SearchSortItem {
|
||||
resp := make([]proto.SearchSortItem, 0)
|
||||
resp = append(resp, proto.SearchSortItem{
|
||||
Key: string(proto.SearchSortTypeDefault),
|
||||
Title: "相关度",
|
||||
})
|
||||
resp = append(resp, proto.SearchSortItem{
|
||||
Key: string(proto.SearchSortTypeHot),
|
||||
Title: "热度最高",
|
||||
})
|
||||
resp = append(resp, proto.SearchSortItem{
|
||||
Key: string(proto.SearchSortTypeNew),
|
||||
Title: "最新发布",
|
||||
})
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package slugser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/actress"
|
||||
"videoweb/pub/models/contenttagmod"
|
||||
"videoweb/pub/models/playletcategorymod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/videocategorymod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// Resolve 解析实体ID:slug 非空时先按 slug 查 ID,否则使用传入 ID。
|
||||
func Resolve(id int64, slug string, required bool, emptyMsg string, lookup func(string) (int64, *srvdef.SrvErr)) (int64, *srvdef.SrvErr) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug != "" {
|
||||
if lookup == nil {
|
||||
return 0, srvdef.New(srvdef.ErrParamError, emptyMsg, emptyMsg)
|
||||
}
|
||||
return lookup(slug)
|
||||
}
|
||||
if required && id <= 0 {
|
||||
if emptyMsg == "" {
|
||||
emptyMsg = "参数错误"
|
||||
}
|
||||
return 0, srvdef.New(srvdef.ErrParamError, emptyMsg, emptyMsg)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ParseIDOrSlug 解析路径参数:纯正整数当作ID,否则当作slug。
|
||||
func ParseIDOrSlug(raw string) (int64, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, ""
|
||||
}
|
||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
|
||||
return id, ""
|
||||
}
|
||||
return 0, raw
|
||||
}
|
||||
|
||||
func VideoID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
return Resolve(id, slug, required, "视频ID不能为空", func(s string) (int64, *srvdef.SrvErr) {
|
||||
item, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Slug: s,
|
||||
}, options.FindOne().SetProjection(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
})
|
||||
}
|
||||
|
||||
func VideoCategoryID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
return Resolve(id, slug, required, "视频分类ID不能为空", func(s string) (int64, *srvdef.SrvErr) {
|
||||
item, err := videocategorymod.FindOneVideoCategory(ctx, bson.M{
|
||||
videocategorymod.FN.VideoCategory.Slug: s,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
})
|
||||
}
|
||||
|
||||
func PlayletID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
return Resolve(id, slug, required, "短剧ID不能为空", func(s string) (int64, *srvdef.SrvErr) {
|
||||
item, err := playletmod.FindOnePlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Slug: s,
|
||||
}, options.FindOne().SetProjection(bson.M{playletmod.FN.Playlet.Id: 1}))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
})
|
||||
}
|
||||
|
||||
func PlayletCategoryID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
return Resolve(id, slug, required, "短剧分类ID不能为空", func(s string) (int64, *srvdef.SrvErr) {
|
||||
item, err := playletcategorymod.FindOnePlayletCategory(ctx, bson.M{
|
||||
playletcategorymod.FN.PlayletCategory.Slug: s,
|
||||
}, options.FindOne().SetProjection(bson.M{playletcategorymod.FN.PlayletCategory.Id: 1}))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
})
|
||||
}
|
||||
|
||||
func ActressID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
return Resolve(id, slug, required, "女优ID不能为空", func(s string) (int64, *srvdef.SrvErr) {
|
||||
item, err := actress.FindOneActress(ctx, bson.M{
|
||||
actress.FN.Actress.Slug: s,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
})
|
||||
}
|
||||
|
||||
func ContentTagID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
return Resolve(id, slug, required, "标签ID不能为空", func(s string) (int64, *srvdef.SrvErr) {
|
||||
item, err := contenttagmod.FindOneContentTag(ctx, bson.M{
|
||||
contenttagmod.FN.ContentTag.Slug: s,
|
||||
}, options.FindOne().SetProjection(bson.M{contenttagmod.FN.ContentTag.Id: 1}))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package translationser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/models/cfg"
|
||||
"videoweb/pub/models/translationcategorymod"
|
||||
"videoweb/pub/models/translationmod"
|
||||
"videoweb/pub/models/videocategorymod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
CacheMgr CacheInfo
|
||||
skdCron *cron.Cron
|
||||
categoryCacheFill sync.Mutex
|
||||
)
|
||||
|
||||
type CacheInfo struct {
|
||||
Lock sync.RWMutex // 读写锁
|
||||
TranslationData proto.AppTranslationInfo // 顶部导航缓存
|
||||
BottomTranslationData proto.AppTranslationInfo // 底部导航缓存
|
||||
TranslationMap map[int64]proto.TranslationInfo
|
||||
|
||||
// 视频分类缓存(按 ID 索引)
|
||||
VideoCategoryMap map[int64]*videocategorymod.VideoCategory
|
||||
}
|
||||
|
||||
func init() {
|
||||
skdCron = cron.New(cron.WithSeconds())
|
||||
CacheMgr.TranslationData = proto.AppTranslationInfo{}
|
||||
CacheMgr.BottomTranslationData = proto.AppTranslationInfo{}
|
||||
CacheMgr.TranslationMap = make(map[int64]proto.TranslationInfo)
|
||||
CacheMgr.VideoCategoryMap = make(map[int64]*videocategorymod.VideoCategory)
|
||||
}
|
||||
|
||||
func Start() {
|
||||
ctx := context.Background()
|
||||
UpdateTranslationInfo(ctx)
|
||||
|
||||
_, _ = skdCron.AddFunc("0 */5 * * * ?", func() {
|
||||
UpdateTranslationInfo(ctx)
|
||||
})
|
||||
|
||||
skdCron.Start()
|
||||
}
|
||||
|
||||
func UpdateTranslationInfo(ctx context.Context) {
|
||||
filter := bson.M{cfg.FN.SysCfg.Id: 1}
|
||||
sysCfg, _ := cfg.FindOneSysCfg(context.Background(), filter)
|
||||
if sysCfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
translation := proto.AppTranslationInfo{}
|
||||
bottomTranslation := proto.AppTranslationInfo{}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(3)
|
||||
var translationCategoryList translationcategorymod.TranslationCategoryList
|
||||
var translationList translationmod.TranslationInfoList
|
||||
var videoCategoryList videocategorymod.VideoCategoryList
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
translationCategoryList, _ = translationcategorymod.FindManyTranslationCategorys(ctx,
|
||||
bson.M{translationcategorymod.FN.TranslationCategory.Status: true},
|
||||
options.Find().SetLimit(1000).SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1}),
|
||||
)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
translationList, _ = translationmod.FindManyTranslationInfos(ctx,
|
||||
bson.M{translationmod.FN.TranslationInfo.Status: true},
|
||||
options.Find().SetLimit(1000).SetSort(bson.M{translationmod.FN.TranslationInfo.Rank: -1}),
|
||||
)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
videoCategoryList, _ = videocategorymod.FindManyVideoCategorys(ctx,
|
||||
bson.M{videocategorymod.FN.VideoCategory.Status: true},
|
||||
options.Find().SetLimit(500).SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1}),
|
||||
)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// ---- 构建各分类 ID Map(用于缓存 & 导航分组) ----
|
||||
newVideoCategoryMap := make(map[int64]*videocategorymod.VideoCategory, len(videoCategoryList))
|
||||
videoCategoryByNavId := make(map[int64][]*videocategorymod.VideoCategory)
|
||||
for _, c := range videoCategoryList {
|
||||
newVideoCategoryMap[c.Id] = c
|
||||
if c.TranslationCategoryId > 0 {
|
||||
videoCategoryByNavId[c.TranslationCategoryId] = append(videoCategoryByNavId[c.TranslationCategoryId], c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 构建导航树 ----
|
||||
tempTranslationMap := make(map[int64]proto.TranslationInfo)
|
||||
|
||||
for _, categoryInfo := range translationCategoryList {
|
||||
categoryTemp := proto.TranslationCategory{
|
||||
ID: categoryInfo.Id,
|
||||
Name: categoryInfo.Name,
|
||||
Icon: netutil.MergeURL(sysCfg.ImgAppCdn, categoryInfo.Icon),
|
||||
TranslationList: make([]proto.TranslationInfo, 0),
|
||||
CategoryType: categoryInfo.CategoryType,
|
||||
UrlValue: categoryInfo.UrlValue,
|
||||
}
|
||||
|
||||
if categoryInfo.CategoryType == pubdef.TranslationTypeSub {
|
||||
// 固定路径条目优先放在最前面(根据导航分类的 UrlValue 决定属于哪个内容域)
|
||||
categoryTemp.TranslationList = append(categoryTemp.TranslationList,
|
||||
buildFixedNavItems(categoryInfo.UrlValue)...,
|
||||
)
|
||||
// 手动配置的导航条目
|
||||
for _, translationInfo := range translationList {
|
||||
if categoryInfo.Id != translationInfo.TransCategoryId {
|
||||
continue
|
||||
}
|
||||
translationTmp := proto.TranslationInfo{
|
||||
ID: translationInfo.Id,
|
||||
Name: translationInfo.Name,
|
||||
TransCategoryId: translationInfo.TransCategoryId,
|
||||
}
|
||||
if translationInfo.IsFriendAds {
|
||||
// 友链广告,顶部暂不支持
|
||||
continue
|
||||
} else if translationInfo.HtmlId > 0 {
|
||||
translationTmp.UrlValue = "/richtext?title=" + url.QueryEscape(strings.TrimSpace(translationInfo.Name))
|
||||
} else if len(translationInfo.Link) > 0 {
|
||||
translationTmp.UrlValue = translationInfo.Link
|
||||
}
|
||||
if translationTmp.UrlValue == "" {
|
||||
continue
|
||||
}
|
||||
tempTranslationMap[translationInfo.Id] = translationTmp
|
||||
categoryTemp.TranslationList = append(categoryTemp.TranslationList, translationTmp)
|
||||
}
|
||||
// 挂载到该导航的视频分类
|
||||
for _, c := range videoCategoryByNavId[categoryInfo.Id] {
|
||||
if c.DisplayPosition == pubdef.CategoryDisplayPositionHomeOnly {
|
||||
continue
|
||||
}
|
||||
categoryTemp.TranslationList = append(categoryTemp.TranslationList, proto.TranslationInfo{
|
||||
ID: c.Id,
|
||||
Name: c.Name,
|
||||
DisplayMode: c.DisplayMode,
|
||||
UrlValue: fmt.Sprintf(pubdef.TranslationPath_VideoMore, c.Id),
|
||||
})
|
||||
}
|
||||
} else if categoryInfo.CategoryType == pubdef.TranslationTypeBottom {
|
||||
for _, translationInfo := range translationList {
|
||||
if categoryInfo.Id != translationInfo.TransCategoryId {
|
||||
continue
|
||||
}
|
||||
translationTmp := proto.TranslationInfo{
|
||||
ID: translationInfo.Id,
|
||||
Name: translationInfo.Name,
|
||||
TransCategoryId: translationInfo.TransCategoryId,
|
||||
}
|
||||
if translationInfo.IsFriendAds {
|
||||
translationTmp.IsFriendAds = true
|
||||
} else if translationInfo.HtmlId > 0 {
|
||||
translationTmp.UrlValue = "/richtext?title=" + url.QueryEscape(strings.TrimSpace(translationInfo.Name))
|
||||
} else if len(translationInfo.Link) > 0 {
|
||||
translationTmp.UrlValue = translationInfo.Link
|
||||
}
|
||||
tempTranslationMap[translationInfo.Id] = translationTmp
|
||||
categoryTemp.TranslationList = append(categoryTemp.TranslationList, translationTmp)
|
||||
}
|
||||
bottomTranslation.CategoryList = append(bottomTranslation.CategoryList, categoryTemp)
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
translation.CategoryList = append(translation.CategoryList, categoryTemp)
|
||||
}
|
||||
|
||||
log.Info("UpdateTranslationInfo", log.Any("categoryList", translation.CategoryList))
|
||||
|
||||
CacheMgr.Lock.Lock()
|
||||
defer CacheMgr.Lock.Unlock()
|
||||
CacheMgr.TranslationData = translation
|
||||
CacheMgr.BottomTranslationData = bottomTranslation
|
||||
CacheMgr.TranslationMap = tempTranslationMap
|
||||
CacheMgr.VideoCategoryMap = newVideoCategoryMap
|
||||
}
|
||||
|
||||
// ---- 导航读取 ----
|
||||
|
||||
func GetTranslationData() proto.AppTranslationInfo {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
return CacheMgr.TranslationData
|
||||
}
|
||||
|
||||
func GetTranslationBottomList() proto.AppTranslationInfo {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
return CacheMgr.BottomTranslationData
|
||||
}
|
||||
|
||||
// ---- 分类读取 ----
|
||||
|
||||
func categoryCacheEmpty() bool {
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
return len(CacheMgr.VideoCategoryMap) == 0
|
||||
}
|
||||
|
||||
// EnsureCategoryCaches 若任一分内容分类缓存为空则立即补全;常规定时 5 分钟刷新。
|
||||
func EnsureCategoryCaches(ctx context.Context) {
|
||||
if !categoryCacheEmpty() {
|
||||
return
|
||||
}
|
||||
categoryCacheFill.Lock()
|
||||
defer categoryCacheFill.Unlock()
|
||||
if !categoryCacheEmpty() {
|
||||
return
|
||||
}
|
||||
UpdateTranslationInfo(ctx)
|
||||
}
|
||||
|
||||
// EnsureVideoCategoryCache 兼容旧调用,等同 EnsureCategoryCaches。
|
||||
func EnsureVideoCategoryCache(ctx context.Context) {
|
||||
EnsureCategoryCaches(ctx)
|
||||
}
|
||||
|
||||
// GetVideoCategory 通过视频分类 ID 获取分类信息,不存在返回 nil
|
||||
func GetVideoCategory(id int64) *videocategorymod.VideoCategory {
|
||||
EnsureCategoryCaches(context.Background())
|
||||
CacheMgr.Lock.RLock()
|
||||
defer CacheMgr.Lock.RUnlock()
|
||||
return CacheMgr.VideoCategoryMap[id]
|
||||
}
|
||||
|
||||
// buildFixedNavItems 根据导航分类的 UrlValue 返回对应内容域的固定路径条目,
|
||||
// 这些条目始终排在动态分类和手动配置条目之前。
|
||||
func buildFixedNavItems(urlValue string) []proto.TranslationInfo {
|
||||
switch urlValue {
|
||||
case pubdef.TranslationPath_Video:
|
||||
return []proto.TranslationInfo{
|
||||
//{Name: pubdef.TranslationName_Video, UrlValue: pubdef.TranslationPath_Video},
|
||||
//{Name: pubdef.TranslationName_VideoAll, UrlValue: pubdef.TranslationPath_VideoAll},
|
||||
//{Name: pubdef.TranslationName_VideoRank, UrlValue: pubdef.TranslationPath_VideoRank},
|
||||
//{Name: pubdef.TranslationName_ActressRank, UrlValue: pubdef.TranslationPath_ActressRank},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package uploadser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/storage/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUploadUserName = "questionnaires"
|
||||
// 分片上传路径
|
||||
videoBodyPath = "/media/web/autopublish/videoBody"
|
||||
// 分片完成路径
|
||||
videoOkPath = "/media/web/autopublish/videoOk"
|
||||
// 图片上传路径
|
||||
imagePath = "/media/web/autopublish/image"
|
||||
)
|
||||
|
||||
// VideoBodyResp 视频分片上传响应
|
||||
type VideoBodyResp struct {
|
||||
Code int `json:"code"`
|
||||
Data VideoBodyData `json:"data"`
|
||||
Hash bool `json:"hash"`
|
||||
Msg string `json:"msg"`
|
||||
Time string `json:"time"`
|
||||
Tip string `json:"tip"`
|
||||
}
|
||||
|
||||
// VideoBodyData 视频分片上传响应数据
|
||||
type VideoBodyData struct {
|
||||
Id string `json:"id"`
|
||||
TaskId string `json:"taskId"`
|
||||
SourceNameFs string `json:"sourceNameFs"`
|
||||
}
|
||||
|
||||
// VideoOkResp 视频上传完成响应
|
||||
type VideoOkResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
}
|
||||
|
||||
// ImageUploadResp 图片上传响应
|
||||
type ImageUploadResp struct {
|
||||
Code int `json:"code"`
|
||||
Data ImageUploadData `json:"data"`
|
||||
Hash bool `json:"hash"`
|
||||
Msg string `json:"msg"`
|
||||
Time string `json:"time"`
|
||||
Tip string `json:"tip"`
|
||||
}
|
||||
|
||||
type ImageUploadData struct {
|
||||
Id string `json:"id"`
|
||||
SourceNameFs string `json:"sourceNameFs"`
|
||||
}
|
||||
|
||||
// VideoBodyParam 视频分片上传入参
|
||||
type VideoBodyParam struct {
|
||||
TaskId string
|
||||
Pos string
|
||||
AppId string
|
||||
UploadUserName string
|
||||
FileHeader *multipart.FileHeader // 分片文件
|
||||
}
|
||||
|
||||
// ImageParam 图片上传入参
|
||||
type ImageParam struct {
|
||||
AppId string
|
||||
FileHeader *multipart.FileHeader
|
||||
}
|
||||
|
||||
// targetDomain 获取转发目标域名
|
||||
func targetDomain() string {
|
||||
return proxy.DomainAddressRepair("", proxy.BucketVideoAddressV2)
|
||||
}
|
||||
|
||||
// ForwardVideoBody 转发视频分片上传
|
||||
func ForwardVideoBody(param *VideoBodyParam) ([]byte, error) {
|
||||
if param.AppId == "" {
|
||||
param.AppId = strconv.Itoa(pubdef.AppId)
|
||||
}
|
||||
if param.UploadUserName == "" {
|
||||
param.UploadUserName = defaultUploadUserName
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
fields := map[string]string{
|
||||
"taskId": param.TaskId,
|
||||
"pos": param.Pos,
|
||||
"appId": param.AppId,
|
||||
"uploadUserName": param.UploadUserName,
|
||||
}
|
||||
for k, v := range fields {
|
||||
if err := mw.WriteField(k, v); err != nil {
|
||||
return nil, fmt.Errorf("write field %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
if param.FileHeader != nil {
|
||||
if err := writeFileField(mw, "file", param.FileHeader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := targetDomain() + videoBodyPath
|
||||
return doMultipartPost(url, mw.FormDataContentType(), &buf)
|
||||
}
|
||||
|
||||
// ForwardVideoOk 转发视频分片完成通知
|
||||
func ForwardVideoOk(taskId string) ([]byte, error) {
|
||||
body := map[string]string{"taskId": taskId}
|
||||
jsonBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := targetDomain() + videoOkPath
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonBytes)) //nolint:noctx
|
||||
if err != nil {
|
||||
log.Error("uploadser:ForwardVideoOk failed", log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// ForwardImage 转发图片上传
|
||||
func ForwardImage(param *ImageParam) ([]byte, error) {
|
||||
if param.AppId == "" {
|
||||
param.AppId = strconv.Itoa(pubdef.AppId)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
if err := mw.WriteField("appId", param.AppId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if param.FileHeader != nil {
|
||||
if err := writeFileField(mw, "file", param.FileHeader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := targetDomain() + imagePath
|
||||
return doMultipartPost(url, mw.FormDataContentType(), &buf)
|
||||
}
|
||||
|
||||
// writeFileField 将 multipart.FileHeader 写入目标 multipart.Writer
|
||||
func writeFileField(mw *multipart.Writer, fieldName string, fh *multipart.FileHeader) error {
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 保留原始文件名和 Content-Type
|
||||
h := make(textproto.MIMEHeader)
|
||||
contentType := fh.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fh.Filename))
|
||||
h.Set("Content-Type", contentType)
|
||||
|
||||
part, err := mw.CreatePart(h)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create part: %w", err)
|
||||
}
|
||||
if _, err = io.Copy(part, src); err != nil {
|
||||
return fmt.Errorf("copy file data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// doMultipartPost 发送 multipart/form-data 请求并返回响应 body
|
||||
func doMultipartPost(url, contentType string, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
cli := &http.Client{}
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
log.Error("uploadser:doMultipartPost failed", log.Any("url", url), log.E(err))
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/crypt"
|
||||
"videoweb/pub/common/ipaddr"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
h5M3U8TicketTTLSeconds = int64(600)
|
||||
h5M3U8TicketKeyVersion = "v1"
|
||||
h5M3U8TicketBindIP = true
|
||||
h5M3U8RoutePrefix = "/h5/m3u8/"
|
||||
hardcodedH5M3U8TicketKey = "3d058714838683a97c04d7b6ab609fc196f29e55df36197341d87325dc63154a"
|
||||
H5M3U8FallbackPath = "/v3/av/w0/w6/db/7j/029e2f2df53d4741ad39b471e2ad1806.m3u8"
|
||||
)
|
||||
|
||||
// H5M3U8Ticket 记录业务接口已确认的正片/预览权限,供 m3u8 接口按 IP 可信计数。
|
||||
type H5M3U8Ticket struct {
|
||||
MediaID int64 `json:"mediaId"`
|
||||
Path string `json:"path"`
|
||||
ExpireAt int64 `json:"expireAt"`
|
||||
IPHash string `json:"ipHash,omitempty"`
|
||||
Playable bool `json:"playable"`
|
||||
Preview bool `json:"preview"`
|
||||
UseNewKey bool `json:"useNewKey,omitempty"` // 短剧原片使用 newm3u8.key
|
||||
KeyVersion string `json:"keyVersion,omitempty"`
|
||||
OfficialPlayletID int64 `json:"officialPlayletId,omitempty"` // 官网短剧ID 非官网播放为0
|
||||
}
|
||||
|
||||
func h5M3U8TicketKey() string {
|
||||
return hardcodedH5M3U8TicketKey
|
||||
}
|
||||
|
||||
// BuildH5M3U8LegacyURL 返回不带路由前缀的相对票据地址,兼容 H5 自行拼接 /h5/m3u8/。
|
||||
func BuildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview bool) string {
|
||||
return buildH5M3U8URL(mediaID, path, ip, playable, preview, false, 0)
|
||||
}
|
||||
|
||||
// BuildH5M3U8PlayletURL 为短剧播放地址生成票据。原片 useNewKey 为 true。
|
||||
func BuildH5M3U8PlayletURL(chapterID int64, path, ip string, useNewKey bool) string {
|
||||
return buildH5M3U8URL(chapterID, path, ip, true, false, useNewKey, 0)
|
||||
}
|
||||
|
||||
// BuildH5M3U8OfficialURL 签发需要再次校验官网开放范围的剧集票据
|
||||
func BuildH5M3U8OfficialURL(chapterID, playletID int64, path, ip string, useNewKey bool) string {
|
||||
return buildH5M3U8URL(chapterID, path, ip, true, false, useNewKey, playletID)
|
||||
}
|
||||
|
||||
func buildH5M3U8URL(mediaID int64, path, ip string, playable, preview, useNewKey bool, officialPlayletID int64) string {
|
||||
key := h5M3U8TicketKey()
|
||||
if key == "" || mediaID <= 0 || strings.TrimSpace(ip) == "" {
|
||||
return ""
|
||||
}
|
||||
normalizedPath, ok := normalizeH5M3U8Path(path)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(h5M3U8TicketTTLSeconds) * time.Second).Unix()
|
||||
ticket := H5M3U8Ticket{
|
||||
MediaID: mediaID,
|
||||
Path: normalizedPath,
|
||||
ExpireAt: expireAt,
|
||||
Playable: playable,
|
||||
Preview: preview,
|
||||
UseNewKey: useNewKey,
|
||||
KeyVersion: h5M3U8TicketKeyVersion,
|
||||
OfficialPlayletID: officialPlayletID,
|
||||
}
|
||||
if h5M3U8TicketBindIP {
|
||||
ticket.IPHash = h5M3U8Hash(ip)
|
||||
}
|
||||
encodedTicket, err := encodeH5M3U8Ticket(ticket, key)
|
||||
if err != nil {
|
||||
log.Error("【BuildH5M3U8LegacyURL】生成H5播放票据失败", log.E(err))
|
||||
return ""
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("id", strconv.FormatInt(mediaID, 10))
|
||||
query.Set("t", strconv.FormatInt(expireAt, 10))
|
||||
query.Set("ticket", encodedTicket)
|
||||
return normalizedPath + "?" + query.Encode()
|
||||
}
|
||||
|
||||
// CheckH5M3U8Ticket 校验 IP、视频、路径、有效期,防止伪造正片计数语义。
|
||||
func CheckH5M3U8Ticket(mediaID, expireAt int64, path, ip, ticket string) (*H5M3U8Ticket, bool) {
|
||||
data, reason := validateH5M3U8Ticket(mediaID, expireAt, path, ip, ticket, true)
|
||||
if reason != "" {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
// CheckH5M3U8TicketForEnkey 校验 /enkey 请求,不校验 m3u8 路径。
|
||||
func CheckH5M3U8TicketForEnkey(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) {
|
||||
data, reason := validateH5M3U8Ticket(mediaID, expireAt, "", ip, ticket, false)
|
||||
if reason != "" {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
// CheckH5M3U8TicketReason 与 CheckH5M3U8Ticket 相同,额外返回失败原因便于排查保底视频。
|
||||
func CheckH5M3U8TicketReason(mediaID, expireAt int64, path, ip, ticket string) (*H5M3U8Ticket, bool, string) {
|
||||
data, reason := validateH5M3U8Ticket(mediaID, expireAt, path, ip, ticket, true)
|
||||
if reason != "" {
|
||||
return nil, false, reason
|
||||
}
|
||||
return data, true, ""
|
||||
}
|
||||
|
||||
func validateH5M3U8Ticket(mediaID, expireAt int64, path, ip, ticket string, checkPath bool) (*H5M3U8Ticket, string) {
|
||||
data, reason := decodeAndValidateH5M3U8TicketReason(mediaID, expireAt, ip, ticket)
|
||||
if reason != "" {
|
||||
return nil, reason
|
||||
}
|
||||
if !checkPath {
|
||||
return data, ""
|
||||
}
|
||||
normalizedPath, pathOK := normalizeH5M3U8Path(path)
|
||||
if !pathOK {
|
||||
return nil, "path_invalid"
|
||||
}
|
||||
payloadPath, valid := normalizeH5M3U8Path(data.Path)
|
||||
if !valid || payloadPath != normalizedPath {
|
||||
return nil, "path_mismatch"
|
||||
}
|
||||
return data, ""
|
||||
}
|
||||
|
||||
func decodeAndValidateH5M3U8Ticket(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) {
|
||||
data, reason := decodeAndValidateH5M3U8TicketReason(mediaID, expireAt, ip, ticket)
|
||||
if reason != "" {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func decodeAndValidateH5M3U8TicketReason(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, string) {
|
||||
key := h5M3U8TicketKey()
|
||||
if key == "" || mediaID <= 0 || expireAt <= 0 || strings.TrimSpace(ticket) == "" {
|
||||
return nil, "param_missing"
|
||||
}
|
||||
data, err := decodeH5M3U8Ticket(ticket, key)
|
||||
if err != nil {
|
||||
return nil, "ticket_decode_failed"
|
||||
}
|
||||
if data.MediaID != mediaID || data.ExpireAt != expireAt {
|
||||
return nil, "id_or_expire_mismatch"
|
||||
}
|
||||
if data.KeyVersion != "" && data.KeyVersion != h5M3U8TicketKeyVersion {
|
||||
return nil, "key_version_mismatch"
|
||||
}
|
||||
if time.Now().Unix() > data.ExpireAt {
|
||||
return nil, "ticket_expired"
|
||||
}
|
||||
if h5M3U8TicketBindIP && data.IPHash != h5M3U8Hash(ip) {
|
||||
return nil, "ip_mismatch"
|
||||
}
|
||||
return data, ""
|
||||
}
|
||||
|
||||
func H5M3U8RequestIP(ctx context.Context) string {
|
||||
if c, ok := ctx.(*gin.Context); ok {
|
||||
return strings.TrimSpace(ipaddr.ResolveClientIP(c))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeH5M3U8TicketParam(ticket string) string {
|
||||
ticket = strings.TrimSpace(ticket)
|
||||
if idx := strings.Index(ticket, "?"); idx >= 0 {
|
||||
ticket = ticket[:idx]
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
func normalizeH5M3U8Path(path string) (string, bool) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
lowerPath := strings.ToLower(path)
|
||||
if strings.HasPrefix(lowerPath, "http://") || strings.HasPrefix(lowerPath, "https://") {
|
||||
return "", false
|
||||
}
|
||||
if idx := strings.Index(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
path = strings.TrimLeft(path, "/")
|
||||
path = strings.TrimPrefix(path, strings.TrimLeft(h5M3U8RoutePrefix, "/"))
|
||||
lowerPath = strings.ToLower(path)
|
||||
if path == "" || strings.Contains(path, `\`) || strings.Contains(path, "..") || strings.Contains(lowerPath, "%2e") {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasSuffix(lowerPath, ".m3u8") {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
func encodeH5M3U8Ticket(ticket H5M3U8Ticket, key string) (string, error) {
|
||||
data, err := json.Marshal(ticket)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encrypted, err := crypt.CoreAesEncryptEx(data, 12, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
func decodeH5M3U8Ticket(ticket, key string) (*H5M3U8Ticket, error) {
|
||||
ticket = normalizeH5M3U8TicketParam(ticket)
|
||||
encrypted, err := base64.URLEncoding.DecodeString(ticket)
|
||||
if err != nil {
|
||||
encrypted, err = base64.RawURLEncoding.DecodeString(ticket)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decrypted, err := safeCoreAesDecryptEx(encrypted, 12, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data H5M3U8Ticket
|
||||
if err = json.Unmarshal(decrypted, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func safeCoreAesDecryptEx(encrypted []byte, nonceLen int, key string) (decrypted []byte, err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("decrypt h5 m3u8 ticket panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return crypt.CoreAesDecryptEx(encrypted, nonceLen, key)
|
||||
}
|
||||
|
||||
func h5M3U8Hash(value string) string {
|
||||
hash := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/pub/common/response"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/models/videolikemod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
// QueryLikeVideoList 按点赞时间倒序查询当前用户点赞的上架视频
|
||||
func QueryLikeVideoList(ctx context.Context, userID int64, req *proto.QueryLikeVideoListReq) (*proto.QueryLikeVideoListResp, *srvdef.SrvErr) {
|
||||
if userID <= 0 || req == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, 20, 100)
|
||||
pipeline := []bson.M{
|
||||
{"$match": bson.M{videolikemod.FN.VideoLike.UserID: userID}},
|
||||
{"$lookup": bson.M{
|
||||
"from": videoinformationmod.TN.VideoInformation,
|
||||
"localField": videolikemod.FN.VideoLike.VideoID,
|
||||
"foreignField": videoinformationmod.FN.VideoInformation.Id,
|
||||
"as": "video",
|
||||
}},
|
||||
{"$unwind": "$video"},
|
||||
{"$match": bson.M{"video." + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}},
|
||||
}
|
||||
countRows := make([]struct {
|
||||
Total int64 `bson:"total"`
|
||||
}, 0)
|
||||
if err := videolikemod.AggregateVideoLike(ctx, &countRows, append(append([]bson.M(nil), pipeline...), bson.M{"$count": "total"})); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total := int64(0)
|
||||
if len(countRows) > 0 {
|
||||
total = countRows[0].Total
|
||||
}
|
||||
resp := &proto.QueryLikeVideoListResp{BasePageResp: response.BasePageResp{
|
||||
TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.FavoriteVideoListItem, 0),
|
||||
}, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}
|
||||
if total == 0 || pageNum > (total-1)/pageSize+1 {
|
||||
return resp, nil
|
||||
}
|
||||
pagePipeline := append([]bson.M{pipeline[0], {"$sort": bson.D{{Key: videolikemod.FN.VideoLike.CreatedAt, Value: -1}, {Key: videolikemod.FN.VideoLike.Id, Value: -1}}}}, pipeline[1:]...)
|
||||
pagePipeline = append(pagePipeline,
|
||||
bson.M{"$skip": (pageNum - 1) * pageSize},
|
||||
bson.M{"$limit": pageSize},
|
||||
bson.M{"$replaceRoot": bson.M{"newRoot": "$video"}},
|
||||
bson.M{"$project": favoriteVideoProjection()},
|
||||
)
|
||||
list := make([]*videoinformationmod.VideoInformation, 0)
|
||||
if err := videolikemod.AggregateVideoLike(ctx, &list, pagePipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdn := configser.GetRequestConfig(ctx).ImgCdn
|
||||
items := make([]*proto.FavoriteVideoListItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
items = append(items, favoriteVideoListItem(item, cdn))
|
||||
}
|
||||
resp.List = items
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common"
|
||||
"videoweb/pub/common/enum/consts"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
"videoweb/pub/common/ipaddr"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/m3u8"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/m3u8requestlogmod"
|
||||
"videoweb/pub/models/playletchaptermod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const m3u8RateLimitFallback = H5M3U8FallbackPath
|
||||
|
||||
type fileHandler func(ctx *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error)
|
||||
|
||||
func Enkey(ctx *gin.Context) {
|
||||
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
||||
idStr := c.Query("id")
|
||||
tStr := c.Query("t")
|
||||
ticket := c.Query("ticket")
|
||||
|
||||
if idStr == "" || tStr == "" || ticket == "" {
|
||||
err = errors.New("unauthorized")
|
||||
return
|
||||
}
|
||||
videoId, parseErr := strconv.ParseInt(idStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
err = errors.New("invalid id")
|
||||
return
|
||||
}
|
||||
tVal, parseErr := strconv.ParseInt(tStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
err = errors.New("invalid t")
|
||||
return
|
||||
}
|
||||
|
||||
ip := ipaddr.ResolveClientIP(c)
|
||||
ticketInfo, ticketOK := CheckH5M3U8TicketForEnkey(videoId, tVal, ip, ticket)
|
||||
if !ticketOK || ticketInfo.UseNewKey {
|
||||
log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId))
|
||||
err = errors.New("ticket check failed")
|
||||
return
|
||||
}
|
||||
if ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, videoId) {
|
||||
err = errors.New("本集暂未在官网开放")
|
||||
return
|
||||
}
|
||||
|
||||
buffer, err := m3u8.Enkey(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
contentLength = int64(buffer.Len())
|
||||
contentType = "application/octet-stream"
|
||||
reader = buffer
|
||||
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
|
||||
return
|
||||
}
|
||||
handlerFile(ctx, fn)
|
||||
}
|
||||
|
||||
func handlerFile(ctx *gin.Context, h fileHandler) {
|
||||
contentLength, contentType, reader, extraHeaders, err := h(ctx)
|
||||
if err != nil {
|
||||
common.ServerJSON(ctx, srvdef.DownloadM3u8File, err)
|
||||
return
|
||||
}
|
||||
ctx.DataFromReader(http.StatusOK, contentLength, contentType, reader, extraHeaders)
|
||||
}
|
||||
|
||||
func GetM3U8Ex(ctx *gin.Context) {
|
||||
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
||||
start := time.Now()
|
||||
m3u8Name := ctx.Param("key")
|
||||
origM3u8Name := m3u8Name
|
||||
status := "success"
|
||||
var mediaID int64
|
||||
var expireAt int64
|
||||
var ticketOK bool
|
||||
var isOverseas bool
|
||||
var cdnDomain string
|
||||
|
||||
ip := ipaddr.ResolveClientIP(c)
|
||||
detector := appg.DetectorV6
|
||||
if ipaddr.IsIPv4(ip) {
|
||||
detector = appg.DetectorV4
|
||||
}
|
||||
if detector != nil {
|
||||
isOverseas, _ = detector.IsOverseasIP(ip)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil && status == "success" {
|
||||
status = "error"
|
||||
}
|
||||
recordM3U8RequestLog(c, m3u8Name, mediaID, expireAt, ticketOK, isOverseas, cdnDomain, status, err, start)
|
||||
}()
|
||||
|
||||
ticket := ctx.Query("ticket")
|
||||
mediaID = parseInt64(ctx.Query("id"))
|
||||
expireAt = parseInt64(ctx.Query("t"))
|
||||
ticketInfo, ticketOK, ticketReason := CheckH5M3U8TicketReason(
|
||||
mediaID, expireAt, origM3u8Name, ip, ticket,
|
||||
)
|
||||
|
||||
if !ticketOK {
|
||||
m3u8Name = H5M3U8FallbackPath
|
||||
log.Warn("【GetM3U8Ex】H5播放票据无效",
|
||||
log.Any("reason", ticketReason),
|
||||
log.Any("mediaId", mediaID),
|
||||
log.Any("expireAt", expireAt),
|
||||
log.Any("ip", ip),
|
||||
log.Any("clientIP", c.ClientIP()),
|
||||
log.Any("remoteIP", c.RemoteIP()),
|
||||
log.Any("xff", c.GetHeader("X-Forwarded-For")),
|
||||
log.Any("cfIP", c.GetHeader("CF-Connecting-IP")),
|
||||
log.Any("path", origM3u8Name),
|
||||
log.Any("hasTicket", ticket != ""))
|
||||
}
|
||||
if ticketOK && ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, mediaID) {
|
||||
status = "official_closed"
|
||||
err = errors.New("本集暂未在官网开放")
|
||||
return
|
||||
}
|
||||
|
||||
countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview
|
||||
if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) {
|
||||
cnt, countErr := m3u8DailyMediaCountByIP(ip, mediaID)
|
||||
if countErr != nil {
|
||||
log.Error("【GetM3U8Ex】统计IP正片获取次数失败", log.Any("ip", ip), log.E(countErr))
|
||||
} else if cnt > configser.GetM3U8DayRateLimitMax() {
|
||||
m3u8Name = m3u8RateLimitFallback
|
||||
status = "rate_limited"
|
||||
log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8",
|
||||
log.Any("ip", ip),
|
||||
log.Any("remoteIP", c.RemoteIP()),
|
||||
log.Any("clientIP", c.ClientIP()),
|
||||
log.Any("xff", c.GetHeader("X-Forwarded-For")),
|
||||
log.Any("cnt", cnt),
|
||||
log.Any("mediaId", mediaID),
|
||||
log.Any("orig", origM3u8Name),
|
||||
log.Any("fallback", m3u8Name))
|
||||
}
|
||||
}
|
||||
|
||||
var req m3u8.Req
|
||||
if err = ctx.BindQuery(&req); err != nil {
|
||||
status = "bind_query_failed"
|
||||
return
|
||||
}
|
||||
req.Name = m3u8Name
|
||||
cdnDomain = configser.GetRandCDN()
|
||||
originDomain := appg.Conf.OrigDomain
|
||||
if isOverseas && len(configser.GetConfigInfo().MediaAppUsCdn) > 0 {
|
||||
cdnDomain = configser.GetConfigInfo().MediaAppUsCdn
|
||||
}
|
||||
log.Info("cdn信息", log.Any("ip:", ip), log.Any("isOverseas", isOverseas), log.Any("cdnDomain", cdnDomain))
|
||||
|
||||
enkeyUri := ""
|
||||
playOriginal := ticketOK && ticketInfo.UseNewKey && m3u8Name != H5M3U8FallbackPath
|
||||
if ticketOK {
|
||||
enkeyPath := "/enkey"
|
||||
if playOriginal {
|
||||
enkeyPath = "/newenkey"
|
||||
}
|
||||
enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket))
|
||||
}
|
||||
|
||||
var buff *bytes.Buffer
|
||||
if playOriginal {
|
||||
_, buff, err = m3u8.TransformExOriginalPath(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
|
||||
} else {
|
||||
_, buff, err = m3u8.TransformEx(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
|
||||
}
|
||||
if err != nil {
|
||||
status = "transform_failed"
|
||||
return
|
||||
}
|
||||
contentType = "application/vnd.apple.mpegurl"
|
||||
reader = bytes.NewReader(buff.Bytes())
|
||||
contentLength = int64(buff.Len())
|
||||
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
|
||||
return
|
||||
}
|
||||
handlerFile(ctx, fn)
|
||||
}
|
||||
|
||||
func parseInt64(value string) int64 {
|
||||
n, _ := strconv.ParseInt(value, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) {
|
||||
expire := redisconst.M3U8DayRateLimitExpire()
|
||||
first, err := redis.Setnx_NewOK(redisconst.M3U8DailyMediaIpKey(ip, mediaID), 1, expire)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
countKey := redisconst.M3U8DayRateLimitIpKey(ip)
|
||||
if first {
|
||||
return redis.Incr(countKey, expire)
|
||||
}
|
||||
value, err := redis.Get(countKey)
|
||||
if err != nil || value == nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseInt(*value, 10, 64)
|
||||
}
|
||||
|
||||
func GetM3U8AuthParam(videoId int64, urlPath, ip, ua string) string {
|
||||
_ = ua
|
||||
if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false); signed != "" {
|
||||
return signed
|
||||
}
|
||||
return urlPath
|
||||
}
|
||||
|
||||
// GetPlayletM3U8AuthParam 为短剧剧集地址签发票据。useNewKey 表示当前地址是原片。
|
||||
func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool) string {
|
||||
if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey); signed != "" {
|
||||
return signed
|
||||
}
|
||||
return urlPath
|
||||
}
|
||||
|
||||
// GetM3U8OfficialParam 返回带官网开放范围校验的剧集播放地址
|
||||
func GetM3U8OfficialParam(chapterID, playletID int64, urlPath, ip string, useNewKey bool) string {
|
||||
return BuildH5M3U8OfficialURL(chapterID, playletID, urlPath, ip, useNewKey)
|
||||
}
|
||||
|
||||
// NewEnkey 返回短剧原片解密密钥。
|
||||
func NewEnkey(ctx *gin.Context) {
|
||||
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
||||
idStr := c.Query("id")
|
||||
tStr := c.Query("t")
|
||||
ticket := c.Query("ticket")
|
||||
if idStr == "" || tStr == "" || ticket == "" {
|
||||
err = errors.New("unauthorized")
|
||||
return
|
||||
}
|
||||
videoId, parseErr := strconv.ParseInt(idStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
err = errors.New("invalid id")
|
||||
return
|
||||
}
|
||||
tVal, parseErr := strconv.ParseInt(tStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
err = errors.New("invalid t")
|
||||
return
|
||||
}
|
||||
ip := ipaddr.ResolveClientIP(c)
|
||||
ticketInfo, ticketOK := CheckH5M3U8TicketForEnkey(videoId, tVal, ip, ticket)
|
||||
if !ticketOK || !ticketInfo.UseNewKey {
|
||||
err = errors.New("ticket check failed")
|
||||
return
|
||||
}
|
||||
if ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, videoId) {
|
||||
err = errors.New("本集暂未在官网开放")
|
||||
return
|
||||
}
|
||||
buffer, err := m3u8.NewEnkey(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
contentLength = int64(buffer.Len())
|
||||
contentType = "application/octet-stream"
|
||||
reader = buffer
|
||||
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
|
||||
return
|
||||
}
|
||||
handlerFile(ctx, fn)
|
||||
}
|
||||
|
||||
// officialChapterAllowed 每次获取官网播放清单或密钥时重新读取开放范围
|
||||
func officialChapterAllowed(ctx context.Context, playletID, chapterID int64) bool {
|
||||
if playletID <= 0 || chapterID <= 0 {
|
||||
return false
|
||||
}
|
||||
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{
|
||||
playletmod.FN.Playlet.Id: playletID,
|
||||
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
||||
})
|
||||
if err != nil || playlet == nil {
|
||||
return false
|
||||
}
|
||||
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
|
||||
playletchaptermod.FN.PlayletChapter.Id: chapterID,
|
||||
playletchaptermod.FN.PlayletChapter.PlayletId: playletID,
|
||||
})
|
||||
if err != nil || chapter == nil {
|
||||
return false
|
||||
}
|
||||
return playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode)
|
||||
}
|
||||
|
||||
func LsjEnkey(ctx *gin.Context) {
|
||||
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
||||
buffer, err := m3u8.LsjEnkey(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
contentLength = int64(buffer.Len())
|
||||
contentType = "application/octet-stream"
|
||||
reader = buffer
|
||||
extraHeaders = map[string]string{}
|
||||
return
|
||||
}
|
||||
handlerFile(ctx, fn)
|
||||
}
|
||||
|
||||
func recordM3U8RequestLog(ctx *gin.Context, m3u8Name string, videoId int64, t int64, ticketOK bool, isOverseas bool, cdnDomain string, status string, reqErr error, start time.Time) {
|
||||
doc := &m3u8requestlogmod.M3U8RequestLog{
|
||||
CreatedAt: time.Now(),
|
||||
Host: ctx.Request.Host,
|
||||
RequestURI: ctx.Request.RequestURI,
|
||||
M3U8Path: m3u8Name,
|
||||
RawQuery: ctx.Request.URL.RawQuery,
|
||||
Query: cloneURLValues(ctx.Request.URL.Query()),
|
||||
Headers: cloneHeader(ctx.Request.Header),
|
||||
VideoId: videoId,
|
||||
T: t,
|
||||
Token: ctx.Query("ticket"),
|
||||
ClientIP: ipaddr.ResolveClientIP(ctx),
|
||||
RemoteAddr: ctx.Request.RemoteAddr,
|
||||
XForwardedFor: ctx.GetHeader("X-Forwarded-For"),
|
||||
XRealIP: ctx.GetHeader("X-Real-IP"),
|
||||
CFConnectingIP: ctx.GetHeader("CF-Connecting-IP"),
|
||||
UserAgent: ctx.Request.UserAgent(),
|
||||
Referer: ctx.GetHeader("Referer"),
|
||||
IsOverseas: isOverseas,
|
||||
CDNDomain: cdnDomain,
|
||||
TokenOK: ticketOK,
|
||||
PathOK: true,
|
||||
Status: status,
|
||||
CostMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
if reqErr != nil {
|
||||
doc.Error = reqErr.Error()
|
||||
}
|
||||
|
||||
go func(data *m3u8requestlogmod.M3U8RequestLog) {
|
||||
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err1 := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); err1 != nil {
|
||||
log.Warn("recordM3U8RequestLog insert failed", log.Any("status", data.Status), log.Any("path", data.M3U8Path))
|
||||
}
|
||||
}(doc)
|
||||
}
|
||||
|
||||
func cloneHeader(header http.Header) map[string][]string {
|
||||
return cloneStringValues(map[string][]string(header))
|
||||
}
|
||||
|
||||
func cloneURLValues(values url.Values) map[string][]string {
|
||||
return cloneStringValues(map[string][]string(values))
|
||||
}
|
||||
|
||||
func cloneStringValues(values map[string][]string) map[string][]string {
|
||||
result := make(map[string][]string, len(values))
|
||||
for key, vals := range values {
|
||||
copied := make([]string, len(vals))
|
||||
copy(copied, vals)
|
||||
result[key] = copied
|
||||
}
|
||||
return result
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/moreser"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/pubdef"
|
||||
"videoweb/pub/storage/proxy"
|
||||
)
|
||||
|
||||
func TestOfficialPlaybackTicket(t *testing.T) {
|
||||
const ip = "127.0.0.1"
|
||||
const path = "v3/official-test.m3u8"
|
||||
signed := BuildH5M3U8OfficialURL(321, 123, path, ip, false)
|
||||
u, err := url.Parse(signed)
|
||||
if err != nil || u.Path != path {
|
||||
t.Fatalf("官网票据地址无效 %q %v", signed, err)
|
||||
}
|
||||
expireAt, err := strconv.ParseInt(u.Query().Get("t"), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ticket, ok := CheckH5M3U8Ticket(321, expireAt, path, ip, u.Query().Get("ticket"))
|
||||
if !ok || ticket.OfficialPlayletID != 123 {
|
||||
t.Fatalf("官网短剧标识未保存在签名票据中 %+v", ticket)
|
||||
}
|
||||
if _, ok := CheckH5M3U8Ticket(321, expireAt, path, "127.0.0.2", u.Query().Get("ticket")); ok {
|
||||
t.Fatal("官网票据未校验请求IP")
|
||||
}
|
||||
if ticket.UseNewKey {
|
||||
t.Fatal("水印片不应使用原片密钥")
|
||||
}
|
||||
original := BuildH5M3U8OfficialURL(321, 123, "bktappupdata/sp/a.m3u8", ip, true)
|
||||
ou, err := url.Parse(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalExpire, err := strconv.ParseInt(ou.Query().Get("t"), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalTicket, ok := CheckH5M3U8Ticket(321, originalExpire, "bktappupdata/sp/a.m3u8", ip, ou.Query().Get("ticket"))
|
||||
if !ok || !originalTicket.UseNewKey {
|
||||
t.Fatalf("原片票据未标记新密钥 %+v", originalTicket)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoListFilterAndSort(t *testing.T) {
|
||||
filter := videoListFilter(12)
|
||||
if filter[videoinformationmod.FN.VideoInformation.Status] != pubdef.InformationStatusVerifyOK ||
|
||||
filter[videoinformationmod.FN.VideoInformation.CategoryId] != int64(12) {
|
||||
t.Fatalf("分类或上架状态筛选错误: %v", filter)
|
||||
}
|
||||
if _, ok := filter[videoinformationmod.FN.VideoInformation.UpdatedAt]; ok {
|
||||
t.Fatalf("最近更新不应限制更新时间: %v", filter)
|
||||
}
|
||||
if got := videoListSort(1); got[0].Key != videoinformationmod.FN.VideoInformation.Real.WatchTimes {
|
||||
t.Fatalf("热度排序错误: %v", got)
|
||||
}
|
||||
if got := videoListSort(2); got[0].Key != videoinformationmod.FN.VideoInformation.UpdatedAt || got[0].Value != -1 {
|
||||
t.Fatalf("最近更新排序错误: %v", got)
|
||||
}
|
||||
if _, err := VideoList(context.Background(), &proto.VideoListReq{SortType: 3}); err == nil {
|
||||
t.Fatal("未拒绝无效排序方式")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoListMixedPagination(t *testing.T) {
|
||||
const videoCount int64 = 17
|
||||
ads := []proxy.AdvitiseInfo{{Id: "ad-1"}, {Id: "ad-2"}}
|
||||
adCount := videoListInsertedAdsCount(videoCount, len(ads))
|
||||
if adCount != 3 {
|
||||
t.Fatalf("广告位数量 = %d, want 3", adCount)
|
||||
}
|
||||
all := make(videoinformationmod.VideoInformationList, 0, videoCount)
|
||||
for id := int64(1); id <= videoCount; id++ {
|
||||
all = append(all, &videoinformationmod.VideoInformation{Id: id})
|
||||
}
|
||||
var got []*proto.VideoRecommendListItem
|
||||
for start := int64(0); start < videoCount+adCount; start += 7 {
|
||||
end := start + 7
|
||||
if end > videoCount+adCount {
|
||||
end = videoCount + adCount
|
||||
}
|
||||
contentStart, contentLimit := moreser.ContentRange(start, end, videoCount, adCount, videoListAdInterval)
|
||||
page := buildVideoListMixedPage(all[contentStart:contentStart+contentLimit], ads, "",
|
||||
start, end, contentStart, adCount)
|
||||
if int64(len(page)) != end-start {
|
||||
t.Fatalf("分页 [%d,%d) 返回 %d 条", start, end, len(page))
|
||||
}
|
||||
got = append(got, page...)
|
||||
}
|
||||
var videoID int64 = 1
|
||||
for pos, item := range got {
|
||||
if pos%6 == 5 && pos < 18 {
|
||||
if item.Type != proto.VideoRecommendItemTypeAdvertise || item.AdvertiseId != ads[(pos/6)%len(ads)].Id {
|
||||
t.Fatalf("位置 %d 广告错误: %+v", pos, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if item.Type != proto.VideoRecommendItemTypeVideo || item.Id != videoID {
|
||||
t.Fatalf("位置 %d 视频错误: %+v, want ID %d", pos, item, videoID)
|
||||
}
|
||||
videoID++
|
||||
}
|
||||
if videoID != videoCount+1 {
|
||||
t.Fatalf("视频末尾 ID = %d, want %d", videoID, videoCount+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoListAdSlots(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
videos int64
|
||||
ads int
|
||||
want int64
|
||||
}{
|
||||
{0, 2, 0}, {5, 2, 0}, {6, 2, 1}, {10, 2, 1}, {11, 2, 2}, {16, 1, 3}, {16, 0, 0},
|
||||
} {
|
||||
if got := videoListInsertedAdsCount(tc.videos, tc.ads); got != tc.want {
|
||||
t.Errorf("视频 %d 条、广告 %d 条得到 %d 个广告位,want %d", tc.videos, tc.ads, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/ipaddr"
|
||||
"videoweb/pub/common/m3u8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetVTT 获取雪碧图 vtt,缩略图路径替换为图片 cdn,兼容 /laosiji 路径资源。
|
||||
func GetVTT(ctx *gin.Context) {
|
||||
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
||||
vttName := c.Param("key")
|
||||
|
||||
var req m3u8.Req
|
||||
if err = c.BindQuery(&req); err != nil {
|
||||
return
|
||||
}
|
||||
req.Name = vttName
|
||||
|
||||
imgCdn := configser.GetAppImageCdn(ipaddr.ResolveClientIP(c))
|
||||
originDomain := appg.Conf.OrigDomain
|
||||
lsjImageYuan := appg.Conf.LsjMediaConf.ImageYuan
|
||||
|
||||
buff, err := m3u8.TransformVtt(req.Name, imgCdn, originDomain, lsjImageYuan)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
contentType = "text/vtt; charset=utf-8"
|
||||
reader = bytes.NewReader(buff)
|
||||
contentLength = int64(len(buff))
|
||||
extraHeaders = map[string]string{}
|
||||
return
|
||||
}
|
||||
handlerFile(ctx, fn)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package wordsfilterser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var (
|
||||
// 只识别带点号和明确后缀的域名,避免把普通英文、拼音和中文昵称当成网址。
|
||||
advertiseDomainPattern = regexp.MustCompile(`(?i)(?:https?://|www[.,])?[a-z0-9][a-z0-9-]{0,62}(?:\.[a-z0-9][a-z0-9-]{0,62})*[.,](?:ai|app|biz|cc|club|cn|co|com|fun|info|io|link|live|me|net|online|org|pro|shop|site|top|tv|vip|win|work|xyz)(?:[^a-z0-9]|$)`)
|
||||
// “加”与联系方式必须成组出现,不再通过中文拼音推断,避免误伤正常姓名。
|
||||
advertiseAddContactPattern = regexp.MustCompile(`(?i)(?:加|\+)[\s._,:,。-]{0,3}(?:v(?:x)?|w(?:x)?|q{1,2}|微(?:信)?|扣{1,2}|群|好友|电报|飞机)`)
|
||||
// 联系方式名称与账号同时出现时才拦截,单独的英文或数字允许正常发布。
|
||||
advertiseContactThenAccountPattern = regexp.MustCompile(`(?i)(?:微信|微号|vx|wx|qq|扣扣|电报|telegram|tg|飞机|联系方式|联系我|联系)[\s::=._-]{0,6}[a-z0-9][a-z0-9._-]{3,}`)
|
||||
advertiseAccountThenContactPattern = regexp.MustCompile(`(?i)[a-z0-9][a-z0-9._-]{3,}[\s()()::=._-]{0,8}(?:微信|微号|vx|wx|qq|扣扣|电报|telegram|tg|飞机)`)
|
||||
// 明确动作词与长账号同时出现时拦截,单独的数字仍交给AI结合内容上下文判断。
|
||||
advertiseAddAccountPattern = regexp.MustCompile(`(?i)(?:加我|加(?:v|q{1,2}|微(?:信)?)|\+)[\s::=._-]{0,6}(?:[a-z][a-z0-9._-]{3,}|[1-9][0-9]{6,10})`)
|
||||
advertiseAccountThenAddPattern = regexp.MustCompile(`(?i)[1-9][0-9]{6,10}[^0-9]{0,16}(?:加我(?:v|q{1,2}|微信)|加(?:v|q{1,2}|微信))`)
|
||||
advertiseActionAccountPattern = regexp.MustCompile(`(?i)(?:换|留(?:下)?(?:联系方式)?|私(?:我|聊)?|找我)[\s::=+._-]{0,6}[1-9][0-9]{8,10}`)
|
||||
// 识别用空白或标点拆开的微信号,例如“wxc z n q 3018”。
|
||||
advertiseBrokenAccountPattern = regexp.MustCompile(`(?i)(?:wx|vx|wxc)(?:[\s._-]*[a-z0-9]){4,}`)
|
||||
// 这些短语本身已经表达站外引流意图,不依赖账号长度判断。
|
||||
advertisePromotionPattern = regexp.MustCompile(`(?i)(?:搜(?:索)?(?:网页|网址|网站)|浏览器搜索|免费(?:空降|约炮|一夜情)|同城(?:约炮|空降|上门)|(?:约炮|约妹|骚妹).{0,8}(?:上门|空降)|电鸽|纸飞机|(?:来|去|上)?(?:薇博|微博)[\s::=._-]{0,3}(?:搜|找|关注))`)
|
||||
|
||||
advertiseTextReplacer = strings.NewReplacer(
|
||||
"丶", ".", "、", ".", "。", ".", ".", ".", "。", ".", "﹒", ".", "·", ".", "⢀", ".",
|
||||
",", ",", "➕", "+", "+", "+",
|
||||
"匚", "c", "匸", "c", "丅", "t", "卩", "p", "卪", "p", "讠", "i",
|
||||
)
|
||||
)
|
||||
|
||||
// validateAdvertiseText 只拦截高置信联系方式、网址和站外引流文本。
|
||||
func validateAdvertiseText(text string) bool {
|
||||
normalized := normalizeAdvertiseText(text)
|
||||
// 纯数字6是用户常见的互动表达,无论长度都不作为广告处理。
|
||||
if normalized != "" && strings.Trim(normalized, "6") == "" {
|
||||
return true
|
||||
}
|
||||
return !advertiseDomainPattern.MatchString(normalized) &&
|
||||
!advertiseAddContactPattern.MatchString(normalized) &&
|
||||
!advertiseContactThenAccountPattern.MatchString(normalized) &&
|
||||
!advertiseAccountThenContactPattern.MatchString(normalized) &&
|
||||
!advertiseAddAccountPattern.MatchString(normalized) &&
|
||||
!advertiseAccountThenAddPattern.MatchString(normalized) &&
|
||||
!advertiseActionAccountPattern.MatchString(normalized) &&
|
||||
!advertiseBrokenAccountPattern.MatchString(normalized) &&
|
||||
!advertisePromotionPattern.MatchString(normalized)
|
||||
}
|
||||
|
||||
// normalizeAdvertiseText 将全角和数学字体还原为普通字符,再处理常见分隔符变形。
|
||||
func normalizeAdvertiseText(text string) string {
|
||||
normalized := strings.ToLower(norm.NFKC.String(strings.TrimSpace(text)))
|
||||
normalized = strings.Map(normalizeAdvertiseRune, normalized)
|
||||
return advertiseTextReplacer.Replace(normalized)
|
||||
}
|
||||
|
||||
// normalizeAdvertiseRune 还原广告中常见的圈字母、圈数字和跨字符集同形字。
|
||||
func normalizeAdvertiseRune(r rune) rune {
|
||||
switch {
|
||||
case r >= '\U0001F150' && r <= '\U0001F169':
|
||||
return 'a' + r - '\U0001F150'
|
||||
case r >= '\u2776' && r <= '\u277e':
|
||||
return '1' + r - '\u2776'
|
||||
case r >= '\u2780' && r <= '\u2788':
|
||||
return '1' + r - '\u2780'
|
||||
case r >= '\u278a' && r <= '\u2792':
|
||||
return '1' + r - '\u278a'
|
||||
}
|
||||
|
||||
switch r {
|
||||
case 'а', 'α':
|
||||
return 'a'
|
||||
case 'в', 'β':
|
||||
return 'b'
|
||||
case 'с':
|
||||
return 'c'
|
||||
case 'е', 'ε':
|
||||
return 'e'
|
||||
case 'і', 'ι':
|
||||
return 'i'
|
||||
case 'ј':
|
||||
return 'j'
|
||||
case 'к', 'κ':
|
||||
return 'k'
|
||||
case 'м', 'μ':
|
||||
return 'm'
|
||||
case 'о', 'ο':
|
||||
return 'o'
|
||||
case 'р', 'ρ':
|
||||
return 'p'
|
||||
case 'ѕ':
|
||||
return 's'
|
||||
case 'т', 'τ':
|
||||
return 't'
|
||||
case 'у', 'υ':
|
||||
return 'y'
|
||||
case 'х', 'χ':
|
||||
return 'x'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package wordsfilterser
|
||||
|
||||
// IsAdvertise 判断文本是否包含广告链接、联系方式或站外引流。
|
||||
func IsAdvertise(text string) bool {
|
||||
return !validateAdvertiseText(text)
|
||||
}
|
||||
|
||||
// Validate 校验文本是否可发布。当前仅拦截广告内容。
|
||||
func Validate(text string) bool {
|
||||
return !IsAdvertise(text)
|
||||
}
|
||||
Reference in New Issue
Block a user