Initial commit: web_hghl_server

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:28:14 +08:00
co-authored by Claude Opus 4.7
commit 65fca60622
653 changed files with 135901 additions and 0 deletions
+333
View File
@@ -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),
}},
}
}
+17
View File
@@ -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
}
+195
View File
@@ -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()
}
+587
View File
@@ -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
}
+506
View File
@@ -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
}
+63
View File
@@ -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
}
+126
View File
@@ -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
}
+454
View File
@@ -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()
}
+85
View File
@@ -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)
}
+116
View File
@@ -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 ""
}
}
+54
View File
@@ -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,
}
}
+35
View File
@@ -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
}
+280
View File
@@ -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
}
+144
View File
@@ -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
}
}
+91
View File
@@ -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 // 年榜
)
+17
View File
@@ -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)
}
+102
View File
@@ -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))
}
}
+233
View File
@@ -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
}
+128
View File
@@ -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
}
+226
View File
@@ -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
}
}
+217
View File
@@ -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 "/"
}
+44
View File
@@ -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))
}
}
+191
View File
@@ -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
}
+137
View File
@@ -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
}
+134
View File
@@ -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)
}
+126
View File
@@ -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
}
+73
View File
@@ -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)
}
}
}
+119
View File
@@ -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)
}
+279
View File
@@ -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"
}
+90
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
package searchser
+487
View File
@@ -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
}
+119
View File
@@ -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
})
}
+260
View File
@@ -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
}
+211
View File
@@ -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)
}
+258
View File
@@ -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[:])
}
+69
View File
@@ -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
}
+386
View File
@@ -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
+127
View File
@@ -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)
}
}
}
+40
View File
@@ -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)
}
+104
View File
@@ -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
}
}
+11
View File
@@ -0,0 +1,11 @@
package wordsfilterser
// IsAdvertise 判断文本是否包含广告链接、联系方式或站外引流。
func IsAdvertise(text string) bool {
return !validateAdvertiseText(text)
}
// Validate 校验文本是否可发布。当前仅拦截广告内容。
func Validate(text string) bool {
return !IsAdvertise(text)
}