commit 65fca606221054c8734512ae7882b01d8688cd03 Author: fish Date: Mon Oct 26 16:28:14 2026 +0800 Initial commit: web_hghl_server Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3fbf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +tool +.DS_Store +.project +.idea +.vscode +/temp +/run +*.tar.gz +*.tar.xz +# Google 服务账号私钥(Indexing API),只能随部署单独投放,禁止入库。 +composed-anvil-*.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..589d3fe --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +include tool/build/Makefile.help + +## 更新APP文档 +update_app_doc: + cd app && swag init --parseDependency --parseDepth 4 + +## 更新Web文档 +update_h5_doc: + cd h5app && swag init --parseDependency --parseDepth 4 + +## 更新Web文档 +update_web_doc: + cd web && swag init --parseDependency --parseDepth 4 + +## 更新所有项目文档 +update_all_docs: update_app_doc update_h5_doc update_web_doc + +## 更新测试服Web +deploy_test_web: + cd web && ./deploy_test.sh + +## 更新测试服H5 +deploy_test_h5: + cd h5app && ./deploy_test.sh + +## 更新测试服APP +deploy_test_app: + cd app && ./deploy_test.sh + +## 更新测试服APP + H5 +deploy_test_h5_and_app: deploy_test_app deploy_test_h5 + +test: + ./tool/build/check_syntax.sh + +.DEFAULT_GOAL := help \ No newline at end of file diff --git a/app/Makefile b/app/Makefile new file mode 100755 index 0000000..8008b1a --- /dev/null +++ b/app/Makefile @@ -0,0 +1,28 @@ +GOPATH:=$(shell go env GOPATH) + +GIT_BRANCH:=$(shell git symbolic-ref --short -q HEAD) +GIT_VERSION:=$(shell git log --pretty=oneline -p -1 | head -n 1 | awk '{print $$1}') +BUILD_DATE:=$(shell date '+%Y-%m-%d %H:%M:%S %z') +BUILD_IP:=$(shell ifconfig | grep inet | grep -v grep | grep -v "inet6" | grep -v "127.0.0.1" | awk '{print $$2}') +VERSON_PATH:=pfshop/pub/srvversion + +APP_BUILD_FLAG:=-ldflags "-X '$(VERSON_PATH).GIT_BRANCH=$(GIT_BRANCH)' -X '$(VERSON_PATH).GIT_VERSION=$(GIT_VERSION)' -X '$(VERSON_PATH).BUILD_DATE=$(BUILD_DATE)' -X '$(VERSON_PATH).BUILD_IP=$(BUILD_IP)'" + + +.PHONY: test deploy +deploy: + @echo "deploy, GOPATH=$(GOPATH)" + go clean + rm -f web_mdcom_app + env GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_mdcom_app -v + tar czf web_mdcom_app.tar.gz web_mdcom_app + rm -f web_mdcom_app + +test: + @echo "test, GOPATH=$(GOPATH)" + GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_mdcom_app + tar czf web_mdcom_app.tar.gz web_mdcom_app docs/ + rm -f web_mdcom_app +localtest: + @echo "test, GOPATH=$(GOPATH)" + go build $(APP_BUILD_FLAG) -o app \ No newline at end of file diff --git a/app/api/actressapi/actress.go b/app/api/actressapi/actress.go new file mode 100644 index 0000000..2fb4ad3 --- /dev/null +++ b/app/api/actressapi/actress.go @@ -0,0 +1,57 @@ +package actressapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/actressser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// QueryActressWorkList 指定女优详情和作品混排列表 +// @Tags 女优相关接口 +// @Summary 指定女优详情和作品列表 -- by 王鸣 +// @Description 查询指定女优详情和作品列表,支持上架时间/观看量/收藏量升降序,每间隔8条作品数据插入一个广告 +// @Accept json +// @Produce json +// @Param params query proto.QueryActressWorkListReq true "指定女优作品列表请求" +// @Success 200 {object} proto.QueryActressWorkListResp{list=[]proto.QueryVideoListItem{VideoInfo=proto.VideoInfo}} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/app/actress/work/list [get] +func QueryActressWorkList(c *gin.Context) { + var req proto.QueryActressWorkListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := actressser.QueryActressWorkList(c, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// ActressList 女优榜单列表 +// @Tags 女优相关接口 +// @Summary 女优榜单列表 -- by 王鸣 +// @Description 返回导航、广告和按热度值降序排列的女优列表 +// @Accept json +// @Produce json +// @Success 200 {object} proto.QueryActressListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/actress/list [get] +func ActressList(c *gin.Context) { + data, err := actressser.ActressListData(c, c.Request.URL.Path) + if err != nil { + common.ServerJSON(c, err, "") + return + } + if data == nil { + common.ServerJSON(c, srvdef.ErrDbQueryError, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} diff --git a/app/api/appinfoapi/appinfo.go b/app/api/appinfoapi/appinfo.go new file mode 100644 index 0000000..2a77b54 --- /dev/null +++ b/app/api/appinfoapi/appinfo.go @@ -0,0 +1,54 @@ +package appinfoapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/appser" + "videoweb/app/service/configser" + "videoweb/app/service/translationser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// QueryAppList 查询应用列表 +// @Tags 应用列表 +// @Summary 查询应用列表 +// @Description 返回应用分类列表,每个分类下含应用信息列表;categoryId=0 返回第一个分类 +// @Accept json +// @Produce json +// @Param categoryId query int false "当前分类ID,0表示默认第一个分类" +// @Success 200 {object} proto.AppListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/applist/list [get] +func QueryAppList(c *gin.Context) { + var req struct { + CategoryId int64 `form:"categoryId"` + } + if err := c.ShouldBindQuery(&req); err != nil || req.CategoryId < 0 { + req.CategoryId = 0 + } + + categoryList, category, serr := appser.AppCategoryData(c, req.CategoryId) + if serr != nil { + common.ServerJSON(c, serr, "") + return + } + + if categoryList == nil { + categoryList = make([]proto.AppCategoryInfo, 0) + } + + resp := proto.AppListResp{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + Config: configser.GetRequestConfig(c), + BottonTranslations: translationser.GetTranslationBottomList(), + Advitise: configser.GetAdvitise(), + Announcement: configser.GetAnnouncement(), + CategoryList: categoryList, + Category: category, + } + configser.RewriteImageCdn(c, &resp) + common.ServerJSON(c, srvdef.Success, resp) +} diff --git a/app/api/authapi/auth.go b/app/api/authapi/auth.go new file mode 100644 index 0000000..96701cc --- /dev/null +++ b/app/api/authapi/auth.go @@ -0,0 +1,186 @@ +package authapi + +import ( + "videoweb/app/service/authser" + "videoweb/app/service/wordsfilterser" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/srvdef" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" +) + +// extractUA 从 gin context 中提取 UA 中间件解析到的设备/系统信息 +func extractUA(ctx *gin.Context) authser.UAInfo { + return authser.UAInfo{ + Device: ctx.GetString(consts.CtxDevType), + SysType: pubdef.SysType(ctx.GetString(consts.CtxSysType)), + AppVer: ctx.GetString(consts.CtxVersion), + IP: ctx.ClientIP(), + } +} + +type guestLoginReq struct { + DevID string `json:"devID"` // 设备 ID(首选) + Mac string `json:"mac"` // 设备 MAC 地址(备用) + DeviceId string `json:"deviceId"` // 设备 ID 第二版本(备用) +} + +type registerReq struct { + AccountNo string `json:"accountNo" binding:"required"` // 账号 + Password string `json:"password" binding:"required"` // 明文密码 + AgentInviteCode string `json:"agentInviteCode"` // 邀请人的邀请码(可选) +} + +type loginReq struct { + Account string `json:"account" binding:"required"` // 账号 + Password string `json:"password" binding:"required"` // 明文密码 +} + +type updateProfileReq struct { + AccountNo string `json:"accountNo" binding:"required"` // 账号(必填,用于定位用户) + Avatar *string `json:"avatar"` // 新头像 URL(可选) + NikeName *string `json:"nikeName"` // 昵称 + OldPassword *string `json:"oldPassword"` // 旧密码明文(修改密码时必填) + NewPassword *string `json:"newPassword"` // 新密码明文(修改密码时必填) +} + +// GuestLogin 游客登录/自动注册 +// @Tags 用户认证 +// @Summary 游客登录 +// @Description 按设备 ID 查找已有游客账号,找不到则自动注册。若请求携带有效 token 则优先复用该用户。 +// @Accept json +// @Produce json +// @Param param body guestLoginReq true "设备信息" +// @Success 200 {object} authser.LoginResp "登录成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/login/guest [POST] +func GuestLogin(ctx *gin.Context) { + var req guestLoginReq + _ = ctx.ShouldBind(&req) + + // 若设备信息为空,尝试从 UA 中间件补充 DevID + if req.DevID == "" { + req.DevID = ctx.GetString(consts.CtxDevId) + } + + currentUserId := common.GetUserId(ctx) + resp, serr := authser.GuestLogin(ctx, &authser.GuestLoginReq{ + DevID: req.DevID, + Mac: req.Mac, + DeviceId: req.DeviceId, + UA: extractUA(ctx), + }, currentUserId) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Register 账号注册(游客升级 or 直接注册) +// @Tags 用户认证 +// @Summary 注册 +// @Description 已登录游客传入账号密码完成升级;未登录用户直接注册新账号。 +// @Accept json +// @Produce json +// @Param param body registerReq true "注册参数" +// @Success 200 {object} authser.LoginResp "注册成功,返回 token 及用户基本信息" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/register [POST] +func Register(ctx *gin.Context) { + var req registerReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + currentUserId := common.GetUserId(ctx) + resp, serr := authser.Register(ctx, req.AccountNo, req.Password, req.AgentInviteCode, currentUserId, extractUA(ctx)) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Login 用户登录 +// @Tags 用户认证 +// @Summary 登录 +// @Description 账号均可作为登录凭证 +// @Accept json +// @Produce json +// @Param param body loginReq true "登录参数" +// @Success 200 {object} authser.LoginResp "登录成功,返回 token 及用户基本信息" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/login [POST] +func Login(ctx *gin.Context) { + var req loginReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := authser.Login(ctx, req.Account, req.Password, extractUA(ctx)) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// GetInfo 获取当前登录用户信息 +// @Tags 用户认证 +// @Summary 获取当前登录用户信息 +// @Description 根据当前登录 token 查询用户基础信息 +// @Accept json +// @Produce json +// @Success 200 {object} authser.UserInfoResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/getInfo [get] +func GetInfo(ctx *gin.Context) { + userId := common.GetUserId(ctx) + if userId <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, nil) + return + } + resp, serr := authser.GetInfo(ctx, userId) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// UpdateProfile 修改用户资料 +// @Tags 用户认证 +// @Summary 修改用户资料(头像 / 密码,可任意组合) +// @Description 账号必填;avatar、oldPassword+newPassword 均为可选,至少提供一项。修改密码时旧密码和新密码必须同时提供。 +// @Accept json +// @Produce json +// @Param param body updateProfileReq true "修改资料参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/profile [POST] +func UpdateProfile(ctx *gin.Context) { + var req updateProfileReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if req.NikeName != nil && !wordsfilterser.Validate(*req.NikeName) { + common.ServerJSON(ctx, srvdef.ErrContainIllegalWord, "包含非法字符") + return + } + serr := authser.UpdateProfile(ctx, &authser.UpdateProfileReq{ + AccountNo: req.AccountNo, + Avatar: req.Avatar, + NikeName: req.NikeName, + OldPassword: req.OldPassword, + NewPassword: req.NewPassword, + }) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/app/api/commentapi/comment.go b/app/api/commentapi/comment.go new file mode 100644 index 0000000..0cbac23 --- /dev/null +++ b/app/api/commentapi/comment.go @@ -0,0 +1,102 @@ +package commentapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/commentser" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// List 评论列表 +// @Tags 评论APP +// @Summary 评论列表 -- by 王鸣 +// @Description 按对象查询树形评论列表 +// @Accept json +// @Produce json +// @Param params query proto.CommentListReq true "评论列表请求" +// @Success 200 {object} proto.CommentListResp{list=[]proto.CommentInfo} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/comment/list [get] +func List(ctx *gin.Context) { + var req proto.CommentListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := commentser.ListCommentTree(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SendComment 发布评论 +// @Tags 评论APP +// @Summary 发布评论 -- by 王鸣 +// @Description 发布评论 +// @Accept json +// @Produce json +// @Param param body proto.CommentSendReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/app/comment/send [post] +func SendComment(ctx *gin.Context) { + var in proto.CommentSendReq + if err := ctx.ShouldBindJSON(&in); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + userId, exists := ctx.Get(consts.CtxUserID) + if !exists { + common.ServerJSON(ctx, srvdef.InvalidToken, nil) + return + } + userIdInt64, ok := userId.(int64) + if !ok || userIdInt64 <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, nil) + return + } + + ip := ctx.ClientIP() + ua, _ := common.GetUA(ctx) + err := commentser.SendComment(ctx, ip, userIdInt64, &in, ua) + if err != nil { + common.ServerJSON(ctx, err.Code, "") + return + } + common.ServerJSON(ctx, srvdef.Success, "success") +} + +// SetLike 设置评论点赞状态 +// @Tags 评论APP +// @Summary 评论点赞/取消点赞 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body proto.CommentLikeSetReq true "点赞参数" +// @Success 200 {object} proto.CommentLikeSetResp "成功" +// @Security ApiKeyAuth +// @Router /api/app/comment/like/set [post] +func SetLike(ctx *gin.Context) { + var req proto.CommentLikeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + userId := common.GetUserId(ctx) + if userId <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, nil) + return + } + resp, err := commentser.SetCommentLike(ctx, userId, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/config/config.go b/app/api/config/config.go new file mode 100644 index 0000000..1dc8b3f --- /dev/null +++ b/app/api/config/config.go @@ -0,0 +1,66 @@ +package config + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// FooterInfo 查询APP底部信息 +// @Tags app配置相关接口 +// @Summary 查询APP底部信息 -- by 王鸣 +// @Description 返回APP底部品牌、底部导航和联系方式 +// @Accept json +// @Produce json +// @Success 200 {object} proto.FooterInfoResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/config/footer/info [get] +func FooterInfo(ctx *gin.Context) { + resp := &proto.FooterInfoResp{ + FooterInfo: configser.GetFooterInfo(), + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// RichTextList 查询APP富文本内容列表 +// @Tags app配置相关接口 +// @Summary 查询APP富文本内容列表 -- by 王鸣 +// @Description 返回启用状态的富文本内容,支持按ids或titles过滤 +// @Accept json +// @Produce json +// @Param ids query string false "富文本ID,多个用英文逗号分隔" +// @Param titles query string false "富文本标题,多个用英文逗号分隔" +// @Success 200 {object} proto.AppRichTextListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/config/richtext/list [get] +func RichTextList(ctx *gin.Context) { + resp := configser.GetAppRichTextList(ctx.Query("ids"), ctx.Query("titles")) + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ContentTagList 查询APP统一标签列表 +// @Tags app配置相关接口 +// @Summary 查询APP统一标签列表 -- by 王鸣 +// @Description 返回启用且有绑定内容数量的统一标签,contentTagType非必填,按绑定内容数量倒序排序 +// @Accept json +// @Produce json +// @Param params query proto.AppContentTagListReq false "统一标签列表请求" +// @Success 200 {object} proto.AppContentTagListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/content/tag/list [get] +func ContentTagList(ctx *gin.Context) { + req := &proto.AppContentTagListReq{} + if err := ctx.ShouldBindQuery(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + resp, err := configser.AppContentTagList(ctx, req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/dhapi/dh.go b/app/api/dhapi/dh.go new file mode 100644 index 0000000..26d6bd0 --- /dev/null +++ b/app/api/dhapi/dh.go @@ -0,0 +1,142 @@ +package dhapi + +import ( + "fmt" + "strconv" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/searchser" + "videoweb/app/service/translationser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// DHApi 导航 +// @Tags 导航 +// @Summary 导航 +// @Description 导航 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.DHHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /dh [get] +func DHApi(c *gin.Context) { + data := proto.DHHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + HotNavs: configser.GetHotNavs(), + } + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} + +// BusinessCooperationApi 导航 +// @Tags 导航 +// @Summary 导航 +// @Description 导航 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.HomeHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /businessCooperation [get] +func BusinessCooperationApi(c *gin.Context) { + data := proto.DHHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + HotSearch: searchser.GetHotSearchItem(c), + Advitise: configser.GetAdvitise(), + } + data.CompletePath = "/businessCooperation" + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} + +// ServicesProjectApi 导航 +// @Tags 导航 +// @Summary 导航 +// @Description 导航 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.HomeHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /servicesProject [get] +func ServicesProjectApi(c *gin.Context) { + data := proto.DHHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + HotSearch: searchser.GetHotSearchItem(c), + Advitise: configser.GetAdvitise(), + } + data.CompletePath = "/servicesProject" + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} + +// ConsultingApi 招募 +// @Tags 招募 +// @Summary 招募 +// @Description 招募 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.HomeHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /consulting [get] +func ConsultingApi(c *gin.Context) { + data := proto.DHConsultingHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + } + data.CompletePath = "/consulting" + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} + +// ConsultingInfoApi 招募详情 +// @Tags 招募详情 +// @Summary 招募详情 +// @Description 招募详情 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.HomeHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /consultinginfo/:id [get] +func ConsultingInfoApi(c *gin.Context) { + data := proto.DHConsultingInfoHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + HotSearch: searchser.GetHotSearchItem(c), + Advitise: configser.GetAdvitise(), + } + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err == nil && id > 0 { + data.CompletePath = fmt.Sprintf("/consultingInfo/%d", id) + } + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} diff --git a/app/api/healthapi/health.go b/app/api/healthapi/health.go new file mode 100644 index 0000000..f9f9d56 --- /dev/null +++ b/app/api/healthapi/health.go @@ -0,0 +1,42 @@ +package healthapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/healthser" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// HealthCheck 健康检查接口 +func HealthCheck(ctx *gin.Context) { + req := &proto.HealthCheckRequest{} + if err := ctx.ShouldBindJSON(req); err != nil { + log.Warn("health check bind error", log.E(err)) + respond(ctx, srvdef.ErrParamError, err.Error(), nil) + return + } + log.Info("health check request", log.Any("req", req)) + + data, serr := healthser.HealthCheck(ctx.Request.Context(), req) + if serr != nil { + log.Warn("health check failed", log.E(serr), log.Any("req", req)) + respond(ctx, serr.Code, serr.GetMsg(), nil) + return + } + + log.Info("health check response", log.Any("resp", data)) + respond(ctx, srvdef.Success, "success", data) +} + +func respond(ctx *gin.Context, code srvdef.CodeType, msg string, data interface{}) { + if msg == "" { + msg = code.Msg() + } + ctx.JSON(200, gin.H{ + "code": code, + "msg": msg, + "data": data, + }) +} diff --git a/app/api/home/home.go b/app/api/home/home.go new file mode 100644 index 0000000..1bb7d8e --- /dev/null +++ b/app/api/home/home.go @@ -0,0 +1,53 @@ +package home + +import ( + "videoweb/app/service/homeser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// Home 首页 +// @Tags 首页 +// @Summary 首页 --by 旺鸣 +// @Description 首页分类数据 +// @Accept json +// @Produce json +// @Success 200 {object} proto.DataResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/home [get] +func Home(c *gin.Context) { + data, err := homeser.HomeData(c) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.JSONWriterCache(c, srvdef.Success, data) +} + +// HomeBase 首页公共数据 +// @Tags 首页 +// @Summary 首页公共数据 --by 旺鸣 +// @Description 只返回翻译、配置、底部翻译、广告和公告 +// @Accept json +// @Produce json +// @Success 200 {object} proto.HomeBaseResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/home/base [get] +func HomeBase(c *gin.Context) { + common.JSONWriterCache(c, srvdef.Success, homeser.HomeBaseData(c)) +} + +// HomeTags 首页标签数据 +// @Tags 首页 +// @Summary 首页标签数据 --by 旺鸣 +// @Description 返回视频、短剧的标签信息,以及基础导航、配置、广告和公告 +// @Accept json +// @Produce json +// @Success 200 {object} proto.HomeTagsResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/home/tags [get] +func HomeTags(c *gin.Context) { + common.JSONWriterCache(c, srvdef.Success, homeser.HomeTagsData(c)) +} diff --git a/app/api/lawandtrust/lawandtrust.go b/app/api/lawandtrust/lawandtrust.go new file mode 100644 index 0000000..5f95ce5 --- /dev/null +++ b/app/api/lawandtrust/lawandtrust.go @@ -0,0 +1,122 @@ +package lawandtrust + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/searchser" + "videoweb/app/service/translationser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// UserAgreementApi 用户协议 +// @Tags 用户协议 +// @Summary 用户协议 +// @Description 用户协议 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.LawAndTrustHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /useragreement [get] +func UserAgreementApi(c *gin.Context) { + data := proto.LawAndTrustHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + Advitise: configser.GetAdvitise(), + HotSearch: searchser.GetHotSearchItem(c), + } + + data.CompletePath = "/useragreement" + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) + +} + +// PrivacyPolicyApi 隐私策略 +// @Tags 隐私策略 +// @Summary 隐私策略 +// @Description 隐私策略 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.LawAndTrustHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /privacyPolicy [get] +func PrivacyPolicyApi(c *gin.Context) { + data := proto.LawAndTrustHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + Advitise: configser.GetAdvitise(), + HotSearch: searchser.GetHotSearchItem(c), + } + data.CompletePath = "/useragreement" + + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) + +} + +// DmcaApi dmca +// @Tags dmca +// @Summary dmca +// @Description dmca +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.LawAndTrustHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /dmca [get] +func DmcaApi(c *gin.Context) { + data := proto.LawAndTrustHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + Advitise: configser.GetAdvitise(), + HotSearch: searchser.GetHotSearchItem(c), + } + + data.CompletePath = "/useragreement" + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} + +// StatementApi 合规声明 +// @Tags 合规声明 +// @Summary 合规声明 +// @Description 合规声明 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.LawAndTrustHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /statement [get] +func StatementApi(c *gin.Context) { + data := proto.LawAndTrustHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetRequestConfig(c), + Announcement: configser.GetAnnouncement(), + Advitise: configser.GetAdvitise(), + HotSearch: searchser.GetHotSearchItem(c), + } + + data.CompletePath = "/useragreement" + configser.RewriteImageCdn(c, &data) + common.JSONWriterCache(c, srvdef.Success, data) +} diff --git a/app/api/navigationapi/header.go b/app/api/navigationapi/header.go new file mode 100644 index 0000000..74c884b --- /dev/null +++ b/app/api/navigationapi/header.go @@ -0,0 +1,27 @@ +package navigationapi + +import ( + "videoweb/app/service/navigationser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// HeaderNavigation 查询头部导航配置 +// @Tags app导航相关接口 +// @Summary 查询头部导航配置 -- by 王鸣 +// @Description 查询导航类型为URL模式和二级导航的头部导航配置 +// @Accept json +// @Produce json +// @Success 200 {object} proto.AppTranslationInfo "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/navigation/header [get] +func HeaderNavigation(ctx *gin.Context) { + resp, err := navigationser.HeaderNavigation(ctx) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.JSONWriterCache(ctx, srvdef.Success, resp) +} diff --git a/app/api/playletapi/playlet.go b/app/api/playletapi/playlet.go new file mode 100644 index 0000000..ea4d6ce --- /dev/null +++ b/app/api/playletapi/playlet.go @@ -0,0 +1,484 @@ +package playletapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/playletser" + "videoweb/app/service/slugser" + "videoweb/app/service/videoser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// HomePlaylet app端短剧首页 +// @Tags app短剧相关接口 +// @Summary app端短剧首页 -- by 王鸣 +// @Description 返回短剧Banner、首页配置模块、公告、广告和SEO信息 +// @Produce json +// @Success 200 {object} proto.HomePlayletResponse "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/home [get] +func HomePlaylet(ctx *gin.Context) { + resp, err := playletser.Home(ctx) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HomeConfigList 按首页配置分页查询内容 +// @Tags app短剧相关接口 +// @Summary app端首页配置内容列表 -- by 王鸣 +// @Description 按首页配置ID或slug分页查询内容,同时传入时优先使用slug +// @Produce json +// @Param params query proto.PlayletHomeConfigListReq true "首页配置内容分页请求" +// @Success 200 {object} proto.PlayletHomeConfigListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/home-config/list [get] +func HomeConfigList(ctx *gin.Context) { + var req proto.PlayletHomeConfigListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.HomeConfigList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TopicDetail 查询短剧题材或视频分类专题详情 +// @Tags app短剧相关接口 +// @Summary app端专题详情 -- by 王鸣 +// @Produce json +// @Param params query proto.PlayletTopicDetailReq true "专题详情请求" +// @Success 200 {object} proto.PlayletTopicDetailResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/topic/detail [get] +func TopicDetail(ctx *gin.Context) { + var req proto.PlayletTopicDetailReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.TopicDetail(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ThemeList 查询全部已启用短剧题材 +// @Tags app短剧相关接口 +// @Summary app端短剧题材列表 +// @Produce json +// @Success 200 {object} proto.PlayletThemeListResp "成功" +// @Router /api/app/playlet/theme/list [get] +func ThemeList(ctx *gin.Context) { + resp, err := playletser.PlayletThemeList(ctx) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// List 分页查询已上架短剧并穿插广告 +// @Tags app短剧相关接口 +// @Summary app端短剧列表 -- by 王鸣 +// @Description 按题材和排序筛选已上架短剧,最近更新按更新时间降序,每4部短剧穿插1条广告 +// @Produce json +// @Param params query proto.PlayletListReq true "短剧列表请求" +// @Success 200 {object} proto.PlayletListResp{list=[]proto.PlayletRecommendListItem} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/list [get] +func List(ctx *gin.Context) { + var req proto.PlayletListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.PlayletList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ContentList 分页查询上架短剧或视频并穿插广告 +// @Tags app短剧相关接口 +// @Summary app端短剧或视频全站排行榜列表 -- by 王鸣 +// @Description 按观看量查询上架内容,dataType 为 1 时每 4 条短剧穿插 1 条广告,为 2 时每 5 条视频穿插 1 条广告,并返回广告配置 +// @Produce json +// @Param params query proto.PlayletContentListReq true "短剧或视频列表请求" +// @Success 200 {object} proto.PlayletContentListResp{list=[]proto.PlayletContentListItem} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/content/list [get] +func ContentList(ctx *gin.Context) { + var req proto.PlayletContentListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.ContentList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// RecommendList app端推荐短剧列表 +// @Tags app短剧相关接口 +// @Summary app端推荐短剧列表 -- by 王鸣 +// @Description 分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告 +// @Accept json +// @Produce json +// @Param params query proto.PlayletRecommendListReq true "推荐短剧列表请求" +// @Success 200 {object} proto.PlayletRecommendListResp{list=[]proto.PlayletRecommendListItem} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/recommend/list [get] +func RecommendList(ctx *gin.Context) { + var req proto.PlayletRecommendListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.PlayletRecommendList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// GuessList 分页查询猜你喜欢短剧 +// @Tags app短剧相关接口 +// @Summary 猜你喜欢短剧列表 -- by 王鸣 +// @Description id可选,传入时排除当前短剧,按观看量降序查询上架短剧 +// @Produce json +// @Param params query proto.PlayletGuessListReq true "猜你喜欢短剧请求" +// @Success 200 {object} proto.PlayletGuessListResp{list=[]proto.PlayletRecommendListItem} "成功" +// @Router /api/app/playlet/guess/list [get] +func GuessList(ctx *gin.Context) { + var req proto.PlayletGuessListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.PlayletGuessList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TagList app端根据标签ID查询短剧列表 +// @Tags app短剧相关接口 +// @Summary app端根据标签ID查询短剧列表 -- by 王鸣 +// @Description 根据统一标签ID分页查询已上架短剧 +// @Accept json +// @Produce json +// @Param id path int true "标签ID" +// @Param pageNum query int false "页码,默认1" +// @Param pageSize query int false "每页数量,默认20,最大100" +// @Success 200 {object} proto.PlayletRecommendListResp{list=[]proto.PlayletRecommendListItem} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/tag/{id} [get] +func TagList(ctx *gin.Context) { + var req proto.ContentTagPageReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + pathID, pathSlug := slugser.ParseIDOrSlug(ctx.Param("id")) + if req.Id <= 0 { + req.Id = pathID + } + if req.Slug == "" { + req.Slug = pathSlug + } + resp, err := playletser.PlayletTagList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// QueryPlayletInfo 短剧详情 +// @Tags app短剧相关接口 +// @Summary 短剧详情 -- by 王鸣 +// @Description 查询短剧详情、剧集列表、点赞状态和收藏状态 +// @Accept json +// @Produce json +// @Param params query proto.QueryPlayletInfoReq true "短剧详情查询请求" +// @Success 200 {object} proto.QueryPlayletInfoResp{playletInfo=proto.PlayletDetailInfo,chapterList=[]proto.PlayletChapterInfo} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/info [get] +func QueryPlayletInfo(ctx *gin.Context) { + var req proto.QueryPlayletInfoReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.QueryPlayletInfo(ctx, common.GetUserId(ctx), videoser.H5M3U8RequestIP(ctx), ctx.Request.UserAgent(), &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// QueryPlayletChapterInfo 短剧剧集详情 +// @Tags app短剧相关接口 +// @Summary app端短剧剧集详情 -- by 王鸣 +// @Description 根据剧集ID查询短剧剧集详情、所属短剧、剧集列表、热门短剧列表和点赞状态 +// @Accept json +// @Produce json +// @Param params query proto.QueryPlayletChapterInfoReq true "短剧剧集详情查询请求" +// @Success 200 {object} proto.QueryPlayletChapterInfoResp{chapterInfo=proto.PlayletChapterInfo,playletInfo=proto.PlayletData,chapterList=[]proto.PlayletChapterInfo,hotPlayletList=[]proto.PlayletData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/chapter/info [get] +func QueryPlayletChapterInfo(ctx *gin.Context) { + var req proto.QueryPlayletChapterInfoReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.QueryPlayletChapterInfo(ctx, common.GetUserId(ctx), videoser.H5M3U8RequestIP(ctx), ctx.Request.UserAgent(), &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// LikePlaylet 点赞或取消点赞短剧 +// @Tags app短剧相关接口 +// @Summary 点赞或取消点赞短剧 -- by 王鸣 +// @Description 当前登录用户点赞或取消点赞指定短剧,isLike=true表示点赞,isLike=false表示取消点赞 +// @Accept json +// @Produce json +// @Param param body proto.LikePlayletReq true "点赞或取消点赞请求" +// @Success 200 {object} proto.LikePlayletResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/like [post] +func LikePlaylet(ctx *gin.Context) { + var req proto.LikePlayletReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + userId := common.GetUserId(ctx) + if userId <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + ua, _ := common.GetUA(ctx) + if ua.UserAgent == "" { + ua.UserAgent = ctx.Request.UserAgent() + } + resp, err := playletser.LikePlaylet(ctx, userId, ctx.ClientIP(), ua, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// QueryLikePlayletList 当前用户点赞短剧分页列表 +// @Tags app短剧相关接口 +// @Summary 当前用户点赞短剧分页列表 +// @Description 按点赞时间倒序返回当前用户点赞的上架短剧 +// @Produce json +// @Param params query proto.QueryLikePlayletListReq true "点赞短剧分页请求" +// @Success 200 {object} proto.QueryLikePlayletListResp{list=[]proto.PlayletData} "成功" +// @Security ApiKeyAuth +// @Router /api/app/playlet/like/list [get] +func QueryLikePlayletList(ctx *gin.Context) { + var req proto.QueryLikePlayletListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + userID := common.GetUserId(ctx) + if userID <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + resp, err := playletser.QueryLikePlayletList(ctx, userID, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// FavoritePlaylet 收藏或取消收藏短剧 +// @Tags app短剧相关接口 +// @Summary 收藏或取消收藏短剧 +// @Description isFavorite=true 收藏 false 取消收藏 +// @Accept json +// @Produce json +// @Param param body proto.FavoritePlayletReq true "短剧收藏请求" +// @Success 200 {object} proto.FavoritePlayletResp "成功" +// @Security ApiKeyAuth +// @Router /api/app/playlet/favorite [post] +func FavoritePlaylet(ctx *gin.Context) { + var req proto.FavoritePlayletReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + userID := common.GetUserId(ctx) + if userID <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + ua, _ := common.GetUA(ctx) + if ua.UserAgent == "" { + ua.UserAgent = ctx.Request.UserAgent() + } + resp, err := playletser.FavoritePlaylet(ctx, userID, ctx.ClientIP(), ua, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// QueryFavoritePlayletList 当前用户收藏短剧分页列表 +// @Tags app短剧相关接口 +// @Summary 当前用户收藏短剧分页列表 +// @Produce json +// @Param params query proto.QueryFavoritePlayletListReq true "收藏短剧分页请求" +// @Success 200 {object} proto.QueryFavoritePlayletListResp{list=[]proto.PlayletData} "成功" +// @Security ApiKeyAuth +// @Router /api/app/playlet/favorite/list [get] +func QueryFavoritePlayletList(ctx *gin.Context) { + var req proto.QueryFavoritePlayletListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + userID := common.GetUserId(ctx) + if userID <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + resp, err := playletser.QueryFavoritePlayletList(ctx, userID, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ReportPlayletHistory 播放成功后记录当前用户的短剧剧集 +// @Tags app短剧相关接口 +// @Summary 上报短剧观看历史 +// @Accept json +// @Produce json +// @Param param body proto.PlayletHistoryReportReq true "当前播放短剧和剧集" +// @Success 200 {object} proto.PlayletHistoryReportResp "成功" +// @Security ApiKeyAuth +// @Router /api/app/playlet/history [post] +func ReportPlayletHistory(ctx *gin.Context) { + userID := common.GetUserId(ctx) + if userID <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + var req proto.PlayletHistoryReportReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.ReportPlayletHistory(ctx, userID, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// QueryPlayletHistoryList 当前用户短剧观看历史分页列表 +// @Tags app短剧相关接口 +// @Summary 短剧观看历史列表 +// @Produce json +// @Param params query proto.PlayletHistoryListReq true "分页请求" +// @Success 200 {object} proto.PlayletHistoryListResp{list=[]proto.PlayletHistoryItem} "成功" +// @Security ApiKeyAuth +// @Router /api/app/playlet/history/list [get] +func QueryPlayletHistoryList(ctx *gin.Context) { + userID := common.GetUserId(ctx) + if userID <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + var req proto.PlayletHistoryListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.QueryPlayletHistoryList(ctx, userID, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ClearPlayletHistory 清空当前用户短剧观看历史 +// @Tags app短剧相关接口 +// @Summary 清空短剧观看历史 +// @Produce json +// @Success 200 {object} proto.PlayletHistoryClearResp "成功" +// @Security ApiKeyAuth +// @Router /api/app/playlet/history [delete] +func ClearPlayletHistory(ctx *gin.Context) { + userID := common.GetUserId(ctx) + if userID <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, "") + return + } + resp, err := playletser.ClearPlayletHistory(ctx, userID) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// QueryPlayletRankList 短剧排行榜分页列表 +// @Tags app短剧相关接口 +// @Summary 短剧排行榜分页列表 -- by 王鸣 +// @Description 分页查询短剧排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50 +// @Accept json +// @Produce json +// @Param params query proto.PlayletRankReq true "短剧排行榜分页请求" +// @Success 200 {object} proto.PlayletRankResp{list=[]proto.PlayletRankData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/rank/list [get] +func QueryPlayletRankList(ctx *gin.Context) { + var req proto.PlayletRankReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "") + return + } + resp, err := playletser.QueryPlayletRank(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/proto/actress.go b/app/api/proto/actress.go new file mode 100644 index 0000000..2c993e7 --- /dev/null +++ b/app/api/proto/actress.go @@ -0,0 +1,96 @@ +package proto + +import ( + "time" + "videoweb/pub/common/netutil" + "videoweb/pub/common/response" + "videoweb/pub/models/actress" + "videoweb/pub/pubdef" +) + +// QueryActressWorkListReq 指定女优作品列表请求 +type QueryActressWorkListReq struct { + pubdef.Page + // 女优ID + Id int64 `json:"id" form:"id"` + // 女优slug,非空时优先按slug解析女优ID + Slug string `json:"slug" form:"slug"` + // 上架时间升序/降序(1-升序 2降序) + AddedTimeSort int64 `json:"addedTimeSort" form:"addedTimeSort"` + // 按观看量升序/降序(1-升序 2降序) + WatchTimesSort int64 `json:"watchTimesSort" form:"watchTimesSort"` + // 按收藏(点赞)量升序/降序(1-升序 2降序) + LikesSort int64 `json:"likesSort" form:"likesSort"` +} + +// QueryActressWorkListResp 指定女优详情和作品混排列表响应 +type QueryActressWorkListResp struct { + // 分页信息;List 类型为 []*QueryVideoListItem,包含视频项(type=0)和广告项(type=2) + response.BasePageResp + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 女优信息 + ActressInfo ActressInfoData `json:"actressInfo"` +} + +// QueryActressListResp 女优榜单列表响应 +type QueryActressListResp struct { + // 当前路径 + Path string `json:"path"` + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 底部导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 女优列表 + ActressInfoList []ActressInfoData `json:"actressInfoList"` +} + +type ActressInfoData struct { + Id int64 `json:"id" bson:"_id"` // Id + Alias string `bson:"alias" json:"alias"` // 别名 + Slug string `json:"slug"` // slug + Avatar string `bson:"avatar" json:"avatar"` // 头像 + Cover string `bson:"cover" json:"cover"` // 封面大图 + Desc string `bson:"desc" json:"desc"` // 详细描述 + Meas actress.Meas `bson:"meas" json:"meas"` // 三围 + MeasValue string `bson:"measValue" json:"measValue"` // 三围值 + Office string `json:"office" bson:"office"` // 事务所 + DebutDate time.Time `bson:"debutDate" json:"debutDate" ` // 出道日期 + BornDate time.Time `bson:"bornDate" json:"bornDate"` // 出生日期 + Region string `json:"region" bson:"region"` // 地区/国家 + Score string `json:"score" bson:"score"` // 评分 + Works int64 `json:"works" bson:"works"` // 作品数 + HotValue int64 `json:"hotValue" bson:"hotValue"` // 热度值 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 观看次数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + Comments int64 `json:"comments" bson:"comments"` // 评论条数 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 +} + +func ActressInfoToData(actress2 actress.Actress, imageCdn string) ActressInfoData { + return ActressInfoData{ + Id: actress2.Id, + Alias: actress2.Alias, + Slug: actress2.Slug, + Avatar: netutil.MergeURL(imageCdn, actress2.Avatar), + Cover: netutil.MergeURL(imageCdn, actress2.Cover), + Desc: actress2.Desc, + Meas: actress2.Meas, + Office: actress2.Office, + DebutDate: actress2.DebutDate, + BornDate: actress2.BornDate, + Region: actress2.Region, + Score: actress2.Score, + MeasValue: actress2.Meas.Measurements(), + Works: actress2.Works, + HotValue: actress2.HotValue, + WatchTimes: actress2.Real.WatchTimes*100 + 2273, + ShowTimes: actress2.Real.WatchTimes*100 + 3273, + Comments: actress2.Real.Comments, + SeoInfo: actress2.SeoInfo, + } +} diff --git a/app/api/proto/ads.go b/app/api/proto/ads.go new file mode 100644 index 0000000..ae27f33 --- /dev/null +++ b/app/api/proto/ads.go @@ -0,0 +1,38 @@ +package proto + +import "videoweb/pub/storage/proxy" + +type AdsClickReq struct { + AdsId string `json:"adsId,omitempty" binding:"required"` + Location int64 `json:"location,omitempty" binding:"required"` // 位置ID +} + +type AdsClickInfo struct { + AppId int64 `json:"appId,omitempty" binding:"required"` // appId + AdsId int64 `json:"adsId,omitempty" binding:"required"` + Location int64 `json:"location,omitempty" binding:"required"` // 位置ID +} + +type AdvitiseData struct { + HomeList []proxy.AdvitiseInfo `json:"homeList" bson:"homeList"` // 首页列表 + DetailTopList []proxy.AdvitiseInfo `json:"detailTopList" bson:"detailTopList"` // 详情页顶部广告 + NineGridList []proxy.AdvitiseInfo `json:"nineGridList" bson:"nineGridList"` // 9宫格 + DetailbannerList []proxy.AdvitiseInfo `json:"detailbannerList" bson:"detailbannerList"` // 详情banner + LaunchScreenList []proxy.AdvitiseInfo `json:"launchScreenList"` // 启动页广告 + FloatingList []proxy.AdvitiseInfo `json:"floatingList" bson:"floatingList"` // 悬浮小方块 + AList []proxy.AdvitiseInfo `json:"AList"` // 固定12广告位 + BList []proxy.AdvitiseInfo `json:"BList"` // 固定13广告位 + DialogList []proxy.AdvitiseInfo `json:"dialogList" bson:"dialogList"` // 弹窗广告 + PostList []proxy.AdvitiseInfo `json:"postList" bson:"postList"` // 列表穿插广告(视频、短剧、女优列表使用) + MustAppList []proxy.AdvitiseInfo `json:"mustAppList" bson:"mustAppList"` // 必备精品 + TitleAppList []proxy.AdvitiseInfo `json:"titleAppList" bson:"titleAppList"` // 标题广告(APP名称) + FriendList []proxy.AdvitiseInfo `json:"friendList" bson:"friendList"` // 友链 + VideoList []proxy.AdvitiseInfo `json:"videoList" bson:"videoList"` // 视频列表 + + //VideoInfoList []proxy.AdvitiseInfo `json:"videoInfoList" bson:"videoInfoList"` // 视频详情 + //DownList []proxy.AdvitiseInfo `json:"downList" bson:"downList"` // 底部广告 + //HomeBanner []proxy.AdvitiseInfo `json:"homeBanner" bson:"homeBanner"` // 首页banner小广告 + //PlayList []proxy.AdvitiseInfo `json:"playList" bson:"playList"` // 播放强制跳转 + //PauseyList []proxy.AdvitiseInfo `json:"pauseyList" bson:"pauseyList"` // 暂停广告 + +} diff --git a/app/api/proto/appinfo.go b/app/api/proto/appinfo.go new file mode 100644 index 0000000..bb954ab --- /dev/null +++ b/app/api/proto/appinfo.go @@ -0,0 +1,61 @@ +package proto + +// AppListResp 应用列表接口响应 +type AppListResp struct { + // 当前路径 + Path string `json:"path"` + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 底部导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 公告 + Announcement AnnouncementInfo `json:"announcement"` + // 应用分类列表(含各分类下的应用列表) + CategoryList []AppCategoryInfo `json:"categoryList"` + // 当前分类(含应用列表) + Category AppCategoryInfo `json:"category"` +} + +// AppCategoryInfo 应用分类信息 +type AppCategoryInfo struct { + // 分类ID + Id int64 `json:"id"` + // 分类标题 + Title string `json:"title"` + // 分类简介 + Intro string `json:"intro"` + // 排序 + Sort int64 `json:"sort"` + // 分类编码(特殊值:12=广告位A 13=广告位B) + Code int64 `json:"code"` + // 应用列表 + AppList []AppInfoData `json:"appList"` +} + +// AppInfoData 应用信息 +type AppInfoData struct { + // 应用ID + Id int64 `json:"id"` + // 所属分类ID + CategoryId int64 `json:"categoryId"` + // 应用名称 + Name string `json:"name"` + // 简介 + Intro string `json:"intro"` + // 详细描述 + Description string `json:"description"` + // Logo 图片地址 + Logo string `json:"logo"` + // 跳转链接(已含渠道码) + Url string `json:"url"` + // 排序 + Sort int64 `json:"sort"` + // 广告ID(广告条目时返回) + AdvitiseId string `json:"advitiseId"` + // 广告位置ID + Location int64 `json:"location"` +} diff --git a/app/api/proto/comment.go b/app/api/proto/comment.go new file mode 100644 index 0000000..c2c9fa2 --- /dev/null +++ b/app/api/proto/comment.go @@ -0,0 +1,64 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +type CommentListReq struct { + ObjectId int64 `form:"objectId" json:"objectId"` // 对象ID + ObjectType pubdef.CommentObjectType `form:"objectType" json:"objectType"` // 对象类型 0=视频 4=短剧 11=短剧剧集 + SortOrder int64 `form:"sortOrder" json:"sortOrder"` // 排序 1=热门 2=最新 默认最新 + pubdef.Page +} + +type CommentListResp struct { + response.BasePageResp +} + +type GetCommentListReq struct { + ObjectId int64 `form:"objectId" json:"objectId"` // 对象ID + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + pubdef.Page +} + +type GetCommentListResp struct { + ObjectId int64 `json:"objectId" bson:"objectId"` + Total int64 `json:"total" bson:"total"` // 总数量 + List []CommentInfo `json:"list" bson:"list"` // 数据 +} + +type CommentSendReq struct { + ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectType pubdef.CommentObjectType `json:"objectType" bson:"objectType"` // 对象类型 0=视频 4=短剧 11=短剧剧集 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + Text string `json:"text" bson:"text"` // 评论内容 +} + +type CommentInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID + ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID + UserName string `json:"userName" bson:"userName"` // 用户名称 + Avatar string `json:"avatar" bson:"avatar"` // 用户头像 + Level int64 `json:"level" bson:"level"` // 评论级别 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + ReplyUserName string `json:"replyUserName" bson:"replyUserName"` // 回复名称 + Text string `json:"text" bson:"text"` // 评论内容 + CommentCount int64 `json:"commentCount" bson:"commentCount"` // 该评论回复数量 + CountLike int64 `json:"countLike" bson:"countLike"` // 点赞数 + IsLike bool `json:"isLike" bson:"-"` // 当前用户是否点赞 + SubComment []CommentInfo `json:"subComment" bson:"subComment"` // 子评论 + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // 创建时间 +} + +type CommentLikeSetReq struct { + CommentId int64 `json:"commentId" binding:"required"` // 评论ID + IsLike *bool `json:"isLike" binding:"required"` // 是否点赞 +} + +type CommentLikeSetResp struct { + CommentId int64 `json:"commentId"` // 评论ID + IsLike bool `json:"isLike"` // 当前点赞状态 + CountLike int64 `json:"countLike"` // 点赞数 +} diff --git a/app/api/proto/config.go b/app/api/proto/config.go new file mode 100644 index 0000000..29b4d06 --- /dev/null +++ b/app/api/proto/config.go @@ -0,0 +1,132 @@ +package proto + +import ( + "videoweb/pub/models/contenttagmod" + "videoweb/pub/pubdef" +) + +type ConfigInfo struct { + PdfAddr string `json:"pdfAddr" bson:"pdfAddr"` // 回家的路PDF + NewUrlAddr string `json:"newUrlAddr" bson:"newUrlAddr"` //最新地址 + NewUrlAddr1 string `json:"newUrlAddr1" bson:"newUrlAddr1"` //最新地址(备用地址1) + NewUrlAddr2 string `json:"newUrlAddr2" bson:"newUrlAddr2"` //最新地址(备用地址2) + ForeverUrlAddr string `json:"foreverUrlAddr" bson:"foreverUrlAddr"` //永久地址 + GmailAddr string `json:"gmailAddr" bson:"gmailAddr"` //邮箱地址 + AppAddr string `json:"appAddr" bson:"appAddr"` //APP下载地址(落地页) + WeiChart string `json:"weiChart" bson:"weiChart"` //微信群 + QQ string `json:"qq" bson:"qq"` //qq群 + TgGroup string `json:"tgGroup" bson:"tgGroup"` //tg群 + PotaoGroup string `json:"potaoGroup" bson:"potaoGroup"` //土豆群 + GitAddr string `json:"gitAddr" bson:"gitAddr"` //git地址 + TwitterAddr string `json:"twitterAddr" bson:"twitterAddr"` //推特地址 + Telegram string `json:"telegram" bson:"telegram"` //商务tg + TelegramAdd string `json:"telegramAdd" bson:"telegramAdd"` //加入我们tg + TranslationAddr string `json:"translationAddr" bson:"translationAddr"` //应用导航 + ImgCdn string `json:"imgCdn" bson:"imgCdn"` // 图片CDN + TsCdn string `json:"tsCdn" bson:"tsCdn"` // 视频CDN + ImgAppUsCdn string `json:"imgAppUsCdn" bson:"imgAppUsCdn"` // 图片CDN(海外app使用) + MediaAppUsCdn string `json:"mediaAppUsCdn" bson:"mediaAppUsCdn"` // 视频CDN(海外app使用) + WebTitle string `json:"webTitle" bson:"webTitle"` // 网站SEO标题 + WebKeyworld []string `json:"webKeyworld" bson:"webKeyworld"` // 网站SEO关键字 + WebDescription string `json:"webDescription" bson:"webDescription"` // 网站SEO描述 + WebSiteName string `json:"webSiteName" bson:"webSiteName"` // 网站SEO网站名称 + WebAddr string `json:"webAddr" bson:"webAddr"` // 网站SEO网站地址 + DHNewUrlAddr1 string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1"` // 导航页-线路1 + DHNewUrlAddr2 string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2"` // 导航页-线路2 + DHNewUrlAddr3 string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3"` // 导航页-线路3 + FreeVPN string `json:"freeVPN" bson:"freeVPN"` // 免费vpn链接 + UseIngAddress []string `json:"useIngAddress" bson:"useIngAddress"` // 所有使用中的域名 + DHTitle string `json:"dhTitle" bson:"dhTitle"` // 导航SEO标题 + DHKeyworld []string `json:"dhKeyworld" bson:"dhKeyworld"` // 导航SEO关键字 + DHDescription string `json:"dhDescription" bson:"dhDescription"` // 导航SEO描述 + DHSiteName string `json:"dhSiteName" bson:"dhSiteName"` // 导航SEO网站名称 + ReportSDKConfig string `json:"reportSdkConfig"` // 数据上报SDK域名配置 + ChannelCode string `json:"channelCode"` // 渠道码 + EnableH265 bool `json:"enableH265" bson:"enableH265"` // 是否允许客户端使用 H265 地址播放 + M3U8DayRateLimitMax int64 `json:"m3u8DayRateLimitMax" bson:"m3u8DayRateLimitMax"` // 每 IP 每天最多不同正片数 + +} + +// AnnouncementInfo 公告数据 +type AnnouncementInfo struct { + VideoInfo []VideoInfoData `json:"videoInfo" bson:"videoInfo"` // 热门信息 + PdfAddr string `json:"pdfAddr" bson:"pdfAddr"` // 回家的路PDF + NewUrlAddr string `json:"newUrlAddr" bson:"newUrlAddr"` // 新国内地址 + NewUrlAddr1 string `json:"newUrlAddr1" bson:"newUrlAddr1"` // 备用地址1 + NewUrlAddr2 string `json:"newUrlAddr2" bson:"newUrlAddr2"` // 备用地址1 + ForeverUrlAddr string `json:"foreverUrlAddr" bson:"foreverUrlAddr"` // 永久海外地址(翻墙访问 永不迷路) + AppAddr string `json:"appAddr" bson:"appAddr"` // 吃瓜app (免费吃瓜 每日更新) + TranslationAddr string `json:"translationAddr" bson:"translationAddr"` // 应用导航 +} + +type AdvitiseInfo struct { + Id int64 `json:"id" bson:"_id"` // 广告id + Title string `json:"title" bson:"title"` // 广告标题 + Images string `json:"images" bson:"images"` // 视频banner + Link string `json:"link" bson:"link"` // 链接 +} + +// FooterInfoResp APP底部信息响应 +type FooterInfoResp struct { + FooterInfo FooterInfo `json:"footerInfo"` // 底部信息 +} + +// FooterInfo APP底部信息 +type FooterInfo struct { + Brand FooterBrand `json:"brand"` // 品牌信息 + NavList []TranslationCategory `json:"navList"` // 底部导航 + ContactList []FooterContact `json:"contactList"` // 联系方式 +} + +// FooterBrand APP底部品牌信息 +type FooterBrand struct { + Title string `json:"title"` // 品牌标题 + Content string `json:"content"` // 品牌标语 +} + +// FooterContact APP底部联系方式 +type FooterContact struct { + Name string `json:"name"` // 联系方式名称 + Value string `json:"value"` // 联系方式值 +} + +// AppContentTagListReq app端统一标签列表请求 +type AppContentTagListReq struct { + ContentTagType *contenttagmod.ContentTagType `form:"contentTagType" json:"contentTagType"` // 内容类型 1-视频 5-短剧,不传返回全部有绑定数量的标签 +} + +// ContentTagPageReq 根据统一标签分页查询内容请求 +type ContentTagPageReq struct { + pubdef.Page + Id int64 `json:"id" form:"id"` // 标签ID + Slug string `json:"slug" form:"slug"` // 标签slug,非空时优先按slug解析标签ID +} + +// AppContentTagListResp app端统一标签列表响应 +type AppContentTagListResp struct { + List []*AppContentTagItem `json:"list"` // 统一标签列表 +} + +// AppContentTagItem app端统一标签列表项 +type AppContentTagItem struct { + Id int64 `json:"id" bson:"_id"` // 标签ID + Name string `json:"name" bson:"name"` // 标签名称 + Slug string `json:"slug" bson:"slug"` // slug + Types []contenttagmod.ContentTagType `json:"types" bson:"types"` // 支持类型 1-视频 5-短剧 + BindCount int64 `json:"bindCount" bson:"bindCount"` // 当前查询类型绑定数量,不传type时为各类型绑定数量合计 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 绑定视频数量 + PlayletCount int64 `json:"playletCount" bson:"playletCount"` // 绑定短剧数量 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Rank int64 `json:"rank,omitempty" bson:"rank,omitempty"` // 排序 +} + +// type AdvitiseData struct { +// DialogList []AdvitiseInfo `json:"dialogList" bson:"dialogList"` // 弹窗广告 +// HomeList []AdvitiseInfo `json:"homeList" bson:"homeList"` // 首页列表 +// VideoTopList []AdvitiseInfo `json:"videoTopList" bson:"videoTopList"` // 帖子详情(上) +// VideoDownList []AdvitiseInfo `json:"videoDownList" bson:"videoDownList"` // 帖子详情(下) +// HotAppList []AdvitiseInfo `json:"hotAppList" bson:"hotAppList"` // 热门应用 +// NewAppList []AdvitiseInfo `json:"newAppList" bson:"newAppList"` // 最新上架 +// MustAppList []AdvitiseInfo `json:"mustAppList" bson:"mustAppList"` // 必备精品 +// TitleAppList []AdvitiseInfo `json:"titleAppList" bson:"titleAppList"` // 标题广告(APP名称) +// } diff --git a/app/api/proto/contribute.go b/app/api/proto/contribute.go new file mode 100644 index 0000000..96613bb --- /dev/null +++ b/app/api/proto/contribute.go @@ -0,0 +1,6 @@ +package proto + +type ContributeData struct { + Path string `json:"path"` + RichText []RichTextInfo `json:"richText" form:"richText"` // 富文本内容 +} diff --git a/app/api/proto/dh.go b/app/api/proto/dh.go new file mode 100644 index 0000000..b314aed --- /dev/null +++ b/app/api/proto/dh.go @@ -0,0 +1,50 @@ +package proto + +// HotNavItem 热门导航 +type HotNavItem struct { + Id int64 `json:"id"` // ID + Title string `json:"title"` // 标题 + Intro string `json:"intro"` // 介绍 + Url string `json:"url"` // 链接 + Icon string `json:"icon"` // 图标 +} + +// DHHtmlData 导航数据 +type DHHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + HotNavs []HotNavItem `json:"hotNavs"` // 热门导航 +} + +// DHConsultingHtmlData 招募 +type DHConsultingHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 +} + +// DHConsultingInfoHtmlData 招募 +type DHConsultingInfoHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 +} diff --git a/app/api/proto/findVideo.go b/app/api/proto/findVideo.go new file mode 100644 index 0000000..b2f9fdc --- /dev/null +++ b/app/api/proto/findVideo.go @@ -0,0 +1,5 @@ +package proto + +type FindVideoData struct { + RichText []RichTextInfo `json:"richText" form:"richText"` // 富文本内容 +} \ No newline at end of file diff --git a/app/api/proto/health.go b/app/api/proto/health.go new file mode 100644 index 0000000..b5b5a3a --- /dev/null +++ b/app/api/proto/health.go @@ -0,0 +1,37 @@ +package proto + +type HealthCheckRequest struct { + GroupID string `json:"groupId" binding:"required"` + Timestamp int64 `json:"timestamp" binding:"required"` + Sign string `json:"sign" binding:"required"` +} + +type ComponentStatus struct { + Status string `json:"status"` + Msg string `json:"msg,omitempty"` + Master string `json:"master,omitempty"` +} + +type HealthCPUMetrics struct { + Cores int `json:"cores"` + Used float64 `json:"used"` +} + +type HealthMemoryMetrics struct { + Total int64 `json:"total"` + Used int64 `json:"used"` +} + +type HealthCheckData struct { + ServerName string `json:"serverName,omitempty"` + ServerIP string `json:"serverIp,omitempty"` + CPU HealthCPUMetrics `json:"cpu"` + Memory HealthMemoryMetrics `json:"memory"` + Disk HealthMemoryMetrics `json:"disk"` + Mysql *ComponentStatus `json:"mysql,omitempty"` + Mongodb *ComponentStatus `json:"mongodb,omitempty"` + Redis *ComponentStatus `json:"redis,omitempty"` + Elasticsearch *ComponentStatus `json:"elasticsearch,omitempty"` + Kafka *ComponentStatus `json:"kafka,omitempty"` + Timestamp int64 `json:"timestamp"` +} diff --git a/app/api/proto/home.go b/app/api/proto/home.go new file mode 100644 index 0000000..64095a7 --- /dev/null +++ b/app/api/proto/home.go @@ -0,0 +1,171 @@ +package proto + +import ( + "time" + "videoweb/pub/pubdef" +) + +// DataResp 首页数据响应 +type DataResp struct { + // 当前路径 + Path string `json:"path"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 公告 + Announcement AnnouncementInfo `json:"announcement"` + // 首页banner信息列表 + HomeBannerList []HomeBannerInfo `json:"homeBannerList"` + // 首页短剧数据 + PlayletData HomePlayletDataResp `json:"playletData"` + // 首页视频数据 + VideoData HomeVideoDataResp `json:"videoData"` + // 女优信息 + ActressInfoList []ActressInfo `json:"actressInfoList"` +} + +// HomeBannerInfo 首页banner信息 +type HomeBannerInfo struct { + Id int64 `json:"id"` // ID + Name string `json:"name"` // 名称 + Image string `json:"image"` // 图片,已拼接图片域名 + VerticalImage string `json:"verticalImage"` // 竖版图片,已拼接图片域名 + Url string `json:"url"` // URL + Rank int64 `json:"rank"` // 排序 + Status bool `json:"status"` // 状态 true-启用 false-停用 +} + +// HomePlayletDataResp 首页短剧数据响应 +type HomePlayletDataResp struct { + RecommendList []PlayletData `json:"recommendList"` // 推荐短剧列表 + NewestList []PlayletData `json:"newestList"` // 最新短剧列表 + HotList []PlayletData `json:"hotList"` // 最热短剧列表 +} + +// HomeVideoDataResp 首页视频数据响应 +type HomeVideoDataResp struct { + RecommendList []VideoInfo `json:"recommendList"` // 推荐视频列表 + NewestList []VideoInfo `json:"newestList"` // 最新视频列表 + HotList []VideoInfo `json:"hotList"` // 最热视频列表 +} + +// HomeBaseResp 首页公共数据响应 +type HomeBaseResp struct { + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 公告 + Announcement AnnouncementInfo `json:"announcement"` +} + +// HomeTagsResp 首页标签数据响应 +type HomeTagsResp struct { + HomeBaseResp + // 视频标签 + VideoTagsList []TagsInfoData `json:"videoTagsList"` + // 短剧标签 + PlayletTagsList []TagsInfoData `json:"playletTagsList"` +} + +// VideoCategoryDataInfo 视频分类数据 +type VideoCategoryDataInfo struct { + CommonData + // Seo相关信息 + SeoInfo pubdef.SeoValue `json:"seoInfo"` + // 描述 + Desc string `json:"desc"` + // 分类关联的视频标签 + Tags []int64 `json:"tags"` + // 视频信息集合 + VideoInfoList []VideoInfo `json:"videoInfoList"` +} + +// CommonData 分类通用数据 +type CommonData struct { + // Id + Id int64 `json:"id"` + // 分类名称 + Name string `json:"name"` + // slug + Slug string `json:"slug"` + // APP显示 + Title string `json:"title"` + // 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + DisplayPosition pubdef.CategoryDisplayPosition `json:"displayPosition"` + // 排序方式 0:上线时间降序 1:观看次数降序 2:点赞(收藏)次数降序 3:上线时间升序 4:观看次数升序 5:点赞(收藏)次数升序 + SortType pubdef.CategorySortType `json:"sortType"` + // 展示方式 + DisplayMode pubdef.DisplayMode `json:"displayMode"` + // URL地址 + UrlValue string `json:"urlValue"` +} + +// VideoInfo 视频信息 +type VideoInfo struct { + ContentData +} + +// ContentData 内容数据信息 +type ContentData struct { + // id + Id int64 `json:"id"` + // 标题 + Title string `json:"title"` + // slug + Slug string `json:"slug"` + // 上架时间 + AddedTime time.Time `json:"addedTime"` + // 封面 + CoverImg string `json:"coverImg"` + // 标签id + Tags []int64 `json:"tags"` + // 标签名称 + TagsName []string `json:"tagsName"` + // 标签slug 与tagsName顺序一致 + TagSlugs []string `json:"tagSlugs"` + // 观看展示数 + WatchNum int64 `json:"watchNum"` + // 点赞展示数 + LikeNum int64 `json:"likeNum"` + // 收藏展示数 + CountCollect int64 `json:"countCollect,omitempty"` + // Seo相关信息 + SeoInfo pubdef.SeoValue `json:"seoInfo"` +} + +// RankResp 排行榜通用响应 +type RankResp struct { + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + RankData HomeRankData `json:"rankData"` +} + +// HomeRankData 首页排行榜数据(总榜/周榜/月榜/年榜) +type HomeRankData struct { + Total []ContentData `json:"total"` // 总榜 + Week []ContentData `json:"week"` // 周榜 + Month []ContentData `json:"month"` // 月榜 + Year []ContentData `json:"year"` // 年榜 +} + +// ActressInfo 女优信息 +type ActressInfo struct { + // Id + Id int64 `json:"id"` + // 别名 + Alias string `json:"alias"` + // slug + Slug string `json:"slug"` + // 头像 + Avatar string `json:"avatar"` + // 作品数 + Works int64 `json:"works"` + // 热度值 + HotValue int64 `json:"hotValue"` +} diff --git a/app/api/proto/lawandtrust.go b/app/api/proto/lawandtrust.go new file mode 100644 index 0000000..af707b2 --- /dev/null +++ b/app/api/proto/lawandtrust.go @@ -0,0 +1,13 @@ +package proto + +// LawAndTrustHtmlData +type LawAndTrustHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 +} diff --git a/app/api/proto/playlet.go b/app/api/proto/playlet.go new file mode 100644 index 0000000..5a3ec97 --- /dev/null +++ b/app/api/proto/playlet.go @@ -0,0 +1,380 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" +) + +// PlayletRecommendListReq 推荐短剧分页请求 +type PlayletRecommendListReq struct { + pubdef.Page + TagId int64 `form:"tagId" json:"tagId"` // 标签ID,不传查询全部标签 + TagSlug string `form:"tagSlug" json:"tagSlug"` // 标签slug,非空时优先按slug解析标签ID + CategoryId int64 `form:"categoryId" json:"categoryId"` // 短剧分类ID,不传查询全部分类 + CategorySlug string `form:"categorySlug" json:"categorySlug"` // 分类slug,非空时优先按slug解析分类ID + SortList int64 `form:"sortList" json:"sortList"` // 排序方式 1-推荐 2-最新 3-最热 +} + +// PlayletRecommendSort 推荐短剧排序方式 +type PlayletRecommendSort int64 + +const ( + PlayletRecommendSortRecommend PlayletRecommendSort = 1 // 推荐 后台配置 isRecommend=true + PlayletRecommendSortNewest PlayletRecommendSort = 2 // 最新 按短剧最后更新时间从近到远排序 + PlayletRecommendSortHot PlayletRecommendSort = 3 // 最热 按短剧累计点击量从高到低排序 +) + +// PlayletRecommendItemType 推荐短剧列表项类型 +type PlayletRecommendItemType int64 + +const ( + PlayletRecommendItemTypePlaylet PlayletRecommendItemType = 0 // 短剧数据 + PlayletRecommendItemTypeAdvertise PlayletRecommendItemType = 2 // 广告数据 +) + +// PlayletRecommendListResp 推荐短剧响应 +type PlayletRecommendListResp struct { + response.BasePageResp +} + +// PlayletThemeListResp 已启用短剧题材列表响应 +type PlayletThemeListResp struct { + List []PlayletThemeItem `json:"list"` // 全部已启用题材 +} + +// PlayletThemeItem 短剧题材列表项 +type PlayletThemeItem struct { + Id int64 `json:"id"` // 题材ID + Name string `json:"name"` // 题材名称 + Slug string `json:"slug"` // 题材slug + Icon string `json:"icon"` // 题材图标完整地址 + Rank int64 `json:"rank"` // 排序值,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // SEO信息 +} + +// PlayletGuessListReq 猜你喜欢短剧分页请求 +type PlayletGuessListReq struct { + pubdef.Page + Id int64 `form:"id" json:"id"` // 当前短剧ID,可选,传入时排除该短剧 +} + +// PlayletGuessListResp 猜你喜欢短剧分页响应 +type PlayletGuessListResp struct { + response.BasePageResp +} + +// PlayletListReq 短剧列表分页请求 +type PlayletListReq struct { + pubdef.Page + ThemeId int64 `form:"themeId" json:"themeId"` // 题材 ID,不传查询全部题材 + ThemeSlug string `form:"themeSlug" json:"themeSlug"` // 题材 slug,非空时优先于 themeId + SortType int64 `form:"sortType" json:"sortType"` // 排序方式,1 热度优先,2 最近更新,默认 1 +} + +// PlayletListResp 短剧与广告混排分页响应 +type PlayletListResp struct { + response.BasePageResp + Advitise AdvitiseData `json:"advitise"` // 完整广告配置 +} + +// PlayletContentListReq 短剧或视频分页请求 +type PlayletContentListReq struct { + pubdef.Page + DataType int64 `form:"dataType" json:"dataType"` // 数据类型,1 短剧,2 视频,必填 +} + +// PlayletContentListResp 短剧或视频与广告混排分页响应 +type PlayletContentListResp struct { + response.BasePageResp + Advitise AdvitiseData `json:"advitise"` // 完整广告配置,postList 为列表广告 +} + +// PlayletContentListItem 短剧、视频或广告列表项 +type PlayletContentListItem struct { + Type int64 `json:"type"` // 数据类型,0 内容,2 广告 + Playlet *PlayletData `json:"playlet,omitempty"` // 短剧数据,dataType 为 1 且 type 为 0 时返回 + Video *VideoInfo `json:"video,omitempty"` // 视频数据,dataType 为 2 且 type 为 0 时返回 + Advertise *proxy.AdvitiseInfo `json:"advertise,omitempty"` // 广告数据,type 为 2 时返回 +} + +// PlayletRecommendListItem 推荐短剧列表项,type=0时返回短剧信息,type=2时返回广告信息 +type PlayletRecommendListItem struct { + Type PlayletRecommendItemType `json:"type"` // 数据类型 0-短剧数据 2-广告 + AdvertiseId string `json:"advertiseId"` // 广告ID,type=2时返回 + Link string `json:"link"` // 广告跳转链接,type=2时返回 + Location int64 `json:"location"` // 广告位置ID,type=2时返回 + PlayletData +} + +// LikePlayletReq 点赞或取消点赞短剧请求 +type LikePlayletReq struct { + // ID + Id int64 `json:"id" binding:"required"` + // 是否点赞,true-点赞、false-取消点赞 + IsLike *bool `json:"isLike" binding:"required"` +} + +// LikePlayletResp 点赞或取消点赞短剧响应 +type LikePlayletResp struct { + // ID + Id int64 `json:"id"` + // 是否点赞,true-点赞、false-取消点赞 + IsLike bool `json:"isLike"` + // 点赞展示数 + LikeNum int64 `json:"likeNum"` +} + +// QueryLikePlayletListReq 当前用户点赞短剧分页请求 +type QueryLikePlayletListReq struct { + pubdef.Page +} + +// QueryLikePlayletListResp 当前用户点赞短剧分页响应 +type QueryLikePlayletListResp struct { + response.BasePageResp + Translations AppTranslationInfo `json:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` // 底部翻译 +} + +// PlayletHistoryListReq 当前用户短剧观看历史分页请求 +type PlayletHistoryListReq struct { + pubdef.Page +} + +// PlayletHistoryReportReq 播放成功后上报短剧观看历史 +type PlayletHistoryReportReq struct { + PlayletID int64 `json:"playletId" binding:"required,gt=0"` // 短剧 ID + ChapterID int64 `json:"chapterId" binding:"required,gt=0"` // 当前播放剧集 ID +} + +// PlayletHistoryReportResp 短剧观看历史上报响应 +type PlayletHistoryReportResp struct { + WatchedAt time.Time `json:"watchedAt"` // 最近观看时间 +} + +// PlayletHistoryListResp 当前用户短剧观看历史分页响应 +type PlayletHistoryListResp struct { + response.BasePageResp + Translations AppTranslationInfo `json:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` // 底部翻译 +} + +// PlayletHistoryItem 短剧观看历史卡片 +type PlayletHistoryItem struct { + PlayletData + ChapterID int64 `json:"chapterId"` // 最近观看剧集 ID + Episode int64 `json:"episode"` // 最近观看集数 + WatchedAt time.Time `json:"watchedAt"` // 最近观看时间 +} + +// PlayletHistoryClearResp 清空短剧观看历史响应 +type PlayletHistoryClearResp struct { + DeletedCount int64 `json:"deletedCount"` // 删除记录数 +} + +// FavoritePlayletReq 收藏或取消收藏短剧请求 +type FavoritePlayletReq struct { + Id int64 `json:"id" binding:"required"` // 短剧ID + IsFavorite *bool `json:"isFavorite" binding:"required"` // true 收藏 false 取消收藏 +} + +// FavoritePlayletResp 收藏或取消收藏短剧响应 +type FavoritePlayletResp struct { + Id int64 `json:"id"` // 短剧ID + IsFavorite bool `json:"isFavorite"` // 当前收藏状态 + CountCollect int64 `json:"countCollect"` // 收藏展示数 +} + +// QueryFavoritePlayletListReq 当前用户收藏短剧分页请求 +type QueryFavoritePlayletListReq struct { + pubdef.Page +} + +// QueryFavoritePlayletListResp 当前用户收藏短剧分页响应 +type QueryFavoritePlayletListResp struct { + response.BasePageResp + Translations AppTranslationInfo `json:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` // 底部翻译 +} + +// PlayletRankReq 短剧排行榜分页请求 +type PlayletRankReq struct { + pubdef.Page + // 榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜 + RankPeriod int64 `json:"rankPeriod" form:"rankPeriod"` +} + +// PlayletRankPeriod 短剧排行榜类型 +type PlayletRankPeriod int64 + +const ( + PlayletRankPeriodLike PlayletRankPeriod = 0 // 点赞榜,按累计点赞量降序,最多50条,不足随机补位 + PlayletRankPeriodWeek PlayletRankPeriod = 1 // 周榜,按本周观看量降序,最多50条,不足随机补位 + PlayletRankPeriodMonth PlayletRankPeriod = 2 // 月榜,按本月观看量降序,最多50条,不足随机补位 + PlayletRankPeriodYear PlayletRankPeriod = 3 // 年榜,按本年观看量降序,最多50条,不足随机补位 +) + +// PlayletRankResp 短剧排行榜分页响应 +type PlayletRankResp struct { + response.BasePageResp + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` +} + +// PlayletRankData 短剧排行榜数据 +type PlayletRankData struct { + PlayletData + // 榜单分数,点赞榜为累计展示点赞数,周/月/年榜为对应周期观看数 + RankScore int64 `json:"rankScore"` +} + +// QueryPlayletInfoReq 短剧详情请求 +type QueryPlayletInfoReq struct { + // 短剧ID + Id int64 `json:"id" form:"id"` + // 短剧slug,非空时优先按slug解析短剧ID + Slug string `json:"slug" form:"slug"` +} + +// QueryPlayletInfoResp 短剧详情响应 +type QueryPlayletInfoResp struct { + FreeEpisodeCount int64 `json:"freeEpisodeCount"` // 官网开放集数 1至1000表示开放第1集到第N集 -1表示开放全集 未配置按2集返回 + // 当前路径 + Path string `json:"path"` + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 公告 + Announcement AnnouncementInfo `json:"announcement"` + // 短剧详情数据 + PlayletInfo PlayletDetailInfo `json:"playletInfo"` + // 剧集列表 + ChapterList []PlayletChapterInfo `json:"chapterList"` + // 当前登录用户是否已点赞该短剧 + IsLike bool `json:"isLike"` + // 当前登录用户是否已收藏该短剧 + IsFavorite bool `json:"isFavorite"` +} + +// QueryPlayletChapterInfoReq 短剧剧集详情请求 +type QueryPlayletChapterInfoReq struct { + // 剧集ID + Id int64 `json:"id" form:"id" binding:"required"` +} + +// QueryPlayletChapterInfoResp 短剧剧集详情响应 +type QueryPlayletChapterInfoResp struct { + FreeEpisodeCount int64 `json:"freeEpisodeCount"` // 官网开放集数 1至1000表示开放第1集到第N集 -1表示开放全集 未配置按2集返回 + // 当前路径 + Path string `json:"path"` + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 公告 + Announcement AnnouncementInfo `json:"announcement"` + // 当前剧集详情 + ChapterInfo PlayletChapterInfo `json:"chapterInfo"` + // 所属短剧详情 + PlayletInfo PlayletData `json:"playletInfo"` + // 剧集列表 + ChapterList []PlayletChapterInfo `json:"chapterList"` + // 热门短剧列表 + HotPlayletList []PlayletData `json:"hotPlayletList"` + // 当前登录用户是否已点赞该短剧 + IsLike bool `json:"isLike"` + // 当前登录用户是否已收藏该短剧 + IsFavorite bool `json:"isFavorite"` +} + +// PlayletDetailInfo 短剧详情数据 +type PlayletDetailInfo struct { + PlayletData + // 评论数量 + CountComment int64 `json:"countComment"` + // 当前登录用户是否已点赞该短剧 + IsLike bool `json:"isLike"` + // 当前登录用户是否已收藏该短剧 + IsFavorite bool `json:"isFavorite"` +} + +// PlayletChapterInfo 短剧剧集数据 +type PlayletChapterInfo struct { + IsOfficialOpen *bool `json:"isOfficialOpen,omitempty"` // 本集是否在官网开放 + Id int64 `json:"id"` // 剧集ID + PlayletId int64 `json:"playletId"` // 短剧ID + MediaContentId uint `json:"mediaContentId"` // 媒资中心章节ID + CurrentEpisode int64 `json:"currentEpisode"` // 第几集 + Title string `json:"title"` // 章节名 + Md5 string `json:"md5"` // 媒体md5 + Hash string `json:"hash"` // 媒体hash + Height int64 `json:"height"` // 高 + Width int64 `json:"width"` // 宽 + Ratio float64 `json:"ratio"` // 宽高比 + VideoUrl string `json:"videoUrl"` // 视频播放地址,优先返回水印地址 + MediaSize int64 `json:"mediaSize"` // 资源大小 + MediaTime int64 `json:"mediaTime"` // 影片长度(秒) + CountBrowse int64 `json:"countBrowse"` // 浏览数 + CountCollect int64 `json:"countCollect"` // 收藏数 + CountLike int64 `json:"countLike"` // 喜欢数 + CountComment int64 `json:"countComment"` // 评论数 + PayType playletchaptermod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币 + WatermarkStatus playletchaptermod.WatermarkStatus `json:"watermarkStatus"` // 水印状态 0-未处理 1-已登记 2-处理中 3-已完成 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 +} + +// PlayletData 短剧数据 +type PlayletData struct { + Id int64 `json:"id"` // 短剧id + Status playletmod.Status `json:"status"` // 状态 0-下架 1-上架 + IsRecommend bool `json:"isRecommend"` // 是否推荐 true-推荐 false-不推荐 + IsNew bool `json:"isNew"` // 是否上新 本周内上架为true + IsFeatured bool `json:"isFeatured"` // 是否爆剧 后台设置 + IsOriginal bool `json:"isOriginal"` // 是否原创 后台新增或无媒资中心ID + ShowOriginal bool `json:"showOriginal"` // 页面是否展示原创标识 + OnlineAt time.Time `json:"onlineAt"` // 上架时间 + MediaCreatedAt time.Time `json:"mediaCreatedAt"` // 媒资中心创建时间 + MediaUpdatedAt time.Time `json:"mediaUpdatedAt"` // 媒资中心更新时间 + Title string `json:"title"` // 标题 + Slug string `json:"slug"` // slug + Desc string `json:"desc"` // 简介 + Anchor string `json:"anchor"` // 作者或演员 + Cover string `json:"cover"` // 封面,已拼接图片域名 + Preview string `json:"preview"` // 预告,已拼接图片域名 + TagIds []int64 `json:"tagIds"` // 标签ID集合 + TagNames []string `json:"tagNames"` // 标签名称集合 + TagSlugs []string `json:"tagSlugs"` // 标签slug集合 与tagNames顺序一致 + ThemeIds []int64 `json:"themeIds"` // 题材ID集合 + ThemeNames []string `json:"themeNames"` // 题材名称集合 + ContentType playletmod.ContentType `json:"contentType"` // 内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画 + PayType playletmod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币,支付方式为金币时生效 + TotalEpisode int64 `json:"totalEpisode"` // 总集数 + UpdateStatus int `json:"updateStatus"` // 更新状态 1-连载中 2-已完结 + CountBrowse int64 `json:"countBrowse"` // 观看展示数 + HeatValue int64 `json:"heatValue"` // 热度值 + CountCollect int64 `json:"countCollect"` // 收藏展示数 + CountLike int64 `json:"countLike"` // 点赞展示数 + VirtualCountLike int64 `json:"virtualCountLike"` // 后台虚拟点赞数 + DisplayCountLike int64 `json:"displayCountLike"` // 点赞展示数 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} diff --git a/app/api/proto/playlet_home.go b/app/api/proto/playlet_home.go new file mode 100644 index 0000000..d115c50 --- /dev/null +++ b/app/api/proto/playlet_home.go @@ -0,0 +1,116 @@ +package proto + +import "videoweb/pub/pubdef" + +// HomePlayletResponse 短剧首页数据。 +type HomePlayletResponse struct { + HomeBannerData []*HomeBannerData `json:"homeBannerData"` + Modules []*HomeModuleData `json:"modules"` + Announcements []*AnnouncementItem `json:"announcements"` + Advitise AdvitiseData `json:"advitise"` + SeoInfo *pubdef.SeoValue `json:"seoInfo,omitempty"` +} + +type HomeBannerData struct { + PlayletData + ShowOnBanner bool `json:"showOnBanner"` + Banner string `json:"banner"` +} + +// HomeModuleData.List 根据 dataType 返回短剧或视频列表。 +type HomeModuleData struct { + Id int64 `json:"id"` + Slug string `json:"slug"` // 页面路径标识 + Title string `json:"title"` + Desc string `json:"desc"` + Label string `json:"label"` + Style string `json:"style"` + CardBackgroundImage string `json:"cardBackgroundImage"` + ModuleType int64 `json:"moduleType"` + DataType int64 `json:"dataType"` + RecommendType int64 `json:"recommendType"` + CategoryId int64 `json:"categoryId"` + Rank int64 `json:"rank"` + List any `json:"list"` + TopicList []*HomeTopicData `json:"topicList"` +} + +type HomeTopicData struct { + Id int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Icon string `json:"icon"` + Rank int64 `json:"rank"` + PlayletCount int64 `json:"playletCount"` + VideoCount int64 `json:"videoCount,omitempty"` + List any `json:"list"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` +} + +type HomeVideoData struct { + Id int64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Cover string `json:"cover"` + Desc string `json:"desc"` + Tags []int64 `json:"tags"` + WatchNum int64 `json:"watchNum"` + LikeNum int64 `json:"likeNum"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` +} + +type AnnouncementItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` +} + +// PlayletHomeConfigListReq 首页配置内容分页请求 +type PlayletHomeConfigListReq struct { + // pageNum 默认 1,pageSize 默认 20、最大 100 + pubdef.Page + Id int64 `form:"id" json:"id"` // 首页配置 ID,与 slug 二选一 + Slug string `form:"slug" json:"slug"` // 页面路径标识,和 ID 同时传时优先使用 +} + +// PlayletHomeConfigListResp 首页配置内容分页响应 +type PlayletHomeConfigListResp struct { + Config *HomeModuleData `json:"config"` // 当前首页配置,不包含首页预览内容 + TotalCount int64 `json:"totalCount"` // 符合配置条件的内容或专题总数 + PageSize int `json:"pageSize"` // 每页条数 + PageNum int `json:"pageNum"` // 当前页码 + List any `json:"list"` // 普通模块内容,dataType 为 1 时是短剧,为 2 时是视频 + TopicList []*HomeTopicData `json:"topicList"` // 专题模块内容,短剧按题材分组,视频按分类分组 + Advitise AdvitiseData `json:"advitise"` // 广告配置 +} + +// PlayletTopicDetailReq 短剧题材或视频分类专题详情请求 +type PlayletTopicDetailReq struct { + // pageNum 默认 1,pageSize 默认 12、最大 100 + pubdef.Page + Id int64 `form:"id" json:"id"` // 专题 ID,dataType 为 1 时是题材 ID,为 2 时是视频分类 ID + TopicSlug string `form:"topicSlug" json:"topicSlug"` // 专题 slug,非空时优先于 id + DataType int64 `form:"dataType" json:"dataType"` // 数据类型,1 为短剧题材,2 为视频分类,默认 1 + SortType int64 `form:"sortType" json:"sortType"` // 排序方式,1 编辑排序,2 最新,3 最热,默认 1 +} + +// PlayletTopicDetailResp 短剧题材或视频分类专题详情响应 +type PlayletTopicDetailResp struct { + Id int64 `json:"id"` // 专题 ID + DataType int64 `json:"dataType"` // 数据类型,1 为短剧题材,2 为视频分类 + Name string `json:"name"` // 专题名称 + Slug string `json:"slug"` // 专题 slug + Desc string `json:"desc"` // 专题描述 + Icon string `json:"icon"` // 题材图标地址,视频分类专题为空 + Rank int64 `json:"rank"` // 专题排序值 + PlayletCount int64 `json:"playletCount"` // 已上架短剧数量,视频专题为 0 + VideoCount int64 `json:"videoCount,omitempty"` // 审核通过的视频数量,为 0 时省略 + Covers []string `json:"covers"` // 顶部封面,最多 3 张 + TotalCount int64 `json:"totalCount"` // 专题内符合条件的内容总数 + PageSize int `json:"pageSize"` // 每页条数 + PageNum int `json:"pageNum"` // 当前页码 + List any `json:"list"` // 当前页内容,dataType 为 1 时是短剧,为 2 时是视频 + MoreTopics []*HomeTopicData `json:"moreTopics"` // 同数据类型的其他专题,最多 3 个 + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // 专题 SEO 信息 + Advitise AdvitiseData `json:"advitise"` // 广告配置 +} diff --git a/app/api/proto/questionnaire.go b/app/api/proto/questionnaire.go new file mode 100644 index 0000000..87c88bc --- /dev/null +++ b/app/api/proto/questionnaire.go @@ -0,0 +1,19 @@ +package proto + +import "videoweb/pub/models/questionnairemod" + +// QuestionnaireSubmitReq App端提交问卷请求 +type QuestionnaireSubmitReq struct { + UserId int64 `json:"userId"` // 登录用户ID,0表示匿名(选填) + QuestionType string `json:"questionType" binding:"required"` // 问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题 + Frequency string `json:"frequency" binding:"required"` // 出现频率:每次都会、经常出现、偶尔出现、仅现一次 + Province string `json:"province" binding:"required"` // 所在省份(必填) + City string `json:"city"` // 所在城市(选填) + Operator string `json:"operator" binding:"required"` // 运营商:电信、联通、移动、广电 + NetworkType string `json:"networkType" binding:"required"` // 网络类型:WI-FI、5G、4G、其他 + DeviceType string `json:"deviceType" binding:"required"` // 设备类型:iPhone、Android、iPad、其他 + DeviceModel string `json:"deviceModel"` // 设备型号(选填) + Description string `json:"description" binding:"required"` // 问题描述(必填) + Contact string `json:"contact"` // 联系方式(选填) + Attachments []questionnairemod.Attachment `json:"attachments"` // 附件列表,最多3个(选填) +} diff --git a/app/api/proto/richtext.go b/app/api/proto/richtext.go new file mode 100644 index 0000000..bdb8050 --- /dev/null +++ b/app/api/proto/richtext.go @@ -0,0 +1,24 @@ +package proto + +import "videoweb/pub/pubdef" + +type RichTextInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 标题 + Content string `json:"content" bson:"content"` // 内容 + Desc string `json:"desc" bson:"desc"` // 标题(app) + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 +} + +// AppRichTextItemResp APP富文本内容列表项 +type AppRichTextItemResp struct { + Id int64 `json:"id"` // 富文本ID + Title string `json:"title"` // 标题 + Content string `json:"content"` // 内容 + Desc string `json:"desc"` // 标题(app) +} + +// AppRichTextListResp APP富文本内容列表响应 +type AppRichTextListResp struct { + List []AppRichTextItemResp `json:"list"` // 富文本内容列表 +} diff --git a/app/api/proto/search.go b/app/api/proto/search.go new file mode 100644 index 0000000..18cb50f --- /dev/null +++ b/app/api/proto/search.go @@ -0,0 +1,82 @@ +package proto + +type SearchSortType string + +const ( + SearchSortTypeDefault SearchSortType = "default" // 默认 相似度 + SearchSortTypeHot SearchSortType = "hot" // 热度排序 + SearchSortTypeNew SearchSortType = "new" // 最新排序 +) + +// RankType 排行榜类型 +type RankType int64 + +const ( + RankType_Video = 1 // 视频 + RankType_Actress = 2 // 女优 + RankType_Filmreview = 3 // 影评 +) + +// RankPeriod 排行榜周期 +type RankPeriod int64 + +const ( + RankPeriod_Week RankPeriod = 1 + RankPeriod_Month RankPeriod = 2 + RankPeriod_Year RankPeriod = 3 +) + +type SearchRankHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + VideoWeek SearchRankData `json:"videoWeek"` + ActressWeek SearchRankData `json:"actressWeek"` + FilmreviewWeek SearchRankData `json:"filmreviewWeek"` + VideoMonth SearchRankData `json:"videoMonth"` + ActressMonth SearchRankData `json:"actressMonth"` + FilmreviewMonth SearchRankData `json:"filmreviewMonth"` + VideoYear SearchRankData `json:"videoYear"` + ActressYear SearchRankData `json:"actressYear"` + FilmreviewYear SearchRankData `json:"filmreviewYear"` +} +type SearchRankData struct { + RankName string `json:"rankName" form:"rankName"` // 排行榜名字 + RankType RankType `json:"rankType" form:"rankType"` // 排行榜类型 1视频, 2女优 ,3影评 + RankPeriod RankPeriod `json:"rankPeriod" form:"rankPeriod"` // 排行榜周期 1=周 2=月 3=年 + List []HotSearchItem `json:"list" form:"list"` +} + +type HotSearchItem struct { + Id int64 `json:"id" form:"id"` // ID + Title string `json:"title" form:"title"` //标题 + HotValue int64 `json:"hotValue" form:"hotValue"` // 热度值 +} + +type SearchResp struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + InTitle string `json:"inTitle"` //输入标题 + InType int64 `json:"inType"` // 输入类型 -1=全部 0=视频 6=短剧 + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Sort []SearchSortItem `json:"sorts"` // 排序选项 + SearchRecommend []string `json:"searchRecommend"` // 搜索推荐 + Media []VideoInfoData `json:"media" form:"media"` // 视频资源 + Playlets []PlayletData `json:"playlets" form:"playlets"` // 短剧资源 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 +} + +type SearchSortItem struct { + Key string `json:"key"` // 值 + Title string `json:"title"` // 标题 +} diff --git a/app/api/proto/sitemap.go b/app/api/proto/sitemap.go new file mode 100644 index 0000000..d9b13c3 --- /dev/null +++ b/app/api/proto/sitemap.go @@ -0,0 +1,18 @@ +package proto + +import ( + "encoding/xml" + "time" +) + +type URL struct { + Loc string `xml:"loc"` + LastMod time.Time `xml:"lastmod"` + ChangeFreq string `xml:"changefreq"` // always/hourly/daily/weekly/monthly/yearly/never + Priority float32 `xml:"priority"` // 0.0 - 1.0 +} +type Sitemap struct { + XMLName xml.Name `xml:"urlset"` + Xmlns string `xml:"xmlns,attr"` + URLs []URL `xml:"url"` +} diff --git a/app/api/proto/translation.go b/app/api/proto/translation.go new file mode 100644 index 0000000..5f49de0 --- /dev/null +++ b/app/api/proto/translation.go @@ -0,0 +1,25 @@ +package proto + +import "videoweb/pub/pubdef" + +type AppTranslationInfo struct { + CategoryList []TranslationCategory `json:"categoryList" form:"categoryList"` +} + +type TranslationCategory struct { + ID int64 `json:"id"` + Name string `json:"name" bson:"name"` // 名称 + Icon string `json:"icon" bson:"icon"` // Icon + TranslationList []TranslationInfo `json:"translationList" form:"translationList"` + UrlValue string `json:"urlValue" bson:"urlValue"` // URl路径 + CategoryType pubdef.TranslationType `json:"categoryType"` // 导航类型 +} + +type TranslationInfo struct { + ID int64 `json:"id"` // ID + Name string `json:"name" bson:"name"` // 名称 + IsFriendAds bool `json:"isFriendAds"` // 是否是友链广告位 + UrlValue string `json:"urlValue" bson:"urlValue"` // URl路径 + TransCategoryId int64 `json:"-"` // 导航分类 + DisplayMode pubdef.DisplayMode `json:"displayMode"` // 展示方式 +} diff --git a/app/api/proto/video.go b/app/api/proto/video.go new file mode 100644 index 0000000..ac71f51 --- /dev/null +++ b/app/api/proto/video.go @@ -0,0 +1,522 @@ +package proto + +import ( + "time" + "videoweb/pub/common/netutil" + "videoweb/pub/common/response" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +// latestTime 返回两个时间中较大的那个 +func latestTime(a, b time.Time) time.Time { + if b.After(a) { + return b + } + return a +} + +type TagsInfoData struct { + Id int64 `json:"id" ` + Name string `json:"name"` // 帖子标题 + Slug string `json:"slug"` // slug + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Works int64 `json:"works" bson:"works,omitempty"` // 作品数量 + Images string `json:"images" bson:"images,omitempty"` // 背景 +} +type CollectionInfoData struct { + Id int64 `json:"id" ` + Name string `json:"name"` // 帖子标题 +} +type VideoType int64 + +const ( + VideoTypeVideo VideoType = 0 // 视频 + VideoTypeCollection VideoType = 1 // 合集 + VideoTypeAdvitise VideoType = 2 // 广告 + VideoType_AV VideoType = 3 // AV视频 +) + +type VideoInfoData struct { + Id int64 `json:"id"` + Type VideoType `json:"type"` // 类型 + Title string `json:"title"` // 帖子标题 + Slug string `json:"slug"` // slug + Keyworld []string `json:"keyworld"` // 关键字 + AddedTime time.Time `json:"addedTime"` // 帖子上架时间 + CreateInfo PostCreateInfo `json:"createInfo"` // 作者信息 + CoverImg string `json:"coverImg"` // 封面 + // Nodes []videoinformationmod.Node `json:"nodes"` // 节点 + Video videoinformationmod.VideoInfo `json:"video"` // 视频 + Tags []TagsInfoData `json:"tags"` // 标签 + IsHot bool `json:"isHot"` // 是否热门 + AdvitiseId string `json:"advitiseId"` // 广告Id + CollectionInfo []VideoInfoData `json:"collectionInfo"` // 合集数据 + Link string `json:"link"` // 跳转链接 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 观看次数 + LikeNum int64 `json:"likeNum,omitempty"` // 点赞展示数 + CountCollect int64 `json:"countCollect,omitempty"` // 收藏展示数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + Location int64 `json:"location" bson:"location"` // 广告位置id + ActressInfo []ActressInfoData `json:"actressInfo" bson:"actressInfo"` // 女优信息 + IssuedAt time.Time `json:"issuedAt" bson:"issuedAt"` // 发行时间 + IssuedBrand string `json:"issuedBrand" bson:"issuedBrand"` // 发行商 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Bango string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 + UpdatedAt time.Time `json:"updatedAt"` // 页面更新时间 + PreviewVtt string `json:"previewVtt,omitempty"` // 雪碧图 vtt +} + +// 面包屑 +type BreadcrumbItem struct { + Name string `json:"name"` // 名称 + Url string `json:"url"` // 跳转链接 +} + +// 详情页导航 +type DetailNavItem struct { + Name string `json:"name"` // 导航名称 + Url string `json:"url"` // 导航链接 +} + +type VideoHistoryInfo struct { + Id int64 `json:"id"` + Title string `json:"title"` // 帖子标题 + AddedTime time.Time `json:"addedTime"` // 上架时间 +} + +type VideoCategoryInfo struct { + Id int64 `json:"id" ` + Name string `json:"name"` + Slug string `json:"slug"` // slug + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Title string `json:"title" bson:"title"` // APP显示 + Desc string `json:"desc" bson:"desc"` // 描述 +} + +// VideoCategoryListResp 已启用视频分类列表响应 +type VideoCategoryListResp struct { + List []VideoCategoryListItem `json:"list"` // 全部已启用视频分类 +} + +// VideoCategoryListItem 视频分类列表项 +type VideoCategoryListItem struct { + Id int64 `json:"id"` // 分类ID + Name string `json:"name"` // 分类名称 + Slug string `json:"slug"` // 分类slug + Title string `json:"title"` // APP展示标题 + Desc string `json:"desc"` // 分类描述 + Rank int64 `json:"rank"` // 排序值,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // SEO信息 +} + +type VideoPlayReq struct { + VideoId int64 `json:"videoId" form:"videoId"` // 视频ID +} + +type VideoPlayResp struct { + AuthKey string `json:"authKey"` +} + +type VideoCreaterInfo struct { + Id int64 `json:"id" ` + Name string `json:"name"` + Slug string `json:"slug"` // slug + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 +} + +// QueryVideoReq 根据分类id查询视频列表请求 +type QueryVideoReq struct { + pubdef.Page + // 视频分类id + CategoryId int64 `json:"categoryId" form:"categoryId"` + // 分类slug,非空时优先按slug解析分类ID + CategorySlug string `json:"categorySlug" form:"categorySlug"` +} + +// QueryVideoItemType 视频更多列表项类型 +type QueryVideoItemType int64 + +const ( + // QueryVideoItemTypeVideo 表示视频数据 + QueryVideoItemTypeVideo QueryVideoItemType = 0 + // QueryVideoItemTypeAdvertise 表示广告数据 + QueryVideoItemTypeAdvertise QueryVideoItemType = 2 +) + +// QueryVideoResp 视频更多分页响应 +type QueryVideoResp struct { + response.BasePageResp + // 当前分类信息(含 SEO) + CategoryInfo VideoCategoryInfo `json:"categoryInfo"` + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` +} + +// QueryVideoListItem 视频更多列表项,type=0时返回视频信息,type=2时返回广告信息。 +type QueryVideoListItem struct { + // 数据类型:0-视频数据、2-广告 + Type QueryVideoItemType `json:"type"` + // 广告ID,type=2时返回 + AdvertiseId string `json:"advertiseId"` + // 广告跳转链接,type=2时返回 + Link string `json:"link"` + // 广告位置ID,type=2时返回 + Location int64 `json:"location"` + VideoInfo +} + +// VideoRecommendListReq 推荐视频分页请求 +type VideoRecommendListReq struct { + pubdef.Page + // 分类id,不传查询全部分类id + CategoryId int64 `form:"categoryId" json:"categoryId"` + // 分类slug,非空时优先按slug解析分类ID + CategorySlug string `form:"categorySlug" json:"categorySlug"` + // 标签ID,不传查询全部标签 + TagId int64 `form:"tagId" json:"tagId"` + // 标签slug,非空时优先按slug解析标签ID + TagSlug string `form:"tagSlug" json:"tagSlug"` + // 排序方式 1-推荐 2-最新 3-最热 + SortList int64 `form:"sortList" json:"sortList"` +} + +// VideoListReq 上架视频分页请求 +type VideoListReq struct { + pubdef.Page + CategoryId int64 `form:"categoryId" json:"categoryId"` // 视频分类 ID,不传查询全部分类 + SortType int64 `form:"sortType" json:"sortType"` // 排序方式,1 热度优先,2 最近更新,默认 1 +} + +// VideoListResp 视频与广告混排分页响应 +type VideoListResp struct { + response.BasePageResp + Advitise AdvitiseData `json:"advitise"` // 完整广告配置 +} + +// VideoRecommendSort 推荐视频排序方式 +type VideoRecommendSort int64 + +const ( + VideoRecommendSortRecommend VideoRecommendSort = 1 // 推荐 后台配置 isRecommend=true + VideoRecommendSortNewest VideoRecommendSort = 2 // 最新 按视频最后更新时间从近到远排序 + VideoRecommendSortHot VideoRecommendSort = 3 // 最热 按视频累计播放量从高到低排序 +) + +// VideoRecommendItemType 推荐视频列表项类型 +type VideoRecommendItemType int64 + +const ( + VideoRecommendItemTypeVideo VideoRecommendItemType = 0 // 视频数据 + VideoRecommendItemTypeAdvertise VideoRecommendItemType = 2 // 广告数据 +) + +// VideoRecommendListResp 推荐视频分页响应 +type VideoRecommendListResp struct { + response.BasePageResp +} + +// VideoGuessListReq 猜你喜欢视频分页请求 +type VideoGuessListReq struct { + pubdef.Page + Id int64 `form:"id" json:"id"` // 当前视频ID,可选,传入时排除该视频 +} + +// VideoGuessListResp 猜你喜欢视频分页响应 +type VideoGuessListResp struct { + response.BasePageResp +} + +// VideoRecommendListItem 推荐视频列表项 +type VideoRecommendListItem struct { + Type VideoRecommendItemType `json:"type"` // 数据类型 0-视频数据 2-广告 + AdvertiseId string `json:"advertiseId"` // 广告ID,type=2时返回 + Link string `json:"link"` // 广告跳转链接,type=2时返回 + Location int64 `json:"location"` // 广告位置ID,type=2时返回 + VideoInfo +} + +// FavoriteVideoReq 收藏或取消收藏视频请求 +type FavoriteVideoReq struct { + // ID + Id int64 `json:"id" binding:"required"` + // 是否收藏,true-收藏、false-取消收藏 + IsFavorite *bool `json:"isFavorite" binding:"required"` +} + +// FavoriteVideoResp 收藏或取消收藏视频响应 +type FavoriteVideoResp struct { + // ID + Id int64 `json:"id"` + // 是否收藏,true-收藏、false-取消收藏 + IsFavorite bool `json:"isFavorite"` +} + +// LikeVideoReq 点赞或取消点赞视频请求 +type LikeVideoReq struct { + // ID + Id int64 `json:"id" binding:"required"` + // 是否点赞,true-点赞、false-取消点赞 + IsLike *bool `json:"isLike" binding:"required"` +} + +// LikeVideoResp 点赞或取消点赞视频响应 +type LikeVideoResp struct { + // ID + Id int64 `json:"id"` + // 是否点赞,true-点赞、false-取消点赞 + IsLike bool `json:"isLike"` + // 点赞展示数 + LikeNum int64 `json:"likeNum"` +} + +// QueryLikeVideoListReq 当前用户点赞视频分页请求 +type QueryLikeVideoListReq struct { + pubdef.Page +} + +// QueryLikeVideoListResp 当前用户点赞视频分页响应 +type QueryLikeVideoListResp struct { + response.BasePageResp + Translations AppTranslationInfo `json:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` // 底部翻译 +} + +// VideoHistoryListReq 当前用户视频观看历史分页请求 +type VideoHistoryListReq struct { + pubdef.Page +} + +// VideoHistoryReportReq 播放成功后上报视频观看历史 +type VideoHistoryReportReq struct { + Id int64 `json:"id" binding:"required,gt=0"` // 视频 ID +} + +// VideoHistoryReportResp 视频观看历史上报响应 +type VideoHistoryReportResp struct { + WatchedAt time.Time `json:"watchedAt"` // 最近观看时间 +} + +// VideoHistoryListResp 当前用户视频观看历史分页响应 +type VideoHistoryListResp struct { + response.BasePageResp + Translations AppTranslationInfo `json:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` // 底部翻译 +} + +// VideoHistoryItem 视频观看历史卡片 +type VideoHistoryItem struct { + FavoriteVideoListItem + Desc string `json:"desc"` // 视频简介 + WatchedAt time.Time `json:"watchedAt"` // 最近观看时间 +} + +// VideoHistoryClearResp 清空视频观看历史响应 +type VideoHistoryClearResp struct { + DeletedCount int64 `json:"deletedCount"` // 删除记录数 +} + +// QueryFavoriteVideoListReq 当前登录用户收藏视频分页请求 +type QueryFavoriteVideoListReq struct { + pubdef.Page +} + +// QueryFavoriteVideoListResp 当前登录用户收藏视频分页响应 +type QueryFavoriteVideoListResp struct { + response.BasePageResp + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` +} + +// FavoriteVideoListItem 当前登录用户收藏视频列表项 +type FavoriteVideoListItem struct { + // id + Id int64 `json:"id"` + // 标题 + Title string `json:"title"` + // slug + Slug string `json:"slug"` + // 上架时间 + AddedTime time.Time `json:"addedTime"` + // 封面 + CoverImg string `json:"coverImg"` + // 标签id + Tags []int64 `json:"tags"` + // 标签名称 + TagsName []string `json:"tagsName"` + // 标签slug 与tagsName顺序一致 + TagSlugs []string `json:"tagSlugs"` + // 观看展示数 + WatchNum int64 `json:"watchNum"` + // 点赞展示数 + LikeNum int64 `json:"likeNum"` + // 收藏展示数 + CountCollect int64 `json:"countCollect"` +} + +// VideoRankReq 视频排行榜分页请求 +type VideoRankReq struct { + pubdef.Page + // 榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜 + RankPeriod int64 `json:"rankPeriod" form:"rankPeriod"` +} + +// VideoRankPeriod 视频排行榜类型 +type VideoRankPeriod int64 + +const ( + VideoRankPeriodLike VideoRankPeriod = 0 // 点赞榜,按累计点赞量降序,最多50条,不足随机补位 + VideoRankPeriodWeek VideoRankPeriod = 1 // 周榜,按本周观看量降序,最多50条,不足随机补位 + VideoRankPeriodMonth VideoRankPeriod = 2 // 月榜,按本月观看量降序,最多50条,不足随机补位 + VideoRankPeriodYear VideoRankPeriod = 3 // 年榜,按本年观看量降序,最多50条,不足随机补位 +) + +// VideoRankResp 视频排行榜分页响应 +type VideoRankResp struct { + response.BasePageResp + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` +} + +// QueryVideoInfoReq 视频详情请求 +type QueryVideoInfoReq struct { + Id int64 `json:"id" form:"id"` + // 视频slug,非空时优先按slug解析视频ID + Slug string `json:"slug" form:"slug"` +} + +// QueryVideoInfoResp 视频详情响应 +type QueryVideoInfoResp struct { + // 当前路径 + Path string `json:"path"` + // 导航 + Translations AppTranslationInfo `json:"translations"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 底部翻译 + BottonTranslations AppTranslationInfo `json:"bottonTranslations"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 公告 + Announcement AnnouncementInfo `json:"announcement"` + + // 视频详情数据 + VideoInfo VideoDetailInfo `json:"videoInfo"` + // 当前登录用户是否已收藏该视频 + IsFavorite bool `json:"isFavorite"` + // 当前登录用户是否已点赞该视频 + IsLike bool `json:"isLike"` +} + +// VideoDetailInfo 视频详情数据 +type VideoDetailInfo struct { + // id + Id int64 `json:"id"` + // 标题 + Title string `json:"title"` + // slug + Slug string `json:"slug"` + // 封面 + CoverImg string `json:"coverImg"` + // 视频播放信息 + Video videoinformationmod.VideoInfo `json:"video"` + // 标签 + Tags []TagsInfoData `json:"tags"` + // 观看展示数 + WatchNum int64 `json:"watchNum"` + // 点赞展示数 + LikeNum int64 `json:"likeNum"` + // 收藏展示数 + CountCollect int64 `json:"countCollect"` + // 上架时间 + AddedTime time.Time `json:"addedTime"` + // 作品简介 + Desc string `json:"desc"` + // 当前登录用户是否已收藏该视频 + IsFavorite bool `json:"isFavorite"` + // 当前登录用户是否已点赞该视频 + IsLike bool `json:"isLike"` +} + +func VideoInfoToData(videoInfo *videoinformationmod.VideoInformation, imgCdn string, creatorInfo PostCreateInfo) VideoInfoData { + t := VideoTypeVideo + switch videoInfo.MediaType { + case videoinformationmod.MediaType_Video: + t = VideoTypeVideo + case videoinformationmod.MediaType_Collection: + t = VideoTypeCollection + case videoinformationmod.MediaType_Advitise: + t = VideoTypeAdvitise + case videoinformationmod.MediaType_AV: + t = VideoType_AV + } + videotemp := VideoInfoData{ + Id: videoInfo.Id, + Type: t, + Title: videoInfo.Title, + Slug: videoInfo.Slug, + Keyworld: videoInfo.Keyworld, + AddedTime: videoInfo.AddedTime, + CreateInfo: creatorInfo, + CoverImg: netutil.MergeURL(imgCdn, videoInfo.CoverImg), + // Nodes: make([]videoinformationmod.Node, 0), + Video: videoInfo.Video, + IsHot: videoInfo.IsHot, + Tags: make([]TagsInfoData, 0), + ShowTimes: videoInfo.Real.ShowTimes*100 + 6241, + WatchTimes: videoInfo.Real.WatchTimes*100 + 3152, + ActressInfo: make([]ActressInfoData, 0), + Desc: videoInfo.Desc, + Bango: videoInfo.Bango, + IssuedBrand: videoInfo.IssuedBrand, + IssuedAt: videoInfo.IssuedAt, + UpdatedAt: latestTime(videoInfo.UpdatedAt, videoInfo.PageUpdatedAt), + PreviewVtt: videoInfo.PreviewVtt, + } + if videoInfo.Video.Cover != "" { + videotemp.Video.Cover = netutil.MergeURL(imgCdn, videotemp.Video.Cover) + } + //for _, nodeInfo := range videoInfo.Nodes { + // nodeTmp := videoinformationmod.Node{ + // Images: make([]string, 0), + // Url: nodeInfo.Url, + // Text: nodeInfo.Text, + // Videos: make([]videoinformationmod.VideoInfo, 0), + // Title: nodeInfo.Title, + // } + // for _, image := range nodeInfo.Images { + // nodeTmp.Images = append(nodeTmp.Images, netutil.MergeURL(imgCdn, image)) + // } + // if nodeInfo.Videos != nil && len(nodeInfo.Videos) > 0 { + // for _, video := range nodeInfo.Videos { + // videoTmp := videoinformationmod.VideoInfo{ + // TaskId: video.TaskId, + // FileId: video.FileId, + // Url: netutil.MergeURL(imgCdn, video.Url), + // Cover: video.Cover, + // Height: video.Height, + // Width: video.Width, + // VideoTime: video.VideoTime, + // PreMp4FileNameFs: video.PreMp4FileNameFs, + // } + // nodeTmp.Videos = append(nodeTmp.Videos, videoTmp) + // } + // } + // videotemp. = append(videotemp.Nodes, nodeTmp) + //} + return videotemp +} + +// StripH265Url 清空主视频 H265 地址。 +func StripH265Url(data *VideoInfoData) { + if data == nil { + return + } + data.Video.H265Url = "" +} diff --git a/app/api/proto/webdata.go b/app/api/proto/webdata.go new file mode 100644 index 0000000..9fb442b --- /dev/null +++ b/app/api/proto/webdata.go @@ -0,0 +1,212 @@ +package proto + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +// HomeHtmlData 首页 +type HomeHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` // 请求路径 + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 +} + +type VideoCategoryData struct { + Id int64 `json:"id" form:"id"` + Name string `json:"name" form:"name"` + Slug string `json:"slug"` + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` +} + +type VideoCategoryHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + VideoCategory VideoCategoryData `json:"videoCategory" form:"videoCategory"` // 当前分类数据 +} + +type CategoryVideoListHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Translation TranslationInfo `json:"translation" form:"translation"` // 当前导航数据 + CategoryList []VideoCategoryData `json:"categoryList" form:"categoryList"` +} + +type VideoCategoryListHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Translation TranslationInfo `json:"translation" form:"translation"` // 当前导航数据 + CategoryList []VideoCategoryInfo `json:"categoryList" form:"categoryList"` + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` +} + +type VideoDetailsHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Video VideoInfoData `json:"video" form:"video"` // 视频详情 + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + CommentList GetCommentListResp `json:"commentList" form:"commentList"` // 评论 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜标签 + RecommendVideos []VideoInfoData `json:"recommendVideos" form:"recommendVideos"` // 推荐视频 + ChoiceVideos []VideoInfoData `json:"choiceVideos" form:"choiceVideos"` // 精选视频 + Breadcrumbs []BreadcrumbItem `json:"breadcrumbs" form:"breadcrumbs"` // 面包屑 + DetailNavs []DetailNavItem `json:"detailNavs" form:"detailNavs"` // 详情页导航 +} + +// CreateVideoListHtmlData 创作者视频列表 +type CreateVideoListHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + CreateInfo PostCreateInfo `json:"createInfo" bson:"createInfo"` // 创作者信息 + VideoList []VideoInfoData `json:"videoList" form:"videoList"` + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` +} +type PostCreateInfo struct { + Id int64 `json:"id" form:"id"` + Name string `json:"name" form:"name"` + Slug string `json:"slug"` // slug + Image string `json:"image" form:"image"` //头像 + Works int64 `json:"works" form:"works"` + CreateType int64 `json:"createType"` // 创作者类型 0=普通 1=官方 + Desc string `json:"desc" form:"desc"` // 作者描述 + Background string `json:"background"` // 背景 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 +} + +// TagHtmlData 首页 +type TagHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Media []VideoInfoData `json:"media" form:"media"` // 视频资源 + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Tag TagsInfoData `json:"tag" form:"tag"` // 标签 + SortItem []videoinformationmod.VideoSortItem `json:"sortItem" form:"sortItem"` // 排序项 + SortValue videoinformationmod.VideoSortItem `json:"sortValue" form:"sortValue"` // 当前排序项 + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` +} +type VideoTagHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Tags []TagsInfoData `json:"tags" form:"tags"` + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` +} + +type VideoHistoryHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + Tags []TagsInfoData `json:"tags" form:"tags"` // 标签 + Media []VideoHistoryInfo `json:"media" form:"media"` // 视频 + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` +} + +type RichTextHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 +} +type NotFoundHtmlData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 +} + +type UrlRedirectData struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + Translations AppTranslationInfo `json:"translations" form:"translations"` // 导航 + BottonTranslations AppTranslationInfo `json:"bottonTranslations" form:"bottonTranslations"` // 底部导航 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` // 公告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Category VideoCategoryInfo `json:"category" form:"category"` // 分类 + UrlRedirect string `json:"urlRedirect" form:"urlRedirect"` +} + +type ContactUsReq struct { + Name string `json:"name" form:"name"` // 姓名 + Email string `json:"email" form:"email"` // 邮件 + Content string `json:"content" form:"content"` // 内容 + CategoryName string `json:"categoryName" form:"categoryName"` // 主旨分类 +} + +// QuestionnaireHtmlData 问卷调查页 +type QuestionnaireHtmlData struct { + Path string `json:"path"` + Translation AppTranslationInfo `json:"translation" form:"translation"` + Config ConfigInfo `json:"config" form:"config"` + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 + Announcement AnnouncementInfo `json:"announcement" form:"announcement"` + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` +} diff --git a/app/api/questionnaireapi/page.go b/app/api/questionnaireapi/page.go new file mode 100644 index 0000000..ace5d15 --- /dev/null +++ b/app/api/questionnaireapi/page.go @@ -0,0 +1,27 @@ +package questionnaireapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/searchser" + "videoweb/app/service/translationser" + + "github.com/gin-gonic/gin" +) + +// Page 问卷调查页 +func Page(c *gin.Context) { + hotSearch := searchser.GetHotSearchItem(c) + data := proto.QuestionnaireHtmlData{ + Path: c.Request.URL.Path, + Translation: translationser.GetTranslationData(), + Config: configser.GetRequestConfig(c), + Advitise: configser.GetAdvitise(), + Announcement: configser.GetAnnouncement(), + HotSearch: hotSearch, + } + configser.RewriteImageCdn(c, &data) + c.HTML(200, "questionnaire", gin.H{ + "data": data, + }) +} diff --git a/app/api/questionnaireapi/questionnaire.go b/app/api/questionnaireapi/questionnaire.go new file mode 100644 index 0000000..50fa313 --- /dev/null +++ b/app/api/questionnaireapi/questionnaire.go @@ -0,0 +1,34 @@ +package questionnaireapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/questionnaireser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// Submit 提交问卷 +// @Tags 问卷调查APP +// @Summary 提交问卷 +// @Description 用户提交问题反馈问卷 +// @Accept json +// @Produce json +// @Param param body proto.QuestionnaireSubmitReq true "提交参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/questionnaire/submit [post] +func Submit(ctx *gin.Context) { + var req proto.QuestionnaireSubmitReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := questionnaireser.Submit(ctx, ctx.ClientIP(), &req) + if serr != nil { + common.ServerJSON(ctx, serr, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, nil) +} diff --git a/app/api/richtextapi/richtext.go b/app/api/richtextapi/richtext.go new file mode 100644 index 0000000..cc67ce1 --- /dev/null +++ b/app/api/richtextapi/richtext.go @@ -0,0 +1,36 @@ +package richtextapi + +import ( + "videoweb/app/service/richtextser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +type getByTitleReq struct { + Title string `form:"title" binding:"required"` +} + +// GetByTitle 按 title 查询富文本 +// @Tags 富文本 +// @Summary 按 title 查询富文本 +// @Description 从缓存中查询启用状态的富文本信息 +// @Produce json +// @Param title query string true "富文本内部标题" +// @Success 200 {object} proto.RichTextInfo "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/richtext [get] +func GetByTitle(ctx *gin.Context) { + var req getByTitleReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + data, serr := richtextser.GetByTitle(req.Title) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, data) +} diff --git a/app/api/searchapi/search.go b/app/api/searchapi/search.go new file mode 100644 index 0000000..0eb184f --- /dev/null +++ b/app/api/searchapi/search.go @@ -0,0 +1,53 @@ +package searchapi + +import ( + "fmt" + "strconv" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/searchser" + "videoweb/app/service/translationser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// SearchApi 搜索 +// @Tags 搜索 +// @Summary 搜索 +// @Description 搜索视频和短剧,省略 type 查询全部,type 为 0 查询视频,为 6 查询短剧,sort 支持 default/hot/new +// @Accept json +// @Produce json +// @Param title query string false "搜索关键词" +// @Param page query int false "页码,默认1" +// @Param type query int false "媒体类型,不传=全部,0=视频,6=短剧" +// @Param sort query string false "排序方式 default/hot/new,默认default" +// @Success 200 {object} proto.SearchResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /search [get] +func SearchApi(c *gin.Context) { + title := c.Query("title") + pageStr := c.DefaultQuery("page", "1") + sort := c.DefaultQuery("sort", string(proto.SearchSortTypeDefault)) + mediaType := int64(-1) + if typeStr, ok := c.GetQuery("type"); ok { + var err error + mediaType, err = strconv.ParseInt(typeStr, 10, 64) + if err != nil || (mediaType != 0 && mediaType != 6) { + common.ServerJSON(c, srvdef.ErrParamError, "type 只支持 0 或 6") + return + } + } + page, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil || page < 1 { + page = 1 + } + data := searchser.Search(c, title, mediaType, page, sort) + data.Path = c.Request.URL.Path + data.CompletePath = fmt.Sprintf("/search") + data.Advitise = configser.GetAdvitise() + data.BottonTranslations = translationser.GetTranslationBottomList() + data.Translations = translationser.GetTranslationData() + common.JSONWriterCache(c, srvdef.Success, data) +} diff --git a/app/api/searchapi/search_test.go b/app/api/searchapi/search_test.go new file mode 100644 index 0000000..58fd5ef --- /dev/null +++ b/app/api/searchapi/search_test.go @@ -0,0 +1,29 @@ +package searchapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +func TestSearchApiRejectsUnsupportedTypes(t *testing.T) { + for _, query := range []string{"type=1", "type=3", "type=2", "type=-1", "type=abc", "type="} { + writer := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(writer) + ctx.Request = httptest.NewRequest(http.MethodGet, "/search/?"+query, nil) + SearchApi(ctx) + var resp struct { + Code int64 `json:"code"` + } + if err := json.Unmarshal(writer.Body.Bytes(), &resp); err != nil { + t.Fatalf("参数 %s 的响应无法解析: %v", query, err) + } + if resp.Code != int64(srvdef.ErrParamError) { + t.Errorf("参数 %s 返回 code %d, want %d", query, resp.Code, srvdef.ErrParamError) + } + } +} diff --git a/app/api/uploadapi/upload.go b/app/api/uploadapi/upload.go new file mode 100644 index 0000000..9457670 --- /dev/null +++ b/app/api/uploadapi/upload.go @@ -0,0 +1,128 @@ +package uploadapi + +import ( + "net/http" + + "videoweb/app/service/uploadser" + "videoweb/pub/common" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// VideoBody 分片上传视频 +// @Tags 上传 +// @Summary 视频分片上传 +// @Description 将视频分片转发至存储服务 /media/web/autopublish/videoBody +// @Accept multipart/form-data +// @Produce json +// @Param taskId formData string true "视频任务ID" +// @Param pos formData string true "当前分片序号,从0开始" +// @Param uploadUserName formData string false "上传用户名,为空默认 questionnaires" +// @Param file formData file true "分片文件数据" +// @Success 200 {object} uploadser.VideoBodyResp "成功" +// @Failure 400 {string} string "参数错误" +// @Router /api/app/upload/videoBody [post] +func VideoBody(ctx *gin.Context) { + form, err := ctx.MultipartForm() + if err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + taskIds := form.Value["taskId"] + posList := form.Value["pos"] + uploadUserNames := form.Value["uploadUserName"] + fileList := form.File["file"] + + if len(taskIds) == 0 || taskIds[0] == "" { + common.ServerJSON(ctx, srvdef.ErrParamError, "taskId 不能为空") + return + } + if len(posList) == 0 || posList[0] == "" { + common.ServerJSON(ctx, srvdef.ErrParamError, "pos 不能为空") + return + } + if len(fileList) == 0 { + common.ServerJSON(ctx, srvdef.ErrParamError, "file 不能为空") + return + } + + param := &uploadser.VideoBodyParam{ + TaskId: taskIds[0], + Pos: posList[0], + FileHeader: fileList[0], + } + if len(uploadUserNames) > 0 { + param.UploadUserName = uploadUserNames[0] + } + + respBytes, err := uploadser.ForwardVideoBody(param) + if err != nil { + log.Error("VideoBody forward failed", log.E(err)) + common.ServerJSON(ctx, srvdef.ServerError, nil) + return + } + + ctx.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) +} + +// VideoOk 通知视频分片上传完成 +// @Tags 上传 +// @Summary 视频分片上传完成通知 +// @Description 所有分片上传完毕后调用,转发至存储服务 /media/web/autopublish/videoOk +// @Accept json +// @Produce json +// @Param param body object true "{ \"taskId\": \"...\" }" +// @Success 200 {object} uploadser.VideoOkResp "成功" +// @Failure 400 {string} string "参数错误" +// @Router /api/app/upload/videoOk [post] +func VideoOk(ctx *gin.Context) { + var req struct { + TaskId string `json:"taskId" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + respBytes, err := uploadser.ForwardVideoOk(req.TaskId) + if err != nil { + log.Error("VideoOk forward failed", log.E(err)) + common.ServerJSON(ctx, srvdef.ServerError, nil) + return + } + + ctx.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) +} + +// UploadImage 上传图片 +// @Tags 上传 +// @Summary 图片上传 +// @Description 将图片转发至存储服务 /media/web/autopublish/image +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "图片文件" +// @Success 200 {object} uploadser.ImageUploadResp "成功" +// @Failure 400 {string} string "参数错误" +// @Router /api/app/upload/image [post] +func UploadImage(ctx *gin.Context) { + f, err := ctx.FormFile("file") + if err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "file 不能为空") + return + } + + param := &uploadser.ImageParam{ + FileHeader: f, + } + respBytes, err := uploadser.ForwardImage(param) + if err != nil { + log.Error("UploadImage forward failed", log.E(err)) + common.ServerJSON(ctx, srvdef.ServerError, nil) + return + } + + ctx.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) +} diff --git a/app/api/videoapi/video.go b/app/api/videoapi/video.go new file mode 100644 index 0000000..59b520c --- /dev/null +++ b/app/api/videoapi/video.go @@ -0,0 +1,428 @@ +package videoapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/slugser" + "videoweb/app/service/translationser" + "videoweb/app/service/videoser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// List app端上架视频列表 +// @Tags 视频相关接口 +// @Summary app端上架视频列表 -- by 王鸣 +// @Description 按分类和排序分页查询上架视频,最近更新按更新时间降序,每五条视频后穿插一条广告 +// @Accept json +// @Produce json +// @Param params query proto.VideoListReq true "视频列表请求" +// @Success 200 {object} proto.VideoListResp{list=[]proto.VideoRecommendListItem} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/list [get] +func List(c *gin.Context) { + var req proto.VideoListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := videoser.VideoList(c, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// CategoryList 查询全部已启用视频分类 +// @Tags 视频相关接口 +// @Summary app端视频分类列表 +// @Produce json +// @Success 200 {object} proto.VideoCategoryListResp "成功" +// @Router /api/app/video/category/list [get] +func CategoryList(c *gin.Context) { + resp, err := videoser.VideoCategoryList(c) + if err != nil { + common.ServerJSON(c, err, err.GetMsg()) + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// RecommendList app端推荐视频列表 +// @Tags 视频相关接口 +// @Summary app端推荐视频列表 -- by 王鸣 +// @Description 分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告 +// @Accept json +// @Produce json +// @Param params query proto.VideoRecommendListReq true "推荐视频列表请求" +// @Success 200 {object} proto.VideoRecommendListResp{list=[]proto.VideoRecommendListItem} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/recommend/list [get] +func RecommendList(c *gin.Context) { + var req proto.VideoRecommendListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := videoser.VideoRecommendList(c, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// GuessList 分页查询猜你喜欢视频 +// @Tags 视频相关接口 +// @Summary 猜你喜欢视频列表 -- by 王鸣 +// @Description id可选,传入时排除当前视频,按展示观看量降序查询上架视频 +// @Produce json +// @Param params query proto.VideoGuessListReq true "猜你喜欢视频请求" +// @Success 200 {object} proto.VideoGuessListResp{list=[]proto.VideoRecommendListItem} "成功" +// @Router /api/app/video/guess/list [get] +func GuessList(c *gin.Context) { + var req proto.VideoGuessListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := videoser.VideoGuessList(c, &req) + if err != nil { + common.ServerJSON(c, err, err.GetMsg()) + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// TagList app端根据标签ID查询视频列表 +// @Tags 视频相关接口 +// @Summary app端根据标签ID查询视频列表 -- by 王鸣 +// @Description 根据统一标签ID分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告 +// @Accept json +// @Produce json +// @Param id path int true "标签ID" +// @Param pageNum query int false "页码,默认1" +// @Param pageSize query int false "每页数量,默认20,最大100" +// @Success 200 {object} proto.VideoRecommendListResp{list=[]proto.VideoRecommendListItem} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/tag/{id} [get] +func TagList(c *gin.Context) { + var req proto.ContentTagPageReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + pathID, pathSlug := slugser.ParseIDOrSlug(c.Param("id")) + if req.Id <= 0 { + req.Id = pathID + } + if req.Slug == "" { + req.Slug = pathSlug + } + data, err := videoser.VideoTagList(c, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// QueryVideoMore +// @Tags 视频相关接口 +// @Summary 根据分类id查询视频列表 --by 王鸣 +// @Description 根据分类id查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告 +// @Accept json +// @Produce json +// @Param params query proto.QueryVideoReq true "视频更多查询请求" +// @Success 200 {object} proto.QueryVideoResp{list=[]proto.QueryVideoListItem{VideoInfo=proto.VideoInfo}} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/more [get] +func QueryVideoMore(c *gin.Context) { + var req proto.QueryVideoReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := videoser.QueryVideoMore(c, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// QueryVideoInfo 视频详情 +// @Tags 视频相关接口 +// @Summary 视频详情 -- by 王鸣 +// @Description 查询视频详情、收藏状态和点赞状态 +// @Accept json +// @Produce json +// @Param params query proto.QueryVideoInfoReq true "视频详情查询请求" +// @Success 200 {object} proto.QueryVideoInfoResp{videoInfo=proto.VideoDetailInfo} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/info [get] +func QueryVideoInfo(c *gin.Context) { + var req proto.QueryVideoInfoReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := videoser.QueryVideoInfo(c, common.GetUserId(c), videoser.H5M3U8RequestIP(c), c.Request.UserAgent(), &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// FavoriteVideo 收藏或取消收藏视频 +// @Tags 视频相关接口 +// @Summary 收藏或取消收藏视频 --by 旺鸣 +// @Description 当前登录用户收藏或取消收藏指定视频,isFavorite=true表示收藏,isFavorite=false表示取消收藏 +// @Accept json +// @Produce json +// @Param param body proto.FavoriteVideoReq true "收藏或取消收藏请求" +// @Success 200 {object} proto.FavoriteVideoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/app/video/favorite [post] +func FavoriteVideo(c *gin.Context) { + var req proto.FavoriteVideoReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + userId := common.GetUserId(c) + if userId <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + ua, _ := common.GetUA(c) + if ua.UserAgent == "" { + ua.UserAgent = c.Request.UserAgent() + } + data, err := videoser.FavoriteVideo(c, userId, c.ClientIP(), ua, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// LikeVideo 点赞或取消点赞视频 +// @Tags 视频相关接口 +// @Summary 点赞或取消点赞视频 -- by 王鸣 +// @Description 当前登录用户点赞或取消点赞指定视频,isLike=true表示点赞,isLike=false表示取消点赞 +// @Accept json +// @Produce json +// @Param param body proto.LikeVideoReq true "点赞或取消点赞请求" +// @Success 200 {object} proto.LikeVideoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/app/video/like [post] +func LikeVideo(c *gin.Context) { + var req proto.LikeVideoReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + userId := common.GetUserId(c) + if userId <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + ua, _ := common.GetUA(c) + if ua.UserAgent == "" { + ua.UserAgent = c.Request.UserAgent() + } + data, err := videoser.LikeVideo(c, userId, c.ClientIP(), ua, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// QueryVideoRank 视频收藏排行榜 +// @Tags 视频相关接口 +// @Summary 视频收藏排行榜 +// @Description 视频收藏排行榜 +// @Accept json +// @Produce json +// @Success 200 {object} proto.RankResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/rank [get] +func QueryVideoRank(c *gin.Context) { + rank := proto.HomeRankData{ + Total: configser.GetVideoRankList(configser.RankPeriodTotal, 0), + Week: configser.GetVideoRankList(configser.RankPeriodWeek, 0), + Month: configser.GetVideoRankList(configser.RankPeriodMonth, 0), + Year: configser.GetVideoRankList(configser.RankPeriodYear, 0), + } + common.ServerJSON(c, srvdef.Success, proto.RankResp{ + RankData: rank, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + }) +} + +// QueryVideoRankList 视频排行榜分页列表 +// @Tags 视频相关接口 +// @Summary 视频排行榜分页列表 -- by 王鸣 +// @Description 分页查询视频排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50 +// @Accept json +// @Produce json +// @Param params query proto.VideoRankReq true "视频排行榜分页请求" +// @Success 200 {object} proto.VideoRankResp{list=[]proto.ContentData} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/video/rank/list [get] +func QueryVideoRankList(c *gin.Context) { + var req proto.VideoRankReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + data, err := videoser.QueryVideoRank(c, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// QueryFavoriteVideoList 当前登录用户收藏视频分页列表 +// @Tags 视频相关接口 +// @Summary 当前登录用户收藏视频分页列表 --by 旺鸣 +// @Description 当前登录用户收藏视频分页列表,按收藏时间倒序排序 +// @Accept json +// @Produce json +// @Param params query proto.QueryFavoriteVideoListReq true "收藏视频分页请求" +// @Success 200 {object} proto.QueryFavoriteVideoListResp{list=[]proto.FavoriteVideoListItem} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/app/video/favorite/list [get] +func QueryFavoriteVideoList(c *gin.Context) { + var req proto.QueryFavoriteVideoListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + userId := common.GetUserId(c) + if userId <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + data, err := videoser.QueryFavoriteVideoList(c, userId, &req) + if err != nil { + common.ServerJSON(c, err, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +// QueryLikeVideoList 当前用户点赞视频分页列表 +// @Tags 视频相关接口 +// @Summary 当前用户点赞视频分页列表 +// @Description 按点赞时间倒序返回当前用户点赞的上架视频 +// @Produce json +// @Param params query proto.QueryLikeVideoListReq true "点赞视频分页请求" +// @Success 200 {object} proto.QueryLikeVideoListResp{list=[]proto.FavoriteVideoListItem} "成功" +// @Security ApiKeyAuth +// @Router /api/app/video/like/list [get] +func QueryLikeVideoList(c *gin.Context) { + var req proto.QueryLikeVideoListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + userID := common.GetUserId(c) + if userID <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + resp, err := videoser.QueryLikeVideoList(c, userID, &req) + if err != nil { + common.ServerJSON(c, err, err.GetMsg()) + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// ReportVideoHistory 播放成功后记录当前用户的视频 +// @Tags 视频相关接口 +// @Summary 上报视频观看历史 +// @Accept json +// @Produce json +// @Param param body proto.VideoHistoryReportReq true "当前播放视频" +// @Success 200 {object} proto.VideoHistoryReportResp "成功" +// @Security ApiKeyAuth +// @Router /api/app/video/history [post] +func ReportVideoHistory(c *gin.Context) { + userID := common.GetUserId(c) + if userID <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + var req proto.VideoHistoryReportReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + resp, err := videoser.ReportVideoHistory(c, userID, &req) + if err != nil { + common.ServerJSON(c, err, err.GetMsg()) + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// QueryVideoHistoryList 当前用户视频观看历史分页列表 +// @Tags 视频相关接口 +// @Summary 视频观看历史列表 +// @Produce json +// @Param params query proto.VideoHistoryListReq true "分页请求" +// @Success 200 {object} proto.VideoHistoryListResp{list=[]proto.VideoHistoryItem} "成功" +// @Security ApiKeyAuth +// @Router /api/app/video/history/list [get] +func QueryVideoHistoryList(c *gin.Context) { + userID := common.GetUserId(c) + if userID <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + var req proto.VideoHistoryListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "") + return + } + resp, err := videoser.QueryVideoHistoryList(c, userID, &req) + if err != nil { + common.ServerJSON(c, err, err.GetMsg()) + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// ClearVideoHistory 清空当前用户视频观看历史 +// @Tags 视频相关接口 +// @Summary 清空视频观看历史 +// @Produce json +// @Success 200 {object} proto.VideoHistoryClearResp "成功" +// @Security ApiKeyAuth +// @Router /api/app/video/history [delete] +func ClearVideoHistory(c *gin.Context) { + userID := common.GetUserId(c) + if userID <= 0 { + common.ServerJSON(c, srvdef.InvalidToken, "") + return + } + resp, err := videoser.ClearVideoHistory(c, userID) + if err != nil { + common.ServerJSON(c, err, err.GetMsg()) + return + } + common.ServerJSON(c, srvdef.Success, resp) +} diff --git a/app/appg/appg.go b/app/appg/appg.go new file mode 100644 index 0000000..102dd2b --- /dev/null +++ b/app/appg/appg.go @@ -0,0 +1,125 @@ +package appg + +import ( + "videoweb/pub/common/ipaddr" + + "github.com/patrickmn/go-cache" +) + +const EsIndex = "sz" + +var ( + Conf GlobalCfgEx + Cache *cache.Cache + Name NameSource + DetectorV6 *ipaddr.XDBDetector + DetectorV4 *ipaddr.XDBDetector +) + +type NameSource struct { + FamilyNames []string `yaml:"familyNames"` + MiddleNames []string `yaml:"middleNames"` + AfterNames []string `yaml:"afterNames"` +} +type PictureSource struct { + Portrait []string `yaml:"portrait"` + UserbackGround []string `yaml:"userbackground"` + ActressBackground []string `yaml:"actressbackground"` +} +type GlobalCfgEx struct { + Redis struct { + Addr string `yaml:"addr"` + Db int `yaml:"db"` + Passwd string `yaml:"passwd"` + Subscribe bool `yaml:"subscribe"` + } + + Mongodb struct { + Addr string `yaml:"addr"` + LogAddr string `yaml:"logaddr"` + RecomAddr string `yaml:"recomaddr"` + AIOrderCenter string `yaml:"aiordercenter"` + } + Es struct { + Addr string `yaml:"addr"` + User string `yaml:"user"` + Pass string `yaml:"pass"` + } `yaml:"es"` + RecomSys struct { + Enable bool `yaml:"enable"` + Addr string `yaml:"addr"` + ApiKey string `yaml:"apikey"` + } `yaml:"recomsys"` + HttpOptions struct { + DcCtxTimeOut uint64 `yaml:"dcCtxTimeOut"` //代充http请求 超时ctx取消时间 + } `yaml:"httpOptions"` + Kafka struct { + Url string `yaml:"url"` + Group string `yaml:"group"` + } `yaml:"kafka"` + JwtKey string `yaml:"jwtKey"` + RunMod string `yaml:"runMod"` + ResKey string `yaml:"resKey"` + CdnKey string `yaml:"cdnKey"` + Pprof bool `yaml:"pprof"` + Master bool `yaml:"master"` //是否是主节点 + Host string `yaml:"host"` + ListenAddr string `yaml:"listenAddr"` + DHListenAddr string `yaml:"dhListenAddr"` + KfAppId string `yaml:"kfAppId"` + KfSecret string `yaml:"kfSecret"` + KfApiUrlPrefix string `yaml:"kfApiUrlPrefix"` + VersionUrl string `yaml:"versionUrl"` + KafkaUrl string `yaml:"kafkaUrl"` + CenterDomain string `yaml:"centerDomain"` + TgToken string `yaml:"tgToken"` + ReplayAttackConfig struct { + Enable bool `yaml:"enable"` + AppKey string `yaml:"appkey"` + GuestKey string `yaml:"guestKey"` + } `yaml:"attack"` + ReplayH5AttackConfig struct { + Enable bool `yaml:"enable"` + AppKey string `yaml:"appkey"` + GuestKey string `yaml:"guestKey"` + } `yaml:"h5attack"` + PayCenter struct { + Addr string `yaml:"addr"` + AppId int64 `yaml:"appId"` + Serect string `yaml:"serect"` + } `yaml:"payCenter"` + AiCenterAppId int64 `yaml:"aiCenterAppId"` + AiUndressingCallbackUrl string `yaml:"aiUndressingCallbackUrl"` + NavigateUrl string `yaml:"navigateUrl"` + NewProductUrl string `yaml:"newProductUrl"` + TransactionUrl string `yaml:"transactionUrl"` // 支付下单地址 + LocalWebUrl string `yaml:"localWebUrl"` + JituanProductUrl string `yaml:"jituanProductUrl"` + AiImageToVideoUrl string `yaml:"aiImageToVideoUrl"` // AI图生视频请求地址 + AiTextToImageUrl string `yaml:"aiTextToImageUrl"` // Ai文生图请求地址 + AiMateH5 string `yaml:"aiMateH5"` + OrigDomain string `yaml:"origDomain"` // M3U8下载地址 + CdnAuthKey string `yaml:"cdnAuthKey"` + TsAuthKey string `yaml:"tsAuthKey"` + LsjMediaConf struct { + AppId string `yaml:"appId"` + ApiKey string `yaml:"apiKey"` + ApiUrl string `yaml:"apiUrl"` + M3u8Yuan string `yaml:"m3u8Yuan"` + TsYuan string `yaml:"tsYuan"` + ImageYuan string `yaml:"imageYuan"` + DomainsRefreshHr int64 `yaml:"domainsRefreshHr"` + } `yaml:"lsjMediaConf"` + TrustedProxies string `yaml:"trustedProxies"` // 可信反向代理IP,多个用逗号分隔,空则不信任任何代理 + BucketVideoAddressV2 string `yaml:"bucketVideoAddressV2"` + BucketVideoAddress string `yaml:"bucketVideoAddress"` + AdCenter struct { + ApiDomain string `yaml:"apiDomain"` // api地址 如:https://adopenapi.adcs01.top + MerchantCode string `yaml:"merchantCode"` // 商户 code + AppCode string `yaml:"appCode"` // 应用 code + AesKey string `yaml:"aesKey"` // 数据解密KEY + } `yaml:"adCenter"` + DataReport struct { + Url string `yaml:"url"` + } `yaml:"dataReport"` +} diff --git a/app/appg/cache.go b/app/appg/cache.go new file mode 100644 index 0000000..0d70ba5 --- /dev/null +++ b/app/appg/cache.go @@ -0,0 +1,11 @@ +package appg + +import ( + "time" + + "github.com/patrickmn/go-cache" +) + +func InitCache() { + Cache = cache.New(5*time.Minute, 10*time.Minute) +} diff --git a/app/appg/helper.go b/app/appg/helper.go new file mode 100644 index 0000000..bfb2f85 --- /dev/null +++ b/app/appg/helper.go @@ -0,0 +1 @@ +package appg diff --git a/app/conf/app.yaml b/app/conf/app.yaml new file mode 100644 index 0000000..f2c8d06 --- /dev/null +++ b/app/conf/app.yaml @@ -0,0 +1,39 @@ +redis: + addr: "172.104.181.99:16789" + db: 3 + passwd: "B6vJHlYiTseLj77z" + subscribe: true +mongodb: + addr: "mongodb://admin:TESTwfeZYP0kjBdy8DAUdwU@172.104.181.99:57017,172.104.181.99:57018,172.104.181.99:57019/web_mdcom?replicaSet=yctest&maxPoolSize=200&connectTimeout=30&authSource=admin" + logaddr: "mongodb://admin:TESTwfeZYP0kjBdy8DAUdwU@172.104.181.99:57017,172.104.181.99:57018,172.104.181.99:57019/web_mdcom_log?replicaSet=yctest&maxPoolSize=200&connectTimeout=30&authSource=admin" +es: + addr: "http://172.104.181.99:9200" + user: "elastic" + pass: "admin123456" +httpOptions: + dcCtxTimeOut: 10 +jwtKey: "2311b15a4da1c80dc541b99abb622233" +runMod: "debug" +resKey: "vEukA&w15z4VAD3kAY#fkL#rBnU!WDhN" +cdnKey: "vEukAw15z4VAD3kAYfkLrBnUWDhN123" +tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN" +tgToken: "" +attack: + enable: false + appkey: "kaFtkDJRcchRMTI9" + h5key: "kaFttDJRcahRMTI7" + windowDurationSeconds: 60 +pprof: false +host: "172.104.181.99" +listenAddr: "25221" +origDomain: "https://yuan.bbaazz.me/" +# trustedProxies: "172.104.181.99" +trustedProxies: "" +bucketVideoAddressV2: "http://139.162.22.207:24996" +adCenter: + apiDomain: "https://adopenapi.adcs01.top" + merchantCode: "1991129808640122880" + appCode: "JHA-1009" + aesKey: "gD4+rmbX5TS5cLbb3wAN5uEAYp/1j4hkQrS9SzuSjc8" +dataReport: + url: "https://api.shuifeng.cc" \ No newline at end of file diff --git a/app/conf/ip2region_v4.xdb b/app/conf/ip2region_v4.xdb new file mode 100644 index 0000000..6f86c7d Binary files /dev/null and b/app/conf/ip2region_v4.xdb differ diff --git a/app/conf/ip2region_v6.xdb b/app/conf/ip2region_v6.xdb new file mode 100644 index 0000000..2e1dc6f Binary files /dev/null and b/app/conf/ip2region_v6.xdb differ diff --git a/app/conf/m3u8.key b/app/conf/m3u8.key new file mode 100644 index 0000000..6cce2a7 --- /dev/null +++ b/app/conf/m3u8.key @@ -0,0 +1 @@ +—�ˆȒ š ÁC�„ \ No newline at end of file diff --git a/app/conf/names.yaml b/app/conf/names.yaml new file mode 100644 index 0000000..328bff2 --- /dev/null +++ b/app/conf/names.yaml @@ -0,0 +1,2605 @@ +familyNames: [ + "王", + "李", + "张", + "刘", + "陈", + "杨", + "黄", + "吴", + "赵", + "周", + "徐", + "孙", + "马", + "朱", + "胡", + "林", + "郭", + "何", + "高", + "罗", + "郑", + "梁", + "谢", + "宋", + "唐", + "许", + "邓", + "冯", + "韩", + "曹", + "曾", + "彭", + "肖", + "蔡", + "潘", + "田", + "董", + "袁", + "于", + "余", + "蒋", + "叶", + "杜", + "苏", + "魏", + "程", + "吕", + "丁", + "沈", + "任", + "姚", + "卢", + "钟", + "姜", + "崔", + "谭", + "廖", + "范", + "汪", + "陆", + "金", + "石", + "戴", + "贾", + "韦", + "夏", + "邱", + "方", + "侯", + "邹", + "熊", + "孟", + "秦", + "白", + "毛", + "江", + "闫", + "薛", + "尹", + "付", + "段", + "雷", + "黎", + "史", + "龙", + "钱", + "贺", + "陶", + "顾", + "龚", + "郝", + "邵", + "万", + "严", + "洪", + "赖", + "武", + "傅", + "莫", + "孔", + "汤", + "向", + "常", + "温", + "康", + "施", + "文", + "牛", + "樊", + "葛", + "邢", + "安", + "齐", + "易", + "乔", + "伍", + "庞", + "颜", + "倪", + "庄", + "聂", + "章", + "鲁", + "岳", + "翟", + "申", + "殷", + "詹", + "欧", + "耿", + "关", + "覃", + "兰", + "焦", + "俞", + "左", + "柳", + "甘", + "祝", + "包", + "代", + "宁", + "符", + "阮", + "尚", + "舒", + "纪", + "柯", + "梅", + "童", + "毕", + "凌", + "单", + "季", + "成", + "霍", + "苗", + "裴", + "涂", + "谷", + "曲", + "盛", + "冉", + "翁", + "蓝", + "骆", + "路", + "游", + "靳", + "辛", + "欧阳", + "管", + "柴", + "蒙", + "鲍", + "华", + "喻", + "祁", + "房", + "蒲", + "滕", + "萧", + "屈", + "饶", + "解", + "牟", + "艾", + "尤", + "时", + "阳", + "阎", + "穆", + "应", + "农", + "司", + "古", + "吉", + "卓", + "车", + "简", + "连", + "缪", + "项", + "麦", + "褚", + "窦", + "娄", + "戚", + "岑", + "党", + "宫", + "景", + "卜", + "费", + "冷", + "晏", + "卫", + "席", + "柏", + "米", + "隋", + "宗", + "桂", + "瞿", + "全", + "苟", + "楼", + "闵", + "佟", + "臧", + "边", + "卞", + "姬", + "邬", + "和", + "师", + "仇", + "栾", + "丘", + "刁", + "沙", + "商", + "寇", + "荣", + "巫", + "郎", + "桑", + "丛", + "甄", + "敖", + "虞", + "仲", + "池", + "巩", + "明", + "佘", + "查", + "麻", + "苑", + "迟", + "邝", + "封", + "官", + "谈", + "鞠", + "匡", + "惠", + "荆", + "乐", + "冀", + "胥", + "郁", + "南", + "班", + "储", + "芦", + "原", + "栗", + "燕", + "楚", + "鄢", + "扬", + "劳", + "谌", + "奚", + "皮", + "蔺", + "粟", + "冼", + "盘", + "满", + "闻", + "厉", + "伊", + "候", + "仝", + "区", + "郜", + "海", + "阚", + "花", + "权", + "强", + "帅", + "屠", + "朴", + "盖", + "练", + "廉", + "禹", + "井", + "巴", + "漆", + "祖", + "丰", + "支", + "卿", + "国", + "狄", + "平", + "计", + "索", + "宣", + "晋", + "相", + "占", + "初", + "门", + "云", + "容", + "敬", + "来", + "扈", + "晁", + "都", + "芮", + "普", + "阙", + "戈", + "浦", + "伏", + "薄", + "鹿", + "邸", + "雍", + "辜", + "阿", + "羊", + "母", + "亓", + "裘", + "修", + "邰", + "杭", + "赫", + "况", + "那", + "宿", + "鲜", + "逯", + "印", + "隆", + "习", + "茹", + "诸", + "战", + "慕", + "危", + "玉", + "银", + "亢", + "公", + "嵇", + "豆", + "哈", + "湛", + "宾", + "勾", + "茅", + "戎", + "利", + "於", + "呼", + "居", + "干", + "揭", + "但", + "尉", + "斯", + "元", + "束", + "檀", + "衣", + "信", + "阴", + "展", + "昝", + "智", + "幸", + "奉", + "植", + "富", + "衡", + "昌", + "巢", + "郦", + "逄", + "寿", + "由", + "竺", + "贝", + "步", + "东", + "鄂", + "弓", + "过", + "钮", + "濮", + "水", + "蔚", + "郗", + "荀", + "贲", + "别", + "邴", + "才", + "操", + "畅", + "从", + "达", + "凡", + "凤", + "扶", + "贡", + "户", + "滑", + "郏", + "矫", + "经", + "靖", + "琚", + "巨", + "蒯", + "旷", + "历", + "励", + "伦", + "雒", + "茆", + "糜", + "宓", + "木", + "綦", + "谯", + "渠", + "山", + "生", + "侍", + "眭", + "腾", + "铁", + "宛", + "隗", + "沃", + "乌", + "毋", + "咸", + "忻", + "续", + "延", + "言", + "尧", + "英", + "鱼", + "庾", + "招", + "种", + "訾", + "宝", + "保", + "暴", + "闭", + "布", + "藏", + "承", + "福", + "果", + "黑", + "怀", + "宦", + "回", + "汲", + "籍", + "蹇", + "将", + "可", + "里", + "粱", + "冒", + "么", + "年", + "泮", + "郄", + "青", + "秋", + "释", + "双", + "税", + "潭", + "天", + "望", + "位", + "仵", + "香", + "刑", + "轩", + "玄", + "郇", + "要", + "宇", + "员", + "哀", + "爱", + "昂", + "熬", + "傲", + "奥", + "把", + "百", + "百里", + "摆", + "拜", + "邦", + "北", + "本", + "碧", + "薜", + "伯", + "博", + "补", + "部", + "采", + "彩", + "菜", + "仓", + "苍", + "曽", + "茶", + "产", + "苌", + "倡", + "唱", + "钞", + "超", + "朝", + "潮", + "沉", + "晨", + "呈", + "赤", + "敕", + "崇", + "瘳", + "丑", + "除", + "楮", + "揣", + "传", + "春", + "淳", + "淳于", + "啜", + "慈", + "次", + "爨", + "催", + "翠", + "寸", + "笪", + "答", + "丹", + "旦", + "淡", + "澹台", + "刀", + "道", + "德", + "迪", + "底", + "第", + "第五", + "佃", + "定", + "东方", + "冬", + "懂", + "钭", + "独孤", + "堵", + "端", + "端木", + "段干", + "敦", + "顿", + "多", + "朵", + "恩", + "尔", + "佴", + "法", + "番", + "藩", + "芳", + "飞", + "斐", + "风", + "酆", + "逢", + "俸", + "苻", + "洑", + "浮", + "甫", + "府", + "复", + "淦", + "刚", + "皋", + "杲", + "格", + "庚", + "公孙", + "公西", + "公羊", + "公冶", + "恭", + "拱", + "供", + "缑", + "菇", + "贯", + "冠", + "光", + "广", + "归", + "贵", + "呙", + "虢", + "还", + "寒", + "汉", + "行", + "蒿", + "好", + "昊", + "浩", + "禾", + "合", + "河", + "荷", + "赫连", + "恒", + "弘", + "红", + "宏", + "鸿", + "后", + "厚", + "呼延", + "忽", + "湖", + "虎", + "化", + "淮", + "槐", + "环", + "桓", + "皇", + "皇甫", + "辉", + "会", + "火", + "伙", + "基", + "稽", + "及", + "戢", + "记", + "继", + "暨", + "加", + "佳", + "家", + "嘉", + "荚", + "甲", + "菅", + "翦", + "见", + "建", + "剑", + "降", + "娇", + "徼", + "缴", + "教", + "接", + "节", + "杰", + "颉", + "介", + "京", + "静", + "酒", + "咎", + "菊", + "句", + "具", + "俱", + "剧", + "矍", + "军", + "君", + "俊", + "卡", + "开", + "凯", + "勘", + "堪", + "考", + "科", + "克", + "空", + "库", + "郐", + "奎", + "蒉", + "阔", + "喇", + "稂", + "朗", + "乐正", + "勒", + "蕾", + "类", + "冷狐", + "梨", + "礼", + "理", + "力", + "立", + "丽", + "莲", + "良", + "疗", + "寥", + "列", + "吝", + "泠", + "零", + "另", + "令", + "令狐", + "刘付", + "刘傅", + "留", + "庐", + "炉", + "禄", + "洛", + "闾", + "闾丘", + "侣", + "律", + "绿", + "买", + "曼", + "卯", + "枚", + "美", + "梦", + "弥", + "弭", + "秘", + "密", + "妙", + "闽", + "敏", + "名", + "谬", + "磨", + "墨", + "默", + "沐", + "牧", + "睦", + "慕容", + "纳", + "纳兰", + "娜", + "乃", + "南宫", + "南门", + "铙", + "能", + "尼", + "念", + "乜", + "甯", + "殴阳", + "鸥", + "偶", + "攀", + "朋", + "蓬", + "澎", + "骈", + "濮阳", + "溥", + "柒", + "亓官", + "其", + "奇", + "祈", + "耆", + "千", + "乾", + "潜", + "羌", + "钦", + "琴", + "勤", + "清", + "庆", + "求", + "璩", + "却", + "荛", + "绕", + "仁", + "日", + "融", + "如", + "汝", + "瑞", + "闰", + "润", + "若", + "撒", + "萨", + "赛", + "伞", + "森", + "厦", + "闪", + "陕", + "善", + "赏", + "上", + "上官", + "韶", + "卲", + "绍", + "厍", + "申屠", + "莘", + "神", + "甚", + "慎", + "升", + "绳", + "圣", + "诗", + "拾", + "矢", + "士", + "世", + "是", + "守", + "首", + "殳", + "书", + "疏", + "树", + "顺", + "司空", + "司寇", + "司马", + "司徒", + "思", + "松", + "送", + "素", + "睢", + "随", + "所", + "锁", + "塔", + "台", + "太", + "太史", + "太叔", + "泰", + "郯", + "塘", + "桃", + "淘", + "藤", + "提", + "遆", + "帖", + "通", + "同", + "徒", + "脱", + "陀", + "庹", + "拓", + "拓跋", + "完", + "完颜", + "万俟", + "旺", + "威", + "巍", + "伟", + "未", + "尉迟", + "闻人", + "问", + "瓮", + "巫马", + "无", + "吾", + "兀", + "西", + "西门", + "希", + "息", + "溪", + "袭", + "洗", + "喜", + "霞", + "夏侯", + "夏候", + "仙", + "先", + "鲜于", + "贤", + "线", + "湘", + "象", + "宵", + "箫", + "潇", + "晓", + "孝", + "校", + "星", + "兴", + "雄", + "吁", + "须", + "顼", + "绪", + "轩辕", + "禤", + "学", + "雪", + "寻", + "牙", + "雅", + "焉", + "岩", + "彦", + "艳", + "宴", + "洋", + "仰", + "养", + "样", + "幺", + "侥", + "药", + "冶", + "野", + "业", + "依", + "仪", + "夷", + "怡", + "宜", + "乙", + "蚁", + "弋", + "义", + "奕", + "羿", + "益", + "裔", + "翼", + "盈", + "营", + "永", + "勇", + "犹", + "油", + "友", + "有", + "迂", + "宇文", + "羽", + "遇", + "誉", + "毓", + "源", + "远", + "院", + "月", + "悦", + "越", + "允", + "运", + "恽", + "载", + "宰", + "迮", + "增", + "甑", + "粘", + "斩", + "长", + "长孙", + "仉", + "掌", + "钊", + "兆", + "肇", + "折", + "真", + "阵", + "镇", + "征", + "正", + "政", + "只", + "职", + "郅", + "治", + "中", + "忠", + "钟离", + "衷", + "仲孙", + "皱", + "邾", + "珠", + "诸葛", + "竹", + "主", + "颛孙", + "壮", + "禚", + "资", + "子", + "紫", + "自", + "字", + "宗政", + "纵", + "俎", + "左丘", + "佐" + ] +middleNames: [ + "小", + "晓", + "佳", + "海", + "嘉", + "云", + "雪", + "新", + "瑞", + "亚", + "丽", + "梦", + "雅", + "秀", + "欣", + "锦", + "梓", + "静", + "艳", + "若", + "慧", + "彦", + "艺", + "书", + "诗", + "淑", + "晨", + "凤", + "燕", + "怡", + "惠", + "乐", + "钰", + "利", + "敏", + "奕", + "悦", + "芳", + "鸿", + "丹", + "培", + "彩", + "颖", + "馨", + "琳", + "舒", + "雯", + "睿", + "宜", + "婉", + "香", + "亦", + "依", + "韵", + "如", + "玲", + "凡", + "素", + "巧", + "莉", + "洁", + "育", + "靖", + "宁", + "楚", + "卓", + "碧", + "翠", + "涵", + "倩", + "芝", + "珍", + "灵", + "语", + "凌", + "芸", + "妍", + "贞", + "笑", + "玮", + "煜", + "毓", + "婷", + "琦", + "琼", + "晶", + "仪", + "茗", + "昕", + "昱", + "真", + "曼", + "梅", + "虹", + "茜", + "璐", + "雁", + "菲", + "娅", + "聪", + "寒", + "沛", + "竹", + "欢", + "薇", + "莲", + "言", + "柔", + "瑾", + "勤", + "蓉", + "琪", + "娜", + "泓", + "莹", + "萍", + "瑜", + "焕", + "蕊", + "霞", + "娟", + "弘", + "晋", + "柳", + "枫", + "京", + "阳", + "珊", + "姿", + "滢", + "菁", + "群", + "岚", + "芬", + "映", + "霄", + "园", + "沁", + "朋", + "婕", + "蓓", + "岩", + "苑", + "琰", + "意", + "影", + "莎", + "荷", + "知", + "泳", + "妙", + "珠", + "迎", + "迪", + "忆", + "婵", + "念", + "娴", + "姣", + "宣", + "品", + "盈", + "烨", + "荔", + "芯", + "姗", + "越", + "歆", + "灿", + "珂", + "榕", + "曦", + "扬", + "洋", + "琛", + "容", + "轶", + "焱", + "晴", + "蕴", + "小", + "晓", + "佳", + "海", + "志", + "嘉", + "俊", + "云", + "新", + "瑞", + "宇", + "亚", + "伟", + "庆", + "欣", + "锦", + "梓", + "振", + "学", + "浩", + "若", + "宝", + "彦", + "艺", + "书", + "智", + "诗", + "旭", + "晨", + "乐", + "钰", + "承", + "铭", + "福", + "利", + "博", + "奕", + "树", + "悦", + "鸿", + "凯", + "培", + "睿", + "宜", + "恩", + "亦", + "健", + "胜", + "飞", + "远", + "如", + "鹏", + "凡", + "哲", + "育", + "富", + "靖", + "宁", + "朝", + "楚", + "卓", + "茂", + "杰", + "松", + "涵", + "耀", + "善", + "柏", + "超", + "昊", + "灵", + "勇", + "凌", + "炳", + "盛", + "裕", + "玮", + "煜", + "毓", + "琦", + "茗", + "昕", + "昱", + "毅", + "奇", + "皓", + "喜", + "聪", + "才", + "寒", + "沛", + "增", + "欢", + "言", + "炜", + "勤", + "辉", + "丰", + "琪", + "泓", + "锐", + "业", + "彬", + "焕", + "弘", + "跃", + "晋", + "枫", + "京", + "阳", + "同", + "翔", + "钧", + "群", + "添", + "泰", + "霄", + "亮", + "宸", + "朋", + "诚", + "腾", + "山", + "岩", + "斌", + "鸣", + "意", + "森", + "知", + "翰", + "泳", + "存", + "迎", + "迪", + "威", + "照", + "骏", + "峰", + "展", + "栋", + "念", + "豪", + "功", + "均", + "宣", + "品", + "晟", + "绪", + "波", + "瀚", + "烨", + "宪", + "航", + "帅", + "越", + "歆", + "峻", + "灿", + "岳", + "珂", + "榕", + "曦", + "泉", + "扬", + "洋", + "琛", + "轶", + "焱", + "涛", + "城", + "令", + "坚", + "升", + "夕", + "河", + "誉", + "祺", + "理", + "磊", + "蔚", + "小", + "晓", + "佳", + "海", + "志", + "嘉", + "俊", + "云", + "雪", + "新", + "瑞", + "宇", + "亚", + "丽", + "梦", + "伟", + "雅", + "庆", + "秀", + "欣", + "锦", + "梓", + "振", + "学", + "浩", + "静", + "艳", + "若", + "慧", + "宝", + "彦", + "艺", + "书", + "智", + "诗", + "淑", + "旭", + "晨", + "凤", + "燕", + "怡", + "惠", + "乐", + "钰", + "承", + "铭", + "福", + "利", + "敏", + "博", + "奕", + "树", + "悦", + "芳", + "鸿", + "凯", + "丹", + "培", + "彩", + "颖", + "馨", + "琳", + "舒", + "雯", + "睿", + "宜", + "恩", + "婉", + "香", + "亦", + "健", + "胜", + "飞", + "远", + "依", + "韵", + "如", + "鹏", + "玲", + "凡", + "素", + "巧", + "哲", + "莉", + "洁", + "育", + "富", + "靖", + "宁", + "朝", + "楚", + "卓", + "茂", + "碧", + "杰", + "翠", + "松", + "涵", + "耀", + "倩", + "善", + "柏", + "芝", + "珍", + "超", + "昊", + "灵", + "语", + "勇", + "凌", + "芸", + "妍", + "炳", + "贞", + "盛", + "笑", + "裕", + "玮", + "煜", + "毓", + "婷", + "琦", + "琼", + "晶", + "仪", + "茗", + "昕", + "昱", + "真", + "曼", + "毅", + "梅", + "虹", + "茜", + "奇", + "璐", + "皓", + "雁", + "菲", + "娅", + "喜", + "聪", + "才", + "寒", + "沛", + "竹", + "增", + "欢", + "薇", + "莲", + "言", + "炜", + "柔", + "瑾", + "勤", + "辉", + "丰", + "蓉", + "琪", + "娜", + "泓", + "莹", + "萍", + "瑜", + "锐", + "业", + "彬", + "焕", + "蕊", + "霞", + "娟", + "弘", + "跃", + "晋", + "柳", + "枫", + "京", + "阳", + "同", + "翔", + "钧", + "珊", + "姿", + "滢", + "菁", + "群", + "岚", + "芬", + "添", + "映", + "泰", + "霄", + "园", + "亮", + "宸", + "沁", + "朋", + "婕", + "诚", + "蓓", + "腾", + "山", + "岩", + "斌", + "苑", + "鸣", + "琰", + "意", + "森", + "影", + "莎", + "荷", + "知", + "翰", + "泳", + "妙", + "存", + "珠", + "迎", + "迪", + "威", + "忆", + "照", + "骏", + "婵", + "峰", + "展", + "栋", + "念", + "娴" + ] +afterNames: [ + "静", + "敏", + "丹", + "丽", + "燕", + "婷", + "娟", + "艳", + "芳", + "娜", + "玲", + "洋", + "洁", + "佳", + "琳", + "雪", + "阳", + "颖", + "莉", + "慧", + "萍", + "倩", + "宁", + "莹", + "霞", + "云", + "晶", + "欣", + "晨", + "欢", + "乐", + "璐", + "梅", + "瑞", + "琪", + "琦", + "岩", + "海", + "琼", + "迪", + "聪", + "菲", + "珊", + "薇", + "蓉", + "凡", + "茜", + "悦", + "雯", + "扬", + "睿", + "瑜", + "萌", + "妍", + "群", + "娇", + "玮", + "虹", + "凤", + "珍", + "梦", + "越", + "枫", + "怡", + "蕊", + "畅", + "芬", + "靖", + "惠", + "硕", + "柳", + "晴", + "莎", + "曦", + "昕", + "艺", + "烨", + "婕", + "朋", + "涵", + "瑾", + "灿", + "芸", + "岚", + "钰", + "嘉", + "曼", + "捷", + "彤", + "卓", + "珂", + "彦", + "真", + "菁", + "培", + "容", + "瑛", + "馨", + "灵", + "雅", + "鸿", + "园", + "凌", + "翠", + "琛", + "煜", + "锦", + "勤", + "霖", + "秀", + "蓓", + "香", + "寒", + "言", + "斐", + "奕", + "巧", + "焱", + "莲", + "蔚", + "姗", + "姣", + "雁", + "舒", + "娅", + "韵", + "琰", + "桐", + "昱", + "盈", + "婵", + "娴", + "竹", + "臻", + "媚", + "霄", + "仪", + "榕", + "贞", + "依", + "珠", + "滢", + "焕", + "婉", + "沁", + "弘", + "芝", + "语", + "淇", + "诗", + "茗", + "宜", + "彩", + "宣", + "素", + "毓", + "碧", + "云", + "芳", + "玲", + "萍", + "婷", + "丽", + "阳", + "梅", + "霞", + "敏", + "宁", + "琳", + "娟", + "燕", + "佳", + "欣", + "涵", + "慧", + "珍", + "海", + "娜", + "艳", + "静", + "雪", + "洁", + "瑞", + "颖", + "香", + "秀", + "琪", + "凤", + "洋", + "雅", + "丹", + "菲", + "怡", + "新", + "雯", + "琦", + "嘉", + "莹", + "芬", + "莉", + "乐", + "利", + "凡", + "晨", + "莲", + "蓉", + "仪", + "霖", + "睿", + "悦", + "馨", + "琼", + "妍", + "珊", + "彤", + "芝", + "芸", + "惠", + "倩", + "聪", + "瑜", + "群", + "灵", + "晓", + "薇", + "茜", + "玮", + "锦", + "欢", + "枫", + "钰", + "艺", + "岚", + "如", + "真", + "竹", + "宜", + "鸿", + "蕊", + "晶", + "梦", + "璐", + "珠", + "亚", + "柔", + "娴", + "翠", + "寒", + "虹", + "勤", + "娇", + "韵", + "贞", + "晴", + "茗", + "扬", + "婕", + "影", + "容", + "言", + "瑾", + "桐", + "迪", + "舒", + "蕾", + "淇", + "巧", + "岩", + "莎", + "彦", + "璇", + "滢", + "雁", + "园", + "朋", + "淑", + "彩", + "昕", + "萌", + "烨", + "菁", + "姣", + "榕", + "曼", + "语", + "书", + "娅", + "媚", + "蓓", + "婉", + "琛", + "曦", + "姿", + "毓", + "凌", + "姗", + "荷", + "婵", + "育", + "捷", + "煜", + "盈", + "越", + "琰", + "霄", + "依", + "碧", + "霜", + "斐", + "卓", + "培", + "灿", + "素", + "弘", + "畅", + "诗", + "宣", + "瑛", + "亭", + "苑", + "溪", + "荔", + "原", + "靖", + "臻", + "硕", + "若", + "茵", + "微", + "奕", + "意", + "珂", + "芯", + "铃", + "柳", + "淳", + "焱", + "伟", + "磊", + "超", + "杰", + "涛", + "鹏", + "勇", + "斌", + "飞", + "波", + "辉", + "亮", + "洋", + "浩", + "凯", + "俊", + "峰", + "佳", + "阳", + "健", + "宇", + "宁", + "云", + "旭", + "欣", + "彬", + "博", + "晨", + "欢", + "乐", + "毅", + "威", + "翔", + "瑞", + "琪", + "琦", + "岩", + "海", + "松", + "迪", + "聪", + "凡", + "悦", + "哲", + "昊", + "锐", + "扬", + "睿", + "群", + "玮", + "航", + "庆", + "越", + "枫", + "畅", + "森", + "炜", + "振", + "靖", + "奇", + "智", + "铭", + "栋", + "硕", + "胜", + "曦", + "昕", + "艺", + "烨", + "朋", + "涵", + "灿", + "诚", + "腾", + "钰", + "嘉", + "捷", + "卓", + "豪", + "远", + "宝", + "珂", + "彦", + "坚", + "培", + "灵", + "鸿", + "皓", + "骏", + "凌", + "昆", + "琛", + "煜", + "泉", + "跃", + "锦", + "勤", + "霖", + "鸣", + "耀", + "寒", + "言", + "旺", + "斐", + "城", + "奕", + "盛", + "焱", + "谦", + "渊", + "蔚", + "桐", + "昱", + "晟", + "岳", + "臻", + "霄", + "茂", + "喜", + "祺", + "榕", + "钧", + "恩", + "焕", + "富", + "弘", + "添", + "裕", + "淇", + "均", + "棋", + "瀚", + "宸", + "诗", + "茗", + "宜", + "宣", + "翰", + "柏", + "毓", + "杰", + "宇", + "云", + "伟", + "辉", + "峰", + "飞", + "阳", + "宁", + "波", + "涛", + "超", + "佳", + "斌", + "欣", + "鹏", + "涵", + "海", + "亮", + "锋", + "铭", + "瑞", + "彬", + "琪", + "洋", + "勇", + "新", + "浩", + "博", + "琦", + "俊", + "翔", + "嘉", + "豪", + "松", + "乐", + "利", + "凡", + "山", + "晨", + "哲", + "凯", + "霖", + "睿", + "悦", + "志", + "旭", + "奇", + "磊", + "聪", + "群", + "宝", + "灵", + "晓", + "胜", + "庆", + "玮", + "锦", + "福", + "毅", + "森", + "欢", + "枫", + "钰", + "艺", + "航", + "健", + "才", + "权", + "远", + "如", + "泉", + "宜", + "鸿", + "威", + "富", + "亚", + "诚", + "鸣", + "寒", + "智", + "城", + "勤", + "昊", + "茗", + "扬", + "丰", + "洲", + "栋", + "恩", + "言", + "桐", + "迪", + "淇", + "钧", + "岩", + "彦", + "盛", + "学", + "宸", + "旺", + "朋", + "喜", + "皓", + "昕", + "升", + "烨", + "炜", + "锐", + "榕", + "祺", + "帅", + "书", + "琛", + "曦", + "毓", + "耀", + "凌", + "坚", + "育", + "翰", + "均", + "骏", + "捷", + "煜", + "振", + "腾", + "越", + "霄", + "茂", + "财", + "棋", + "谦", + "河", + "斐", + "业", + "泰", + "裕", + "卓", + "培", + "灿", + "善", + "弘", + "树", + "畅", + "诗", + "钊", + "宣", + "亭", + "渊", + "溪", + "昆", + "原", + "靖", + "柏", + "臻", + "硕", + "若", + "奕", + "承", + "意", + "珂", + "通", + "瀚", + "朝", + "朗", + "韬", + "跃", + "淳", + "同", + "岳", + "焱", + "晟", + "蔚", + "昱", + "衡", + "泓", + "添", + "恺", + "功", + "林", + "龙", + "英", + "芳", + "红", + "丽", + "阳", + "宁", + "清", + "琳", + "涛", + "燕", + "春", + "青", + "艳", + "铭", + "雨", + "菲", + "新", + "雯", + "元", + "楠", + "莹", + "莉", + "萱", + "霖", + "辰", + "花", + "彤", + "群", + "茹", + "森", + "钰", + "岚", + "如", + "竹", + "蕊", + "鸣", + "翠", + "晴", + "虎", + "叶", + "蕾", + "莎", + "宸", + "杨", + "萌", + "菁", + "榕", + "雷", + "枝", + "蓓", + "霄", + "碧", + "泰", + "蓝", + "霏", + "树", + "莺", + "瑛", + "乔", + "原", + "芷", + "茵", + "卉", + "苗", + "桃", + "初", + "柳", + "木", + "苹", + "震", + "音", + "遥", + "柯", + "蒙", + "姝", + "菱", + "苏", + "征", + "樱", + "苇", + "庚", + "棠", + "迎", + "鹃", + "杏", + "苓", + "珑", + "煦", + "暖", + "澎", + "荟", + "霁", + "宵", + "朔", + "旦", + "浩", + "泽", + "洋", + "江", + "洪", + "涵", + "彦", + "源", + "蓉", + "莲", + "昊", + "风", + "水", + "薇", + "茜", + "虹", + "扬", + "川", + "润", + "泉", + "益", + "潇", + "淼", + "湘", + "夏", + "淇", + "禹", + "盈", + "沛", + "泓", + "童", + "滨", + "满", + "瀚", + "乾", + "鹤", + "菡", + "河", + "荷", + "溪", + "泳", + "津", + "韬", + "芙", + "玫", + "衍", + "研", + "沐", + "芊", + "淋", + "澄", + "池", + "潮", + "滔", + "陆", + "端", + "汐", + "游", + "漪", + "潼", + "泊", + "罡", + "淞", + "潞", + "汀", + "涓", + "宇", + "辉", + "萍", + "霞", + "荣", + "兰", + "庆", + "旭", + "秋", + "月", + "晨", + "星", + "琪", + "桂", + "香", + "翔", + "芬", + "琼", + "鸿", + "贵", + "恒", + "羽", + "娇", + "希", + "灵", + "耀", + "菊", + "露", + "朝", + "巧", + "熙", + "芸", + "丰", + "炜", + "皓", + "枫", + "煜", + "楚", + "烨", + "昭", + "灿", + "炳", + "爽", + "昱", + "影", + "桐", + "雁", + "晖", + "炎", + "焕", + "圆", + "祺", + "姣", + "硕", + "齐", + "定", + "婵", + "凝", + "焱", + "琰", + "照", + "果", + "桦", + "棋", + "霜", + "珏", + "晔", + "壮", + "申", + "驰", + "望", + "朗", + "衡", + "煌", + "栩", + "旻", + "烁", + "禾", + "实", + "鹰", + "皎", + "谷", + "烽", + "昶", + "骐", + "迅", + "嫦", + "穗", + "焰", + "耘", + "葵", + "荧", + "宙", + "曜", + "东", + "雪", + "梅", + "慧", + "琴", + "松", + "冰", + "坤", + "冬", + "银", + "栋", + "素", + "凌", + "柏", + "寒", + "坚", + "易", + "咏", + "白", + "严", + "玄" + ] diff --git a/app/deploy_prod.sh b/app/deploy_prod.sh new file mode 100755 index 0000000..5978860 --- /dev/null +++ b/app/deploy_prod.sh @@ -0,0 +1,11 @@ +#!/bin/bash +echo "开始" +rm -rf ./web_hghl_app ./web_hghl_app.tar.gz +GOOS=linux GOARCH=amd64 go build -o web_hghl_app +tar zvcf web_hghl_app.tar.gz web_hghl_app + + +scp web_hghl_app.tar.gz root@172.104.166.87:/data/uzip +ssh root@172.104.166.87 "/server/web_hghl/app/up_app.sh" + +rm -rf ./web_hghl_app ./web_hghl_app.tar.gz \ No newline at end of file diff --git a/app/docs/docs.go b/app/docs/docs.go new file mode 100644 index 0000000..dbdfca7 --- /dev/null +++ b/app/docs/docs.go @@ -0,0 +1,6740 @@ +// Package swag_app_docs Code generated by swaggo/swag. DO NOT EDIT +package swag_app_docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/app/actress/list": { + "get": { + "description": "返回导航、广告和按热度值降序排列的女优列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优相关接口" + ], + "summary": "女优榜单列表 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.QueryActressListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/actress/work/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "查询指定女优详情和作品列表,支持上架时间/观看量/收藏量升降序,每间隔8条作品数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优相关接口" + ], + "summary": "指定女优详情和作品列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "上架时间升序/降序(1-升序 2降序)", + "name": "addedTimeSort", + "in": "query" + }, + { + "type": "integer", + "description": "女优ID", + "name": "id", + "in": "query" + }, + { + "type": "integer", + "description": "按收藏(点赞)量升序/降序(1-升序 2降序)", + "name": "likesSort", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "女优slug,非空时优先按slug解析女优ID", + "name": "slug", + "in": "query" + }, + { + "type": "integer", + "description": "按观看量升序/降序(1-升序 2降序)", + "name": "watchTimesSort", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryActressWorkListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoListItem" + }, + { + "type": "object", + "properties": { + "VideoInfo": { + "$ref": "#/definitions/proto.VideoInfo" + } + } + } + ] + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/applist/list": { + "get": { + "description": "返回应用分类列表,每个分类下含应用信息列表;categoryId=0 返回第一个分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "应用列表" + ], + "summary": "查询应用列表", + "parameters": [ + { + "type": "integer", + "description": "当前分类ID,0表示默认第一个分类", + "name": "categoryId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/getInfo": { + "get": { + "description": "根据当前登录 token 查询用户基础信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/authser.UserInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/login": { + "post": { + "description": "账号均可作为登录凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "登录", + "parameters": [ + { + "description": "登录参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.loginReq" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回 token 及用户基本信息", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/profile": { + "post": { + "description": "账号必填;avatar、oldPassword+newPassword 均为可选,至少提供一项。修改密码时旧密码和新密码必须同时提供。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "修改用户资料(头像 / 密码,可任意组合)", + "parameters": [ + { + "description": "修改资料参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.updateProfileReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/register": { + "post": { + "description": "已登录游客传入账号密码完成升级;未登录用户直接注册新账号。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "注册", + "parameters": [ + { + "description": "注册参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.registerReq" + } + } + ], + "responses": { + "200": { + "description": "注册成功,返回 token 及用户基本信息", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/list": { + "get": { + "description": "按对象查询树形评论列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论APP" + ], + "summary": "评论列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "对象ID", + "name": "objectId", + "in": "query" + }, + { + "enum": [ + 0, + 4 + ], + "type": "integer", + "x-enum-comments": { + "CommentObjectTypePlaylet": "短剧", + "CommentObjectTypeVideo": "视频" + }, + "x-enum-varnames": [ + "CommentObjectTypeVideo", + "CommentObjectTypePlaylet" + ], + "description": "对象类型 0=视频 4=短剧", + "name": "objectType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.CommentListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "发布评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论APP" + ], + "summary": "发布评论 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.CommentSendReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/config/footer/info": { + "get": { + "description": "返回APP底部品牌、底部导航和联系方式", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app配置相关接口" + ], + "summary": "查询APP底部信息 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FooterInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/config/richtext/list": { + "get": { + "description": "返回启用状态的富文本内容,支持按ids或titles过滤", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app配置相关接口" + ], + "summary": "查询APP富文本内容列表 -- by 王鸣", + "parameters": [ + { + "type": "string", + "description": "富文本ID,多个用英文逗号分隔", + "name": "ids", + "in": "query" + }, + { + "type": "string", + "description": "富文本标题,多个用英文逗号分隔", + "name": "titles", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppRichTextListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/content/tag/list": { + "get": { + "description": "返回启用且有绑定内容数量的统一标签,contentTagType非必填,按绑定内容数量倒序排序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app配置相关接口" + ], + "summary": "查询APP统一标签列表 -- by 王鸣", + "parameters": [ + { + "enum": [ + 1, + 5 + ], + "type": "integer", + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ], + "description": "内容类型 1-视频 5-短剧,不传返回全部有绑定数量的标签", + "name": "contentTagType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppContentTagListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/home": { + "get": { + "description": "首页分类数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "首页" + ], + "summary": "首页 --by 旺鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.DataResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/home/base": { + "get": { + "description": "只返回翻译、配置、底部翻译、广告和公告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "首页" + ], + "summary": "首页公共数据 --by 旺鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeBaseResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/home/tags": { + "get": { + "description": "返回视频、短剧的标签信息,以及基础导航、配置、广告和公告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "首页" + ], + "summary": "首页标签数据 --by 旺鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeTagsResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/login/guest": { + "post": { + "description": "按设备 ID 查找已有游客账号,找不到则自动注册。若请求携带有效 token 则优先复用该用户。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "游客登录", + "parameters": [ + { + "description": "设备信息", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.guestLoginReq" + } + } + ], + "responses": { + "200": { + "description": "登录成功", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/navigation/header": { + "get": { + "description": "查询导航类型为URL模式和二级导航的头部导航配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app导航相关接口" + ], + "summary": "查询头部导航配置 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/info": { + "get": { + "description": "根据剧集ID查询短剧剧集详情、所属短剧、剧集列表、热门短剧列表和点赞状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集详情 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "剧集ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryPlayletChapterInfoResp" + }, + { + "type": "object", + "properties": { + "chapterInfo": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + }, + "chapterList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "hotPlayletList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "playletInfo": { + "$ref": "#/definitions/proto.PlayletData" + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/info": { + "get": { + "description": "查询短剧详情、剧集列表、热门短剧列表和点赞状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "短剧详情 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "slug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryPlayletInfoResp" + }, + { + "type": "object", + "properties": { + "chapterList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "hotPlayletList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "playletInfo": { + "$ref": "#/definitions/proto.PlayletDetailInfo" + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/like": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户点赞或取消点赞指定短剧,isLike=true表示点赞,isLike=false表示取消点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "点赞或取消点赞短剧 -- by 王鸣", + "parameters": [ + { + "description": "点赞或取消点赞请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.LikePlayletReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LikePlayletResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/rank/list": { + "get": { + "description": "分页查询短剧排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "短剧排行榜分页列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜", + "name": "rankPeriod", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRankResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletRankData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/recommend/list": { + "get": { + "description": "分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端推荐短剧列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧分类ID,不传查询全部分类", + "name": "categoryId", + "in": "query" + }, + { + "type": "string", + "description": "分类slug,非空时优先按slug解析分类ID", + "name": "categorySlug", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序方式 1-推荐 2-最新 3-最热", + "name": "sortList", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID,不传查询全部标签", + "name": "tagId", + "in": "query" + }, + { + "type": "string", + "description": "标签slug,非空时优先按slug解析标签ID", + "name": "tagSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/tag/{id}": { + "get": { + "description": "根据统一标签ID分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据标签ID查询短剧列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "标签ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认20,最大100", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/questionnaire/submit": { + "post": { + "description": "用户提交问题反馈问卷", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "问卷调查APP" + ], + "summary": "提交问卷", + "parameters": [ + { + "description": "提交参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.QuestionnaireSubmitReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/richtext": { + "get": { + "description": "从缓存中查询启用状态的富文本信息", + "produces": [ + "application/json" + ], + "tags": [ + "富文本" + ], + "summary": "按 title 查询富文本", + "parameters": [ + { + "type": "string", + "description": "富文本内部标题", + "name": "title", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.RichTextInfo" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/upload/image": { + "post": { + "description": "将图片转发至存储服务 /media/web/autopublish/image", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "上传" + ], + "summary": "图片上传", + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.ImageUploadResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/upload/videoBody": { + "post": { + "description": "将视频分片转发至存储服务 /media/web/autopublish/videoBody", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "上传" + ], + "summary": "视频分片上传", + "parameters": [ + { + "type": "string", + "description": "视频任务ID", + "name": "taskId", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "当前分片序号,从0开始", + "name": "pos", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "上传用户名,为空默认 questionnaires", + "name": "uploadUserName", + "in": "formData" + }, + { + "type": "file", + "description": "分片文件数据", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.VideoBodyResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/upload/videoOk": { + "post": { + "description": "所有分片上传完毕后调用,转发至存储服务 /media/web/autopublish/videoOk", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "上传" + ], + "summary": "视频分片上传完成通知", + "parameters": [ + { + "description": "{ \\", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.VideoOkResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/favorite": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户收藏或取消收藏指定视频,isFavorite=true表示收藏,isFavorite=false表示取消收藏", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "收藏或取消收藏视频 --by 旺鸣", + "parameters": [ + { + "description": "收藏或取消收藏请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.FavoriteVideoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FavoriteVideoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/favorite/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户收藏视频分页列表,按收藏时间倒序排序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "当前登录用户收藏视频分页列表 --by 旺鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryFavoriteVideoListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.FavoriteVideoListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/info": { + "get": { + "description": "查询视频详情、热门视频列表、收藏状态和点赞状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "视频详情 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "视频slug,非空时优先按slug解析视频ID", + "name": "slug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoInfoResp" + }, + { + "type": "object", + "properties": { + "hotVideoList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoData" + } + }, + "videoInfo": { + "$ref": "#/definitions/proto.VideoDetailInfo" + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/like": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户点赞或取消点赞指定视频,isLike=true表示点赞,isLike=false表示取消点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "点赞或取消点赞视频 -- by 王鸣", + "parameters": [ + { + "description": "点赞或取消点赞请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.LikeVideoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LikeVideoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/more": { + "get": { + "description": "根据分类id查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "根据分类id查询视频列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "视频分类id", + "name": "categoryId", + "in": "query" + }, + { + "type": "string", + "description": "分类slug,非空时优先按slug解析分类ID", + "name": "categorySlug", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoListItem" + }, + { + "type": "object", + "properties": { + "VideoInfo": { + "$ref": "#/definitions/proto.VideoInfo" + } + } + } + ] + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/rank": { + "get": { + "description": "视频收藏排行榜", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "视频收藏排行榜", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.RankResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/rank/list": { + "get": { + "description": "分页查询视频排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "视频排行榜分页列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜", + "name": "rankPeriod", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRankResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/recommend/list": { + "get": { + "description": "分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端推荐视频列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "分类id,不传查询全部分类id", + "name": "categoryId", + "in": "query" + }, + { + "type": "string", + "description": "分类slug,非空时优先按slug解析分类ID", + "name": "categorySlug", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序方式 1-推荐 2-最新 3-最热", + "name": "sortList", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID,不传查询全部标签", + "name": "tagId", + "in": "query" + }, + { + "type": "string", + "description": "标签slug,非空时优先按slug解析标签ID", + "name": "tagSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/tag/{id}": { + "get": { + "description": "根据统一标签ID分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端根据标签ID查询视频列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "标签ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认20,最大100", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/businessCooperation": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导航", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航" + ], + "summary": "导航", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/consulting": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "招募", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "招募" + ], + "summary": "招募", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/consultinginfo/:id": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "招募详情", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "招募详情" + ], + "summary": "招募详情", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/dh": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导航", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航" + ], + "summary": "导航", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.DHHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/dmca": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "dmca", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dmca" + ], + "summary": "dmca", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/privacyPolicy": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "隐私策略", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "隐私策略" + ], + "summary": "隐私策略", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "搜索,支持查询参数:title=关键词\u0026page=1\u0026type=0\u0026sort=default(不传type=全部;type: 0=视频 1=女优 3=AV 6=短剧;sort: default/hot/new)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "搜索" + ], + "summary": "搜索", + "parameters": [ + { + "type": "string", + "description": "搜索关键词", + "name": "title", + "in": "query" + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "媒体类型,不传=全部,0=视频 1=女优 3=AV 6=短剧", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "排序方式 default/hot/new,默认default", + "name": "sort", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.SearchResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/servicesProject": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导航", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航" + ], + "summary": "导航", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/statement": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "合规声明", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合规声明" + ], + "summary": "合规声明", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/useragreement": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "用户协议", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户协议" + ], + "summary": "用户协议", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "actress.Meas": { + "type": "object", + "properties": { + "blood": { + "description": "血型", + "type": "string" + }, + "bust": { + "description": "胸围 英寸", + "type": "integer" + }, + "cup": { + "description": "罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I", + "type": "integer" + }, + "dia": { + "description": "直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45)", + "type": "integer" + }, + "gender": { + "description": "性别 0:女 1:男 2:其他", + "type": "integer" + }, + "height": { + "description": "身高 厘米", + "type": "integer" + }, + "hip": { + "description": "臀围 英寸", + "type": "integer" + }, + "len": { + "description": "长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145)", + "type": "integer" + }, + "waist": { + "description": "腰围 英寸", + "type": "integer" + }, + "weight": { + "description": "体重 公斤", + "type": "integer" + } + } + }, + "authapi.guestLoginReq": { + "type": "object", + "properties": { + "devID": { + "description": "设备 ID(首选)", + "type": "string" + }, + "deviceId": { + "description": "设备 ID 第二版本(备用)", + "type": "string" + }, + "mac": { + "description": "设备 MAC 地址(备用)", + "type": "string" + } + } + }, + "authapi.loginReq": { + "type": "object", + "required": [ + "account", + "password" + ], + "properties": { + "account": { + "description": "账号", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.registerReq": { + "type": "object", + "required": [ + "accountNo", + "password" + ], + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "agentInviteCode": { + "description": "邀请人的邀请码(可选)", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.updateProfileReq": { + "type": "object", + "required": [ + "accountNo" + ], + "properties": { + "accountNo": { + "description": "账号(必填,用于定位用户)", + "type": "string" + }, + "avatar": { + "description": "新头像 URL(可选)", + "type": "string" + }, + "newPassword": { + "description": "新密码明文(修改密码时必填)", + "type": "string" + }, + "nikeName": { + "description": "昵称", + "type": "string" + }, + "oldPassword": { + "description": "旧密码明文(修改密码时必填)", + "type": "string" + } + } + }, + "authser.LoginResp": { + "type": "object", + "properties": { + "accountNo": { + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "isNewUser": { + "description": "是否本次请求新创建的用户", + "type": "boolean" + }, + "isRegister": { + "description": "是否已完成账号注册", + "type": "boolean" + }, + "loginType": { + "description": "登录方式 0=设备 3=账号", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "nickName": { + "type": "string" + }, + "recharge": { + "description": "当前金币个数", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/pubdef.UserStatus" + }, + "token": { + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "authser.UserInfoResp": { + "type": "object", + "properties": { + "accountNo": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "inviteCode": { + "type": "string" + }, + "isRegister": { + "type": "boolean" + }, + "loginType": { + "$ref": "#/definitions/pubdef.LoginType" + }, + "nickName": { + "type": "string" + }, + "recharge": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/pubdef.UserStatus" + }, + "userId": { + "type": "integer" + } + } + }, + "contenttagmod.ContentTagType": { + "type": "integer", + "enum": [ + 1, + 5 + ], + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ] + }, + "playletchaptermod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletchaptermod.WatermarkStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "WatermarkStatusDone": "已完成", + "WatermarkStatusFailed": "处理失败", + "WatermarkStatusPending": "未处理(待 mediafile/sync)", + "WatermarkStatusProcessing": "处理中(已提交加水印任务,待回写)", + "WatermarkStatusRegistered": "已登记(原片已登记,待 task/add)" + }, + "x-enum-varnames": [ + "WatermarkStatusPending", + "WatermarkStatusRegistered", + "WatermarkStatusProcessing", + "WatermarkStatusDone", + "WatermarkStatusFailed" + ] + }, + "playletmod.ContentType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "ContentTypeAlbum": "普通短剧/影集(对应 media_type=6)", + "ContentTypeAnime": "动漫(对应 media_type=1)", + "ContentTypeComic": "漫画短剧(对应 media_type=4)", + "ContentTypeManga": "漫画(对应 media_type=2)", + "ContentTypeTheater": "短剧(对应 media_type=5)" + }, + "x-enum-varnames": [ + "ContentTypeTheater", + "ContentTypeAlbum", + "ContentTypeComic", + "ContentTypeAnime", + "ContentTypeManga" + ] + }, + "playletmod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletmod.Status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "x-enum-comments": { + "StatusDown": "下架", + "StatusUp": "上架" + }, + "x-enum-varnames": [ + "StatusDown", + "StatusUp" + ] + }, + "proto.ActressInfo": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "proto.ActressInfoData": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "comments": { + "description": "评论条数", + "type": "integer" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "measValue": { + "description": "三围值", + "type": "string" + }, + "office": { + "description": "事务所", + "type": "string" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "watchTimes": { + "description": "观看次数", + "type": "integer" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "proto.AdvitiseData": { + "type": "object", + "properties": { + "AList": { + "description": "固定12广告位", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "BList": { + "description": "固定13广告位", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "detailTopList": { + "description": "详情页顶部广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "detailbannerList": { + "description": "详情banner", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "dialogList": { + "description": "弹窗广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "floatingList": { + "description": "悬浮小方块", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "friendList": { + "description": "友链", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "homeList": { + "description": "首页列表", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "launchScreenList": { + "description": "启动页广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "mustAppList": { + "description": "必备精品", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "nineGridList": { + "description": "9宫格", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "postList": { + "description": "列表穿插广告(视频、短剧、女优列表使用)", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "titleAppList": { + "description": "标题广告(APP名称)", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "videoList": { + "description": "视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + } + } + }, + "proto.AnnouncementInfo": { + "type": "object", + "properties": { + "appAddr": { + "description": "吃瓜app (免费吃瓜 每日更新)", + "type": "string" + }, + "foreverUrlAddr": { + "description": "永久海外地址(翻墙访问 永不迷路)", + "type": "string" + }, + "newUrlAddr": { + "description": "新国内地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "备用地址1", + "type": "string" + }, + "newUrlAddr2": { + "description": "备用地址1", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "videoInfo": { + "description": "热门信息", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfoData" + } + } + } + }, + "proto.AppCategoryInfo": { + "type": "object", + "properties": { + "appList": { + "description": "应用列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppInfoData" + } + }, + "code": { + "description": "分类编码(特殊值:12=广告位A 13=广告位B)", + "type": "integer" + }, + "id": { + "description": "分类ID", + "type": "integer" + }, + "intro": { + "description": "分类简介", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "title": { + "description": "分类标题", + "type": "string" + } + } + }, + "proto.AppContentTagItem": { + "type": "object", + "properties": { + "bindCount": { + "description": "当前查询类型绑定数量,不传type时为各类型绑定数量合计", + "type": "integer" + }, + "id": { + "description": "标签ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "playletCount": { + "description": "绑定短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + }, + "videoCount": { + "description": "绑定视频数量", + "type": "integer" + } + } + }, + "proto.AppContentTagListResp": { + "type": "object", + "properties": { + "list": { + "description": "统一标签列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppContentTagItem" + } + } + } + }, + "proto.AppInfoData": { + "type": "object", + "properties": { + "advitiseId": { + "description": "广告ID(广告条目时返回)", + "type": "string" + }, + "categoryId": { + "description": "所属分类ID", + "type": "integer" + }, + "description": { + "description": "详细描述", + "type": "string" + }, + "id": { + "description": "应用ID", + "type": "integer" + }, + "intro": { + "description": "简介", + "type": "string" + }, + "location": { + "description": "广告位置ID", + "type": "integer" + }, + "logo": { + "description": "Logo 图片地址", + "type": "string" + }, + "name": { + "description": "应用名称", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "url": { + "description": "跳转链接(已含渠道码)", + "type": "string" + } + } + }, + "proto.AppListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "category": { + "description": "当前分类(含应用列表)", + "allOf": [ + { + "$ref": "#/definitions/proto.AppCategoryInfo" + } + ] + }, + "categoryList": { + "description": "应用分类列表(含各分类下的应用列表)", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppCategoryInfo" + } + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.AppRichTextItemResp": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "id": { + "description": "富文本ID", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.AppRichTextListResp": { + "type": "object", + "properties": { + "list": { + "description": "富文本内容列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppRichTextItemResp" + } + } + } + }, + "proto.AppTranslationInfo": { + "type": "object", + "properties": { + "categoryList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationCategory" + } + } + } + }, + "proto.CommentInfo": { + "type": "object", + "properties": { + "avatar": { + "description": "用户头像", + "type": "string" + }, + "commentCount": { + "description": "该评论回复数量", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "评论ID", + "type": "integer" + }, + "level": { + "description": "评论级别", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "replyUserName": { + "description": "回复名称", + "type": "string" + }, + "subComment": { + "description": "子评论", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "proto.CommentListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.CommentSendReq": { + "type": "object", + "properties": { + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 0=视频 4=短剧", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CommentObjectType" + } + ] + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + } + } + }, + "proto.ConfigInfo": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "dhDescription": { + "description": "导航SEO描述", + "type": "string" + }, + "dhKeyworld": { + "description": "导航SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "dhNewUrlAddr1": { + "description": "导航页-线路1", + "type": "string" + }, + "dhNewUrlAddr2": { + "description": "导航页-线路2", + "type": "string" + }, + "dhNewUrlAddr3": { + "description": "导航页-线路3", + "type": "string" + }, + "dhSiteName": { + "description": "导航SEO网站名称", + "type": "string" + }, + "dhTitle": { + "description": "导航SEO标题", + "type": "string" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "freeVPN": { + "description": "免费vpn链接", + "type": "string" + }, + "gitAddr": { + "description": "git地址", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgCdn": { + "description": "图片CDN", + "type": "string" + }, + "m3u8DayRateLimitMax": { + "description": "每 IP 每天最多不同正片数", + "type": "integer" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "最新地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "potaoGroup": { + "description": "土豆群", + "type": "string" + }, + "qq": { + "description": "qq群", + "type": "string" + }, + "reportSdkConfig": { + "description": "数据上报SDK域名配置", + "type": "string" + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "telegramAdd": { + "description": "加入我们tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "tsCdn": { + "description": "视频CDN", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + }, + "useIngAddress": { + "description": "所有使用中的域名", + "type": "array", + "items": { + "type": "string" + } + }, + "webAddr": { + "description": "网站SEO网站地址", + "type": "string" + }, + "webDescription": { + "description": "网站SEO描述", + "type": "string" + }, + "webKeyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "webSiteName": { + "description": "网站SEO网站名称", + "type": "string" + }, + "webTitle": { + "description": "网站SEO标题", + "type": "string" + }, + "weiChart": { + "description": "微信群", + "type": "string" + } + } + }, + "proto.ContentData": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.DHHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotNavs": { + "description": "热门导航", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotNavItem" + } + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "path": { + "type": "string" + }, + "richTextInfo": { + "description": "富文本", + "allOf": [ + { + "$ref": "#/definitions/proto.RichTextInfo" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.DataResp": { + "type": "object", + "properties": { + "actressInfoList": { + "description": "女优信息", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfo" + } + }, + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "homeBannerList": { + "description": "首页banner信息列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeBannerInfo" + } + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "playletData": { + "description": "首页短剧数据", + "allOf": [ + { + "$ref": "#/definitions/proto.HomePlayletDataResp" + } + ] + }, + "videoData": { + "description": "首页视频数据", + "allOf": [ + { + "$ref": "#/definitions/proto.HomeVideoDataResp" + } + ] + } + } + }, + "proto.FavoriteVideoListItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.FavoriteVideoReq": { + "type": "object", + "required": [ + "id", + "isFavorite" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isFavorite": { + "description": "是否收藏,true-收藏、false-取消收藏", + "type": "boolean" + } + } + }, + "proto.FavoriteVideoResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isFavorite": { + "description": "是否收藏,true-收藏、false-取消收藏", + "type": "boolean" + } + } + }, + "proto.FooterBrand": { + "type": "object", + "properties": { + "content": { + "description": "品牌标语", + "type": "string" + }, + "title": { + "description": "品牌标题", + "type": "string" + } + } + }, + "proto.FooterContact": { + "type": "object", + "properties": { + "name": { + "description": "联系方式名称", + "type": "string" + }, + "value": { + "description": "联系方式值", + "type": "string" + } + } + }, + "proto.FooterInfo": { + "type": "object", + "properties": { + "brand": { + "description": "品牌信息", + "allOf": [ + { + "$ref": "#/definitions/proto.FooterBrand" + } + ] + }, + "contactList": { + "description": "联系方式", + "type": "array", + "items": { + "$ref": "#/definitions/proto.FooterContact" + } + }, + "navList": { + "description": "底部导航", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationCategory" + } + } + } + }, + "proto.FooterInfoResp": { + "type": "object", + "properties": { + "footerInfo": { + "description": "底部信息", + "allOf": [ + { + "$ref": "#/definitions/proto.FooterInfo" + } + ] + } + } + }, + "proto.HomeBannerInfo": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "image": { + "description": "图片,已拼接图片域名", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "url": { + "description": "URL", + "type": "string" + }, + "verticalImage": { + "description": "竖版图片,已拼接图片域名", + "type": "string" + } + } + }, + "proto.HomeBaseResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.HomeHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "description": "请求路径", + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "path": { + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.HomePlayletDataResp": { + "type": "object", + "properties": { + "hotList": { + "description": "最热短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "newestList": { + "description": "最新短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "recommendList": { + "description": "推荐短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + } + } + }, + "proto.HomeRankData": { + "type": "object", + "properties": { + "month": { + "description": "月榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + }, + "total": { + "description": "总榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + }, + "week": { + "description": "周榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + }, + "year": { + "description": "年榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + } + } + }, + "proto.HomeTagsResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "playletTagsList": { + "description": "短剧标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "videoTagsList": { + "description": "视频标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + } + } + }, + "proto.HomeVideoDataResp": { + "type": "object", + "properties": { + "hotList": { + "description": "最热视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfo" + } + }, + "newestList": { + "description": "最新视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfo" + } + }, + "recommendList": { + "description": "推荐视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfo" + } + } + } + }, + "proto.HotNavItem": { + "type": "object", + "properties": { + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "intro": { + "description": "介绍", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + } + } + }, + "proto.HotSearchItem": { + "type": "object", + "properties": { + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.LawAndTrustHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "path": { + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.LikePlayletReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + } + } + }, + "proto.LikePlayletResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + }, + "likeNum": { + "description": "点赞数(真实点赞数加后台虚拟点赞数)", + "type": "integer" + } + } + }, + "proto.LikeVideoReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + } + } + }, + "proto.LikeVideoResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + }, + "likeNum": { + "description": "点赞数(真假点赞数之和)", + "type": "integer" + } + } + }, + "proto.PlayletChapterInfo": { + "type": "object", + "properties": { + "countBrowse": { + "description": "浏览数", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "喜欢数", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "剧集ID", + "type": "integer" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaContentId": { + "description": "媒资中心章节ID", + "type": "integer" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "videoUrl": { + "description": "视频播放地址,优先返回水印地址", + "type": "string" + }, + "watermarkStatus": { + "description": "水印状态 0-未处理 1-已登记 2-处理中 3-已完成", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.WatermarkStatus" + } + ] + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletDetailInfo": { + "type": "object", + "properties": { + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数量", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isLike": { + "description": "当前登录用户是否已点赞该短剧", + "type": "boolean" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletRankData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "rankScore": { + "description": "榜单分数,点赞榜为累计展示点赞数,周/月/年榜为对应周期观看数", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletRankResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.PlayletRecommendItemType": { + "type": "integer", + "enum": [ + 0, + 2 + ], + "x-enum-comments": { + "PlayletRecommendItemTypeAdvertise": "广告数据", + "PlayletRecommendItemTypePlaylet": "短剧数据" + }, + "x-enum-varnames": [ + "PlayletRecommendItemTypePlaylet", + "PlayletRecommendItemTypeAdvertise" + ] + }, + "proto.PlayletRecommendListItem": { + "type": "object", + "properties": { + "advertiseId": { + "description": "广告ID,type=2时返回", + "type": "string" + }, + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "link": { + "description": "广告跳转链接,type=2时返回", + "type": "string" + }, + "location": { + "description": "广告位置ID,type=2时返回", + "type": "integer" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "type": { + "description": "数据类型 0-短剧数据 2-广告", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRecommendItemType" + } + ] + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletRecommendListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PostCreateInfo": { + "type": "object", + "properties": { + "background": { + "description": "背景", + "type": "string" + }, + "createType": { + "description": "创作者类型 0=普通 1=官方", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "作者描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "description": "头像", + "type": "string" + }, + "name": { + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "works": { + "type": "integer" + } + } + }, + "proto.QueryActressListResp": { + "type": "object", + "properties": { + "actressInfoList": { + "description": "女优列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfoData" + } + }, + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryActressWorkListResp": { + "type": "object", + "properties": { + "actressInfo": { + "description": "女优信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ActressInfoData" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryFavoriteVideoListResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryPlayletChapterInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "chapterInfo": { + "description": "当前剧集详情", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + ] + }, + "chapterList": { + "description": "剧集列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotPlayletList": { + "description": "热门短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "isLike": { + "description": "当前登录用户是否已点赞该短剧", + "type": "boolean" + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "playletInfo": { + "description": "所属短剧详情", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletData" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryPlayletInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "chapterList": { + "description": "剧集列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotPlayletList": { + "description": "热门短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "isLike": { + "description": "当前登录用户是否已点赞该短剧", + "type": "boolean" + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "playletInfo": { + "description": "短剧详情数据", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletDetailInfo" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryVideoInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotVideoList": { + "description": "热门视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoData" + } + }, + "isFavorite": { + "description": "当前登录用户是否已收藏该视频", + "type": "boolean" + }, + "isLike": { + "description": "当前登录用户是否已点赞该视频", + "type": "boolean" + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "videoInfo": { + "description": "视频详情数据", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoDetailInfo" + } + ] + } + } + }, + "proto.QueryVideoItemType": { + "type": "integer", + "enum": [ + 0, + 2 + ], + "x-enum-varnames": [ + "QueryVideoItemTypeVideo", + "QueryVideoItemTypeAdvertise" + ] + }, + "proto.QueryVideoListItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "advertiseId": { + "description": "广告ID,type=2时返回", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "link": { + "description": "广告跳转链接,type=2时返回", + "type": "string" + }, + "location": { + "description": "广告位置ID,type=2时返回", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "数据类型:0-视频数据、2-广告", + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoItemType" + } + ] + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.QueryVideoResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "categoryInfo": { + "description": "当前分类信息(含 SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoCategoryInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QuestionnaireSubmitReq": { + "type": "object", + "required": [ + "description", + "deviceType", + "frequency", + "networkType", + "operator", + "province", + "questionType" + ], + "properties": { + "attachments": { + "description": "附件列表,最多3个(选填)", + "type": "array", + "items": { + "$ref": "#/definitions/questionnairemod.Attachment" + } + }, + "city": { + "description": "所在城市(选填)", + "type": "string" + }, + "contact": { + "description": "联系方式(选填)", + "type": "string" + }, + "description": { + "description": "问题描述(必填)", + "type": "string" + }, + "deviceModel": { + "description": "设备型号(选填)", + "type": "string" + }, + "deviceType": { + "description": "设备类型:iPhone、Android、iPad、其他", + "type": "string" + }, + "frequency": { + "description": "出现频率:每次都会、经常出现、偶尔出现、仅现一次", + "type": "string" + }, + "networkType": { + "description": "网络类型:WI-FI、5G、4G、其他", + "type": "string" + }, + "operator": { + "description": "运营商:电信、联通、移动、广电", + "type": "string" + }, + "province": { + "description": "所在省份(必填)", + "type": "string" + }, + "questionType": { + "description": "问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题", + "type": "string" + }, + "userId": { + "description": "登录用户ID,0表示匿名(选填)", + "type": "integer" + } + } + }, + "proto.RankResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "rankData": { + "$ref": "#/definitions/proto.HomeRankData" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.RichTextInfo": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "id": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.SearchResp": { + "type": "object", + "properties": { + "actress": { + "description": "女优资源", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfoData" + } + }, + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "inTitle": { + "description": "输入标题", + "type": "string" + }, + "inType": { + "description": "输入类型 -1=全部 0=视频 1=女优 3=AV 6=短剧", + "type": "integer" + }, + "media": { + "description": "视频资源", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfoData" + } + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "playlets": { + "description": "短剧资源", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "searchRecommend": { + "description": "搜索推荐", + "type": "array", + "items": { + "type": "string" + } + }, + "sorts": { + "description": "排序选项", + "type": "array", + "items": { + "$ref": "#/definitions/proto.SearchSortItem" + } + }, + "totalSize": { + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.SearchSortItem": { + "type": "object", + "properties": { + "key": { + "description": "值", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.TagsInfoData": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "images": { + "description": "背景", + "type": "string" + }, + "name": { + "description": "帖子标题", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "proto.TranslationCategory": { + "type": "object", + "properties": { + "categoryType": { + "description": "导航类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.TranslationType" + } + ] + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + }, + "translationList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationInfo" + } + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.TranslationInfo": { + "type": "object", + "properties": { + "displayMode": { + "description": "展示方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.DisplayMode" + } + ] + }, + "id": { + "description": "ID", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.VideoCategoryInfo": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "title": { + "description": "APP显示", + "type": "string" + } + } + }, + "proto.VideoData": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoDetailInfo": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "desc": { + "description": "作品简介", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "isFavorite": { + "description": "当前登录用户是否已收藏该视频", + "type": "boolean" + }, + "isLike": { + "description": "当前登录用户是否已点赞该视频", + "type": "boolean" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "video": { + "description": "视频播放信息", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoInfo": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoInfoData": { + "type": "object", + "properties": { + "actressInfo": { + "description": "女优信息", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfoData" + } + }, + "addedTime": { + "description": "帖子上架时间", + "type": "string" + }, + "advitiseId": { + "description": "广告Id", + "type": "string" + }, + "bango": { + "description": "番号,唯一", + "type": "string" + }, + "collectionInfo": { + "description": "合集数据", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfoData" + } + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "createInfo": { + "description": "作者信息", + "allOf": [ + { + "$ref": "#/definitions/proto.PostCreateInfo" + } + ] + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "issuedAt": { + "description": "发行时间", + "type": "string" + }, + "issuedBrand": { + "description": "发行商", + "type": "string" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "link": { + "description": "跳转链接", + "type": "string" + }, + "location": { + "description": "广告位置id", + "type": "integer" + }, + "previewVtt": { + "description": "雪碧图 vtt", + "type": "string" + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "type": { + "description": "类型", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoType" + } + ] + }, + "updatedAt": { + "description": "页面更新时间", + "type": "string" + }, + "video": { + "description": "Nodes []videoinformationmod.Node ` + "`" + `json:\"nodes\"` + "`" + ` // 节点", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "watchTimes": { + "description": "观看次数", + "type": "integer" + } + } + }, + "proto.VideoRankResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.VideoRecommendItemType": { + "type": "integer", + "enum": [ + 0, + 2 + ], + "x-enum-comments": { + "VideoRecommendItemTypeAdvertise": "广告数据", + "VideoRecommendItemTypeVideo": "视频数据" + }, + "x-enum-varnames": [ + "VideoRecommendItemTypeVideo", + "VideoRecommendItemTypeAdvertise" + ] + }, + "proto.VideoRecommendListItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "advertiseId": { + "description": "广告ID,type=2时返回", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "link": { + "description": "广告跳转链接,type=2时返回", + "type": "string" + }, + "location": { + "description": "广告位置ID,type=2时返回", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "数据类型 0-视频数据 2-广告", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRecommendItemType" + } + ] + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoRecommendListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.VideoType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "VideoTypeAdvitise": "广告", + "VideoTypeCollection": "合集", + "VideoTypeVideo": "视频", + "VideoType_AV": "AV视频" + }, + "x-enum-varnames": [ + "VideoTypeVideo", + "VideoTypeCollection", + "VideoTypeAdvitise", + "VideoType_AV" + ] + }, + "proxy.AdvitiseInfo": { + "type": "object", + "properties": { + "id": { + "description": "广告id", + "type": "string" + }, + "images": { + "description": "视频banner", + "type": "string" + }, + "link": { + "description": "链接", + "type": "string" + }, + "location": { + "description": "位置id", + "type": "integer" + }, + "title": { + "description": "广告标题", + "type": "string" + } + } + }, + "pubdef.CommentObjectType": { + "type": "integer", + "enum": [ + 0, + 4 + ], + "x-enum-comments": { + "CommentObjectTypePlaylet": "短剧", + "CommentObjectTypeVideo": "视频" + }, + "x-enum-varnames": [ + "CommentObjectTypeVideo", + "CommentObjectTypePlaylet" + ] + }, + "pubdef.DisplayMode": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "DisplayModeAvatarList": "头像列表", + "DisplayModeBigCover": "大封面列表(一大图两列)", + "DisplayModeCardCarousel": "卡片层叠轮播", + "DisplayModeDefault": "默认(上图下标题多列)", + "DisplayModeImageText": "图文列表(左图右标题多列)" + }, + "x-enum-varnames": [ + "DisplayModeDefault", + "DisplayModeCardCarousel", + "DisplayModeAvatarList", + "DisplayModeImageText", + "DisplayModeBigCover" + ] + }, + "pubdef.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeMobile": "手机登陆", + "LoginTypeQrCode": "二维码登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeMobile", + "LoginTypeQrCode", + "LoginTypeAccount" + ] + }, + "pubdef.SeoValue": { + "type": "object", + "properties": { + "description": { + "description": "网站SEO描述", + "type": "string" + }, + "keyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "网站SEO标题", + "type": "string" + } + } + }, + "pubdef.TranslationType": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "TranslationTypeBottom": "底部导航", + "TranslationTypeSub": "二级导航", + "TranslationTypeUrl": "URL模式" + }, + "x-enum-varnames": [ + "TranslationTypeUrl", + "TranslationTypeSub", + "TranslationTypeBottom" + ] + }, + "pubdef.UserStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "UserStatusDisable": "封禁", + "UserStatusNormal": "正常", + "UserStatusSlient": "禁言" + }, + "x-enum-varnames": [ + "UserStatusNormal", + "UserStatusDisable", + "UserStatusSlient" + ] + }, + "questionnairemod.Attachment": { + "type": "object", + "properties": { + "fileType": { + "description": "附件类型(img / video)", + "type": "string" + }, + "url": { + "description": "附件URL地址", + "type": "string" + } + } + }, + "uploadser.ImageUploadData": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sourceNameFs": { + "type": "string" + } + } + }, + "uploadser.ImageUploadResp": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/uploadser.ImageUploadData" + }, + "hash": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "time": { + "type": "string" + }, + "tip": { + "type": "string" + } + } + }, + "uploadser.VideoBodyData": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sourceNameFs": { + "type": "string" + }, + "taskId": { + "type": "string" + } + } + }, + "uploadser.VideoBodyResp": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/uploadser.VideoBodyData" + }, + "hash": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "time": { + "type": "string" + }, + "tip": { + "type": "string" + } + } + }, + "uploadser.VideoOkResp": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "msg": { + "type": "string" + }, + "tip": { + "type": "string" + } + } + }, + "videoinformationmod.VideoInfo": { + "type": "object", + "properties": { + "cover": { + "description": "视频封面", + "type": "string" + }, + "fileId": { + "description": "媒资库ID", + "type": "string" + }, + "h265Url": { + "description": "H.265 m3u8 播放地址,空字符串表示暂无该编码源", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "H.264 m3u8 播放地址(始终存在)", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "BasicAuth": { + "type": "basic" + }, + "OAuth2AccessCode": { + "type": "oauth2", + "flow": "accessCode", + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information" + } + }, + "OAuth2Application": { + "type": "oauth2", + "flow": "application", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Implicit": { + "type": "oauth2", + "flow": "implicit", + "authorizationUrl": "https://example.com/oauth/authorize", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Password": { + "type": "oauth2", + "flow": "password", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "read": "Grants read access", + "write": "Grants write access" + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "视频站-api.lcncctn.com", + BasePath: "/", + Schemes: []string{"http", "https"}, + Title: "视频站APP接口", + Description: "这是视频站APP接口服务.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/app/docs/swagger.json b/app/docs/swagger.json new file mode 100644 index 0000000..3af08bb --- /dev/null +++ b/app/docs/swagger.json @@ -0,0 +1,6720 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "description": "这是视频站APP接口服务.", + "title": "视频站APP接口", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "host": "视频站-api.lcncctn.com", + "basePath": "/", + "paths": { + "/api/app/actress/list": { + "get": { + "description": "返回导航、广告和按热度值降序排列的女优列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优相关接口" + ], + "summary": "女优榜单列表 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.QueryActressListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/actress/work/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "查询指定女优详情和作品列表,支持上架时间/观看量/收藏量升降序,每间隔8条作品数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优相关接口" + ], + "summary": "指定女优详情和作品列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "上架时间升序/降序(1-升序 2降序)", + "name": "addedTimeSort", + "in": "query" + }, + { + "type": "integer", + "description": "女优ID", + "name": "id", + "in": "query" + }, + { + "type": "integer", + "description": "按收藏(点赞)量升序/降序(1-升序 2降序)", + "name": "likesSort", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "女优slug,非空时优先按slug解析女优ID", + "name": "slug", + "in": "query" + }, + { + "type": "integer", + "description": "按观看量升序/降序(1-升序 2降序)", + "name": "watchTimesSort", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryActressWorkListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoListItem" + }, + { + "type": "object", + "properties": { + "VideoInfo": { + "$ref": "#/definitions/proto.VideoInfo" + } + } + } + ] + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/applist/list": { + "get": { + "description": "返回应用分类列表,每个分类下含应用信息列表;categoryId=0 返回第一个分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "应用列表" + ], + "summary": "查询应用列表", + "parameters": [ + { + "type": "integer", + "description": "当前分类ID,0表示默认第一个分类", + "name": "categoryId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/getInfo": { + "get": { + "description": "根据当前登录 token 查询用户基础信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/authser.UserInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/login": { + "post": { + "description": "账号均可作为登录凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "登录", + "parameters": [ + { + "description": "登录参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.loginReq" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回 token 及用户基本信息", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/profile": { + "post": { + "description": "账号必填;avatar、oldPassword+newPassword 均为可选,至少提供一项。修改密码时旧密码和新密码必须同时提供。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "修改用户资料(头像 / 密码,可任意组合)", + "parameters": [ + { + "description": "修改资料参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.updateProfileReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/register": { + "post": { + "description": "已登录游客传入账号密码完成升级;未登录用户直接注册新账号。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "注册", + "parameters": [ + { + "description": "注册参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.registerReq" + } + } + ], + "responses": { + "200": { + "description": "注册成功,返回 token 及用户基本信息", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/list": { + "get": { + "description": "按对象查询树形评论列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论APP" + ], + "summary": "评论列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "对象ID", + "name": "objectId", + "in": "query" + }, + { + "enum": [ + 0, + 4 + ], + "type": "integer", + "x-enum-comments": { + "CommentObjectTypePlaylet": "短剧", + "CommentObjectTypeVideo": "视频" + }, + "x-enum-varnames": [ + "CommentObjectTypeVideo", + "CommentObjectTypePlaylet" + ], + "description": "对象类型 0=视频 4=短剧", + "name": "objectType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.CommentListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "发布评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论APP" + ], + "summary": "发布评论 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.CommentSendReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/config/footer/info": { + "get": { + "description": "返回APP底部品牌、底部导航和联系方式", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app配置相关接口" + ], + "summary": "查询APP底部信息 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FooterInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/config/richtext/list": { + "get": { + "description": "返回启用状态的富文本内容,支持按ids或titles过滤", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app配置相关接口" + ], + "summary": "查询APP富文本内容列表 -- by 王鸣", + "parameters": [ + { + "type": "string", + "description": "富文本ID,多个用英文逗号分隔", + "name": "ids", + "in": "query" + }, + { + "type": "string", + "description": "富文本标题,多个用英文逗号分隔", + "name": "titles", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppRichTextListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/content/tag/list": { + "get": { + "description": "返回启用且有绑定内容数量的统一标签,contentTagType非必填,按绑定内容数量倒序排序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app配置相关接口" + ], + "summary": "查询APP统一标签列表 -- by 王鸣", + "parameters": [ + { + "enum": [ + 1, + 5 + ], + "type": "integer", + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ], + "description": "内容类型 1-视频 5-短剧,不传返回全部有绑定数量的标签", + "name": "contentTagType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppContentTagListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/home": { + "get": { + "description": "首页分类数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "首页" + ], + "summary": "首页 --by 旺鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.DataResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/home/base": { + "get": { + "description": "只返回翻译、配置、底部翻译、广告和公告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "首页" + ], + "summary": "首页公共数据 --by 旺鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeBaseResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/home/tags": { + "get": { + "description": "返回视频、短剧的标签信息,以及基础导航、配置、广告和公告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "首页" + ], + "summary": "首页标签数据 --by 旺鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeTagsResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/login/guest": { + "post": { + "description": "按设备 ID 查找已有游客账号,找不到则自动注册。若请求携带有效 token 则优先复用该用户。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "游客登录", + "parameters": [ + { + "description": "设备信息", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.guestLoginReq" + } + } + ], + "responses": { + "200": { + "description": "登录成功", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/navigation/header": { + "get": { + "description": "查询导航类型为URL模式和二级导航的头部导航配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app导航相关接口" + ], + "summary": "查询头部导航配置 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/info": { + "get": { + "description": "根据剧集ID查询短剧剧集详情、所属短剧、剧集列表、热门短剧列表和点赞状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集详情 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "剧集ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryPlayletChapterInfoResp" + }, + { + "type": "object", + "properties": { + "chapterInfo": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + }, + "chapterList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "hotPlayletList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "playletInfo": { + "$ref": "#/definitions/proto.PlayletData" + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/info": { + "get": { + "description": "查询短剧详情、剧集列表、热门短剧列表和点赞状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "短剧详情 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "slug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryPlayletInfoResp" + }, + { + "type": "object", + "properties": { + "chapterList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "hotPlayletList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "playletInfo": { + "$ref": "#/definitions/proto.PlayletDetailInfo" + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/like": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户点赞或取消点赞指定短剧,isLike=true表示点赞,isLike=false表示取消点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "点赞或取消点赞短剧 -- by 王鸣", + "parameters": [ + { + "description": "点赞或取消点赞请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.LikePlayletReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LikePlayletResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/rank/list": { + "get": { + "description": "分页查询短剧排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "短剧排行榜分页列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜", + "name": "rankPeriod", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRankResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletRankData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/recommend/list": { + "get": { + "description": "分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端推荐短剧列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧分类ID,不传查询全部分类", + "name": "categoryId", + "in": "query" + }, + { + "type": "string", + "description": "分类slug,非空时优先按slug解析分类ID", + "name": "categorySlug", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序方式 1-推荐 2-最新 3-最热", + "name": "sortList", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID,不传查询全部标签", + "name": "tagId", + "in": "query" + }, + { + "type": "string", + "description": "标签slug,非空时优先按slug解析标签ID", + "name": "tagSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/tag/{id}": { + "get": { + "description": "根据统一标签ID分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据标签ID查询短剧列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "标签ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认20,最大100", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/questionnaire/submit": { + "post": { + "description": "用户提交问题反馈问卷", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "问卷调查APP" + ], + "summary": "提交问卷", + "parameters": [ + { + "description": "提交参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.QuestionnaireSubmitReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/richtext": { + "get": { + "description": "从缓存中查询启用状态的富文本信息", + "produces": [ + "application/json" + ], + "tags": [ + "富文本" + ], + "summary": "按 title 查询富文本", + "parameters": [ + { + "type": "string", + "description": "富文本内部标题", + "name": "title", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.RichTextInfo" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/upload/image": { + "post": { + "description": "将图片转发至存储服务 /media/web/autopublish/image", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "上传" + ], + "summary": "图片上传", + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.ImageUploadResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/upload/videoBody": { + "post": { + "description": "将视频分片转发至存储服务 /media/web/autopublish/videoBody", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "上传" + ], + "summary": "视频分片上传", + "parameters": [ + { + "type": "string", + "description": "视频任务ID", + "name": "taskId", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "当前分片序号,从0开始", + "name": "pos", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "上传用户名,为空默认 questionnaires", + "name": "uploadUserName", + "in": "formData" + }, + { + "type": "file", + "description": "分片文件数据", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.VideoBodyResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/upload/videoOk": { + "post": { + "description": "所有分片上传完毕后调用,转发至存储服务 /media/web/autopublish/videoOk", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "上传" + ], + "summary": "视频分片上传完成通知", + "parameters": [ + { + "description": "{ \\", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.VideoOkResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/favorite": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户收藏或取消收藏指定视频,isFavorite=true表示收藏,isFavorite=false表示取消收藏", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "收藏或取消收藏视频 --by 旺鸣", + "parameters": [ + { + "description": "收藏或取消收藏请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.FavoriteVideoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FavoriteVideoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/favorite/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户收藏视频分页列表,按收藏时间倒序排序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "当前登录用户收藏视频分页列表 --by 旺鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryFavoriteVideoListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.FavoriteVideoListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/info": { + "get": { + "description": "查询视频详情、热门视频列表、收藏状态和点赞状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "视频详情 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "视频slug,非空时优先按slug解析视频ID", + "name": "slug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoInfoResp" + }, + { + "type": "object", + "properties": { + "hotVideoList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoData" + } + }, + "videoInfo": { + "$ref": "#/definitions/proto.VideoDetailInfo" + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/like": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "当前登录用户点赞或取消点赞指定视频,isLike=true表示点赞,isLike=false表示取消点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "点赞或取消点赞视频 -- by 王鸣", + "parameters": [ + { + "description": "点赞或取消点赞请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.LikeVideoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LikeVideoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/more": { + "get": { + "description": "根据分类id查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "根据分类id查询视频列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "视频分类id", + "name": "categoryId", + "in": "query" + }, + { + "type": "string", + "description": "分类slug,非空时优先按slug解析分类ID", + "name": "categorySlug", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoListItem" + }, + { + "type": "object", + "properties": { + "VideoInfo": { + "$ref": "#/definitions/proto.VideoInfo" + } + } + } + ] + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/rank": { + "get": { + "description": "视频收藏排行榜", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "视频收藏排行榜", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.RankResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/rank/list": { + "get": { + "description": "分页查询视频排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "视频排行榜分页列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜", + "name": "rankPeriod", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRankResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/recommend/list": { + "get": { + "description": "分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端推荐视频列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "分类id,不传查询全部分类id", + "name": "categoryId", + "in": "query" + }, + { + "type": "string", + "description": "分类slug,非空时优先按slug解析分类ID", + "name": "categorySlug", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序方式 1-推荐 2-最新 3-最热", + "name": "sortList", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID,不传查询全部标签", + "name": "tagId", + "in": "query" + }, + { + "type": "string", + "description": "标签slug,非空时优先按slug解析标签ID", + "name": "tagSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/tag/{id}": { + "get": { + "description": "根据统一标签ID分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端根据标签ID查询视频列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "标签ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认20,最大100", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRecommendListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoRecommendListItem" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/businessCooperation": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导航", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航" + ], + "summary": "导航", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/consulting": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "招募", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "招募" + ], + "summary": "招募", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/consultinginfo/:id": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "招募详情", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "招募详情" + ], + "summary": "招募详情", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/dh": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导航", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航" + ], + "summary": "导航", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.DHHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/dmca": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "dmca", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dmca" + ], + "summary": "dmca", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/privacyPolicy": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "隐私策略", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "隐私策略" + ], + "summary": "隐私策略", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "搜索,支持查询参数:title=关键词\u0026page=1\u0026type=0\u0026sort=default(不传type=全部;type: 0=视频 1=女优 3=AV 6=短剧;sort: default/hot/new)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "搜索" + ], + "summary": "搜索", + "parameters": [ + { + "type": "string", + "description": "搜索关键词", + "name": "title", + "in": "query" + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "媒体类型,不传=全部,0=视频 1=女优 3=AV 6=短剧", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "排序方式 default/hot/new,默认default", + "name": "sort", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.SearchResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/servicesProject": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导航", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航" + ], + "summary": "导航", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/statement": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "合规声明", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合规声明" + ], + "summary": "合规声明", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/useragreement": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "用户协议", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户协议" + ], + "summary": "用户协议", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.LawAndTrustHtmlData" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "actress.Meas": { + "type": "object", + "properties": { + "blood": { + "description": "血型", + "type": "string" + }, + "bust": { + "description": "胸围 英寸", + "type": "integer" + }, + "cup": { + "description": "罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I", + "type": "integer" + }, + "dia": { + "description": "直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45)", + "type": "integer" + }, + "gender": { + "description": "性别 0:女 1:男 2:其他", + "type": "integer" + }, + "height": { + "description": "身高 厘米", + "type": "integer" + }, + "hip": { + "description": "臀围 英寸", + "type": "integer" + }, + "len": { + "description": "长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145)", + "type": "integer" + }, + "waist": { + "description": "腰围 英寸", + "type": "integer" + }, + "weight": { + "description": "体重 公斤", + "type": "integer" + } + } + }, + "authapi.guestLoginReq": { + "type": "object", + "properties": { + "devID": { + "description": "设备 ID(首选)", + "type": "string" + }, + "deviceId": { + "description": "设备 ID 第二版本(备用)", + "type": "string" + }, + "mac": { + "description": "设备 MAC 地址(备用)", + "type": "string" + } + } + }, + "authapi.loginReq": { + "type": "object", + "required": [ + "account", + "password" + ], + "properties": { + "account": { + "description": "账号", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.registerReq": { + "type": "object", + "required": [ + "accountNo", + "password" + ], + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "agentInviteCode": { + "description": "邀请人的邀请码(可选)", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.updateProfileReq": { + "type": "object", + "required": [ + "accountNo" + ], + "properties": { + "accountNo": { + "description": "账号(必填,用于定位用户)", + "type": "string" + }, + "avatar": { + "description": "新头像 URL(可选)", + "type": "string" + }, + "newPassword": { + "description": "新密码明文(修改密码时必填)", + "type": "string" + }, + "nikeName": { + "description": "昵称", + "type": "string" + }, + "oldPassword": { + "description": "旧密码明文(修改密码时必填)", + "type": "string" + } + } + }, + "authser.LoginResp": { + "type": "object", + "properties": { + "accountNo": { + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "isNewUser": { + "description": "是否本次请求新创建的用户", + "type": "boolean" + }, + "isRegister": { + "description": "是否已完成账号注册", + "type": "boolean" + }, + "loginType": { + "description": "登录方式 0=设备 3=账号", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "nickName": { + "type": "string" + }, + "recharge": { + "description": "当前金币个数", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/pubdef.UserStatus" + }, + "token": { + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "authser.UserInfoResp": { + "type": "object", + "properties": { + "accountNo": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "inviteCode": { + "type": "string" + }, + "isRegister": { + "type": "boolean" + }, + "loginType": { + "$ref": "#/definitions/pubdef.LoginType" + }, + "nickName": { + "type": "string" + }, + "recharge": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/pubdef.UserStatus" + }, + "userId": { + "type": "integer" + } + } + }, + "contenttagmod.ContentTagType": { + "type": "integer", + "enum": [ + 1, + 5 + ], + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ] + }, + "playletchaptermod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletchaptermod.WatermarkStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "WatermarkStatusDone": "已完成", + "WatermarkStatusFailed": "处理失败", + "WatermarkStatusPending": "未处理(待 mediafile/sync)", + "WatermarkStatusProcessing": "处理中(已提交加水印任务,待回写)", + "WatermarkStatusRegistered": "已登记(原片已登记,待 task/add)" + }, + "x-enum-varnames": [ + "WatermarkStatusPending", + "WatermarkStatusRegistered", + "WatermarkStatusProcessing", + "WatermarkStatusDone", + "WatermarkStatusFailed" + ] + }, + "playletmod.ContentType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "ContentTypeAlbum": "普通短剧/影集(对应 media_type=6)", + "ContentTypeAnime": "动漫(对应 media_type=1)", + "ContentTypeComic": "漫画短剧(对应 media_type=4)", + "ContentTypeManga": "漫画(对应 media_type=2)", + "ContentTypeTheater": "短剧(对应 media_type=5)" + }, + "x-enum-varnames": [ + "ContentTypeTheater", + "ContentTypeAlbum", + "ContentTypeComic", + "ContentTypeAnime", + "ContentTypeManga" + ] + }, + "playletmod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletmod.Status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "x-enum-comments": { + "StatusDown": "下架", + "StatusUp": "上架" + }, + "x-enum-varnames": [ + "StatusDown", + "StatusUp" + ] + }, + "proto.ActressInfo": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "proto.ActressInfoData": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "comments": { + "description": "评论条数", + "type": "integer" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "measValue": { + "description": "三围值", + "type": "string" + }, + "office": { + "description": "事务所", + "type": "string" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "watchTimes": { + "description": "观看次数", + "type": "integer" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "proto.AdvitiseData": { + "type": "object", + "properties": { + "AList": { + "description": "固定12广告位", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "BList": { + "description": "固定13广告位", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "detailTopList": { + "description": "详情页顶部广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "detailbannerList": { + "description": "详情banner", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "dialogList": { + "description": "弹窗广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "floatingList": { + "description": "悬浮小方块", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "friendList": { + "description": "友链", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "homeList": { + "description": "首页列表", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "launchScreenList": { + "description": "启动页广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "mustAppList": { + "description": "必备精品", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "nineGridList": { + "description": "9宫格", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "postList": { + "description": "列表穿插广告(视频、短剧、女优列表使用)", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "titleAppList": { + "description": "标题广告(APP名称)", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "videoList": { + "description": "视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + } + } + }, + "proto.AnnouncementInfo": { + "type": "object", + "properties": { + "appAddr": { + "description": "吃瓜app (免费吃瓜 每日更新)", + "type": "string" + }, + "foreverUrlAddr": { + "description": "永久海外地址(翻墙访问 永不迷路)", + "type": "string" + }, + "newUrlAddr": { + "description": "新国内地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "备用地址1", + "type": "string" + }, + "newUrlAddr2": { + "description": "备用地址1", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "videoInfo": { + "description": "热门信息", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfoData" + } + } + } + }, + "proto.AppCategoryInfo": { + "type": "object", + "properties": { + "appList": { + "description": "应用列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppInfoData" + } + }, + "code": { + "description": "分类编码(特殊值:12=广告位A 13=广告位B)", + "type": "integer" + }, + "id": { + "description": "分类ID", + "type": "integer" + }, + "intro": { + "description": "分类简介", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "title": { + "description": "分类标题", + "type": "string" + } + } + }, + "proto.AppContentTagItem": { + "type": "object", + "properties": { + "bindCount": { + "description": "当前查询类型绑定数量,不传type时为各类型绑定数量合计", + "type": "integer" + }, + "id": { + "description": "标签ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "playletCount": { + "description": "绑定短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + }, + "videoCount": { + "description": "绑定视频数量", + "type": "integer" + } + } + }, + "proto.AppContentTagListResp": { + "type": "object", + "properties": { + "list": { + "description": "统一标签列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppContentTagItem" + } + } + } + }, + "proto.AppInfoData": { + "type": "object", + "properties": { + "advitiseId": { + "description": "广告ID(广告条目时返回)", + "type": "string" + }, + "categoryId": { + "description": "所属分类ID", + "type": "integer" + }, + "description": { + "description": "详细描述", + "type": "string" + }, + "id": { + "description": "应用ID", + "type": "integer" + }, + "intro": { + "description": "简介", + "type": "string" + }, + "location": { + "description": "广告位置ID", + "type": "integer" + }, + "logo": { + "description": "Logo 图片地址", + "type": "string" + }, + "name": { + "description": "应用名称", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "url": { + "description": "跳转链接(已含渠道码)", + "type": "string" + } + } + }, + "proto.AppListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "category": { + "description": "当前分类(含应用列表)", + "allOf": [ + { + "$ref": "#/definitions/proto.AppCategoryInfo" + } + ] + }, + "categoryList": { + "description": "应用分类列表(含各分类下的应用列表)", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppCategoryInfo" + } + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.AppRichTextItemResp": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "id": { + "description": "富文本ID", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.AppRichTextListResp": { + "type": "object", + "properties": { + "list": { + "description": "富文本内容列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AppRichTextItemResp" + } + } + } + }, + "proto.AppTranslationInfo": { + "type": "object", + "properties": { + "categoryList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationCategory" + } + } + } + }, + "proto.CommentInfo": { + "type": "object", + "properties": { + "avatar": { + "description": "用户头像", + "type": "string" + }, + "commentCount": { + "description": "该评论回复数量", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "评论ID", + "type": "integer" + }, + "level": { + "description": "评论级别", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "replyUserName": { + "description": "回复名称", + "type": "string" + }, + "subComment": { + "description": "子评论", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "proto.CommentListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.CommentSendReq": { + "type": "object", + "properties": { + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 0=视频 4=短剧", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CommentObjectType" + } + ] + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + } + } + }, + "proto.ConfigInfo": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "dhDescription": { + "description": "导航SEO描述", + "type": "string" + }, + "dhKeyworld": { + "description": "导航SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "dhNewUrlAddr1": { + "description": "导航页-线路1", + "type": "string" + }, + "dhNewUrlAddr2": { + "description": "导航页-线路2", + "type": "string" + }, + "dhNewUrlAddr3": { + "description": "导航页-线路3", + "type": "string" + }, + "dhSiteName": { + "description": "导航SEO网站名称", + "type": "string" + }, + "dhTitle": { + "description": "导航SEO标题", + "type": "string" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "freeVPN": { + "description": "免费vpn链接", + "type": "string" + }, + "gitAddr": { + "description": "git地址", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgCdn": { + "description": "图片CDN", + "type": "string" + }, + "m3u8DayRateLimitMax": { + "description": "每 IP 每天最多不同正片数", + "type": "integer" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "最新地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "potaoGroup": { + "description": "土豆群", + "type": "string" + }, + "qq": { + "description": "qq群", + "type": "string" + }, + "reportSdkConfig": { + "description": "数据上报SDK域名配置", + "type": "string" + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "telegramAdd": { + "description": "加入我们tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "tsCdn": { + "description": "视频CDN", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + }, + "useIngAddress": { + "description": "所有使用中的域名", + "type": "array", + "items": { + "type": "string" + } + }, + "webAddr": { + "description": "网站SEO网站地址", + "type": "string" + }, + "webDescription": { + "description": "网站SEO描述", + "type": "string" + }, + "webKeyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "webSiteName": { + "description": "网站SEO网站名称", + "type": "string" + }, + "webTitle": { + "description": "网站SEO标题", + "type": "string" + }, + "weiChart": { + "description": "微信群", + "type": "string" + } + } + }, + "proto.ContentData": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.DHHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotNavs": { + "description": "热门导航", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotNavItem" + } + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "path": { + "type": "string" + }, + "richTextInfo": { + "description": "富文本", + "allOf": [ + { + "$ref": "#/definitions/proto.RichTextInfo" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.DataResp": { + "type": "object", + "properties": { + "actressInfoList": { + "description": "女优信息", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfo" + } + }, + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "homeBannerList": { + "description": "首页banner信息列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeBannerInfo" + } + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "playletData": { + "description": "首页短剧数据", + "allOf": [ + { + "$ref": "#/definitions/proto.HomePlayletDataResp" + } + ] + }, + "videoData": { + "description": "首页视频数据", + "allOf": [ + { + "$ref": "#/definitions/proto.HomeVideoDataResp" + } + ] + } + } + }, + "proto.FavoriteVideoListItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.FavoriteVideoReq": { + "type": "object", + "required": [ + "id", + "isFavorite" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isFavorite": { + "description": "是否收藏,true-收藏、false-取消收藏", + "type": "boolean" + } + } + }, + "proto.FavoriteVideoResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isFavorite": { + "description": "是否收藏,true-收藏、false-取消收藏", + "type": "boolean" + } + } + }, + "proto.FooterBrand": { + "type": "object", + "properties": { + "content": { + "description": "品牌标语", + "type": "string" + }, + "title": { + "description": "品牌标题", + "type": "string" + } + } + }, + "proto.FooterContact": { + "type": "object", + "properties": { + "name": { + "description": "联系方式名称", + "type": "string" + }, + "value": { + "description": "联系方式值", + "type": "string" + } + } + }, + "proto.FooterInfo": { + "type": "object", + "properties": { + "brand": { + "description": "品牌信息", + "allOf": [ + { + "$ref": "#/definitions/proto.FooterBrand" + } + ] + }, + "contactList": { + "description": "联系方式", + "type": "array", + "items": { + "$ref": "#/definitions/proto.FooterContact" + } + }, + "navList": { + "description": "底部导航", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationCategory" + } + } + } + }, + "proto.FooterInfoResp": { + "type": "object", + "properties": { + "footerInfo": { + "description": "底部信息", + "allOf": [ + { + "$ref": "#/definitions/proto.FooterInfo" + } + ] + } + } + }, + "proto.HomeBannerInfo": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "image": { + "description": "图片,已拼接图片域名", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "url": { + "description": "URL", + "type": "string" + }, + "verticalImage": { + "description": "竖版图片,已拼接图片域名", + "type": "string" + } + } + }, + "proto.HomeBaseResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.HomeHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "description": "请求路径", + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "path": { + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.HomePlayletDataResp": { + "type": "object", + "properties": { + "hotList": { + "description": "最热短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "newestList": { + "description": "最新短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "recommendList": { + "description": "推荐短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + } + } + }, + "proto.HomeRankData": { + "type": "object", + "properties": { + "month": { + "description": "月榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + }, + "total": { + "description": "总榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + }, + "week": { + "description": "周榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + }, + "year": { + "description": "年榜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentData" + } + } + } + }, + "proto.HomeTagsResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "playletTagsList": { + "description": "短剧标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "videoTagsList": { + "description": "视频标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + } + } + }, + "proto.HomeVideoDataResp": { + "type": "object", + "properties": { + "hotList": { + "description": "最热视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfo" + } + }, + "newestList": { + "description": "最新视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfo" + } + }, + "recommendList": { + "description": "推荐视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfo" + } + } + } + }, + "proto.HotNavItem": { + "type": "object", + "properties": { + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "intro": { + "description": "介绍", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + } + } + }, + "proto.HotSearchItem": { + "type": "object", + "properties": { + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.LawAndTrustHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "path": { + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.LikePlayletReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + } + } + }, + "proto.LikePlayletResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + }, + "likeNum": { + "description": "点赞数(真实点赞数加后台虚拟点赞数)", + "type": "integer" + } + } + }, + "proto.LikeVideoReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + } + } + }, + "proto.LikeVideoResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞、false-取消点赞", + "type": "boolean" + }, + "likeNum": { + "description": "点赞数(真假点赞数之和)", + "type": "integer" + } + } + }, + "proto.PlayletChapterInfo": { + "type": "object", + "properties": { + "countBrowse": { + "description": "浏览数", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "喜欢数", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "剧集ID", + "type": "integer" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaContentId": { + "description": "媒资中心章节ID", + "type": "integer" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "videoUrl": { + "description": "视频播放地址,优先返回水印地址", + "type": "string" + }, + "watermarkStatus": { + "description": "水印状态 0-未处理 1-已登记 2-处理中 3-已完成", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.WatermarkStatus" + } + ] + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletDetailInfo": { + "type": "object", + "properties": { + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数量", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isLike": { + "description": "当前登录用户是否已点赞该短剧", + "type": "boolean" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletRankData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "rankScore": { + "description": "榜单分数,点赞榜为累计展示点赞数,周/月/年榜为对应周期观看数", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletRankResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.PlayletRecommendItemType": { + "type": "integer", + "enum": [ + 0, + 2 + ], + "x-enum-comments": { + "PlayletRecommendItemTypeAdvertise": "广告数据", + "PlayletRecommendItemTypePlaylet": "短剧数据" + }, + "x-enum-varnames": [ + "PlayletRecommendItemTypePlaylet", + "PlayletRecommendItemTypeAdvertise" + ] + }, + "proto.PlayletRecommendListItem": { + "type": "object", + "properties": { + "advertiseId": { + "description": "广告ID,type=2时返回", + "type": "string" + }, + "anchor": { + "description": "作者或演员", + "type": "string" + }, + "contentType": { + "description": "内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "countBrowse": { + "description": "累计点击量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "真实点赞数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "displayCountLike": { + "description": "展示点赞数,真实点赞数加后台虚拟点赞数", + "type": "integer" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isNew": { + "description": "是否上新 本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "link": { + "description": "广告跳转链接,type=2时返回", + "type": "string" + }, + "location": { + "description": "广告位置ID,type=2时返回", + "type": "integer" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告,已拼接图片域名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态 0-下架 1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "type": { + "description": "数据类型 0-短剧数据 2-广告", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRecommendItemType" + } + ] + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "virtualCountLike": { + "description": "后台虚拟点赞数", + "type": "integer" + } + } + }, + "proto.PlayletRecommendListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PostCreateInfo": { + "type": "object", + "properties": { + "background": { + "description": "背景", + "type": "string" + }, + "createType": { + "description": "创作者类型 0=普通 1=官方", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "作者描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "description": "头像", + "type": "string" + }, + "name": { + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "works": { + "type": "integer" + } + } + }, + "proto.QueryActressListResp": { + "type": "object", + "properties": { + "actressInfoList": { + "description": "女优列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfoData" + } + }, + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryActressWorkListResp": { + "type": "object", + "properties": { + "actressInfo": { + "description": "女优信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ActressInfoData" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryFavoriteVideoListResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryPlayletChapterInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "chapterInfo": { + "description": "当前剧集详情", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + ] + }, + "chapterList": { + "description": "剧集列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotPlayletList": { + "description": "热门短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "isLike": { + "description": "当前登录用户是否已点赞该短剧", + "type": "boolean" + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "playletInfo": { + "description": "所属短剧详情", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletData" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryPlayletInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "chapterList": { + "description": "剧集列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterInfo" + } + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotPlayletList": { + "description": "热门短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "isLike": { + "description": "当前登录用户是否已点赞该短剧", + "type": "boolean" + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "playletInfo": { + "description": "短剧详情数据", + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletDetailInfo" + } + ] + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QueryVideoInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcement": { + "description": "公告", + "allOf": [ + { + "$ref": "#/definitions/proto.AnnouncementInfo" + } + ] + }, + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "config": { + "description": "配置信息", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotVideoList": { + "description": "热门视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoData" + } + }, + "isFavorite": { + "description": "当前登录用户是否已收藏该视频", + "type": "boolean" + }, + "isLike": { + "description": "当前登录用户是否已点赞该视频", + "type": "boolean" + }, + "path": { + "description": "当前路径", + "type": "string" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "videoInfo": { + "description": "视频详情数据", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoDetailInfo" + } + ] + } + } + }, + "proto.QueryVideoItemType": { + "type": "integer", + "enum": [ + 0, + 2 + ], + "x-enum-varnames": [ + "QueryVideoItemTypeVideo", + "QueryVideoItemTypeAdvertise" + ] + }, + "proto.QueryVideoListItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "advertiseId": { + "description": "广告ID,type=2时返回", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "link": { + "description": "广告跳转链接,type=2时返回", + "type": "string" + }, + "location": { + "description": "广告位置ID,type=2时返回", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "数据类型:0-视频数据、2-广告", + "allOf": [ + { + "$ref": "#/definitions/proto.QueryVideoItemType" + } + ] + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.QueryVideoResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "categoryInfo": { + "description": "当前分类信息(含 SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoCategoryInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.QuestionnaireSubmitReq": { + "type": "object", + "required": [ + "description", + "deviceType", + "frequency", + "networkType", + "operator", + "province", + "questionType" + ], + "properties": { + "attachments": { + "description": "附件列表,最多3个(选填)", + "type": "array", + "items": { + "$ref": "#/definitions/questionnairemod.Attachment" + } + }, + "city": { + "description": "所在城市(选填)", + "type": "string" + }, + "contact": { + "description": "联系方式(选填)", + "type": "string" + }, + "description": { + "description": "问题描述(必填)", + "type": "string" + }, + "deviceModel": { + "description": "设备型号(选填)", + "type": "string" + }, + "deviceType": { + "description": "设备类型:iPhone、Android、iPad、其他", + "type": "string" + }, + "frequency": { + "description": "出现频率:每次都会、经常出现、偶尔出现、仅现一次", + "type": "string" + }, + "networkType": { + "description": "网络类型:WI-FI、5G、4G、其他", + "type": "string" + }, + "operator": { + "description": "运营商:电信、联通、移动、广电", + "type": "string" + }, + "province": { + "description": "所在省份(必填)", + "type": "string" + }, + "questionType": { + "description": "问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题", + "type": "string" + }, + "userId": { + "description": "登录用户ID,0表示匿名(选填)", + "type": "integer" + } + } + }, + "proto.RankResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "rankData": { + "$ref": "#/definitions/proto.HomeRankData" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.RichTextInfo": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "id": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.SearchResp": { + "type": "object", + "properties": { + "actress": { + "description": "女优资源", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfoData" + } + }, + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "bottonTranslations": { + "description": "底部导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "completePath": { + "type": "string" + }, + "config": { + "description": "配置文件", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "hotSearch": { + "description": "热搜", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HotSearchItem" + } + }, + "inTitle": { + "description": "输入标题", + "type": "string" + }, + "inType": { + "description": "输入类型 -1=全部 0=视频 1=女优 3=AV 6=短剧", + "type": "integer" + }, + "media": { + "description": "视频资源", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfoData" + } + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "playlets": { + "description": "短剧资源", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletData" + } + }, + "searchRecommend": { + "description": "搜索推荐", + "type": "array", + "items": { + "type": "string" + } + }, + "sorts": { + "description": "排序选项", + "type": "array", + "items": { + "$ref": "#/definitions/proto.SearchSortItem" + } + }, + "totalSize": { + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.SearchSortItem": { + "type": "object", + "properties": { + "key": { + "description": "值", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "proto.TagsInfoData": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "images": { + "description": "背景", + "type": "string" + }, + "name": { + "description": "帖子标题", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "proto.TranslationCategory": { + "type": "object", + "properties": { + "categoryType": { + "description": "导航类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.TranslationType" + } + ] + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + }, + "translationList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationInfo" + } + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.TranslationInfo": { + "type": "object", + "properties": { + "displayMode": { + "description": "展示方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.DisplayMode" + } + ] + }, + "id": { + "description": "ID", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.VideoCategoryInfo": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "title": { + "description": "APP显示", + "type": "string" + } + } + }, + "proto.VideoData": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoDetailInfo": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "desc": { + "description": "作品简介", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "isFavorite": { + "description": "当前登录用户是否已收藏该视频", + "type": "boolean" + }, + "isLike": { + "description": "当前登录用户是否已点赞该视频", + "type": "boolean" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "video": { + "description": "视频播放信息", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoInfo": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoInfoData": { + "type": "object", + "properties": { + "actressInfo": { + "description": "女优信息", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ActressInfoData" + } + }, + "addedTime": { + "description": "帖子上架时间", + "type": "string" + }, + "advitiseId": { + "description": "广告Id", + "type": "string" + }, + "bango": { + "description": "番号,唯一", + "type": "string" + }, + "collectionInfo": { + "description": "合集数据", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoInfoData" + } + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "createInfo": { + "description": "作者信息", + "allOf": [ + { + "$ref": "#/definitions/proto.PostCreateInfo" + } + ] + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "issuedAt": { + "description": "发行时间", + "type": "string" + }, + "issuedBrand": { + "description": "发行商", + "type": "string" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "link": { + "description": "跳转链接", + "type": "string" + }, + "location": { + "description": "广告位置id", + "type": "integer" + }, + "previewVtt": { + "description": "雪碧图 vtt", + "type": "string" + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "$ref": "#/definitions/proto.TagsInfoData" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "type": { + "description": "类型", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoType" + } + ] + }, + "updatedAt": { + "description": "页面更新时间", + "type": "string" + }, + "video": { + "description": "Nodes []videoinformationmod.Node `json:\"nodes\"` // 节点", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "watchTimes": { + "description": "观看次数", + "type": "integer" + } + } + }, + "proto.VideoRankResp": { + "type": "object", + "properties": { + "bottonTranslations": { + "description": "底部翻译", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + }, + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + }, + "translations": { + "description": "导航", + "allOf": [ + { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + ] + } + } + }, + "proto.VideoRecommendItemType": { + "type": "integer", + "enum": [ + 0, + 2 + ], + "x-enum-comments": { + "VideoRecommendItemTypeAdvertise": "广告数据", + "VideoRecommendItemTypeVideo": "视频数据" + }, + "x-enum-varnames": [ + "VideoRecommendItemTypeVideo", + "VideoRecommendItemTypeAdvertise" + ] + }, + "proto.VideoRecommendListItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "advertiseId": { + "description": "广告ID,type=2时返回", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "likeNum": { + "description": "点赞(收藏)数(真假点赞(收藏)数之和)", + "type": "integer" + }, + "link": { + "description": "广告跳转链接,type=2时返回", + "type": "string" + }, + "location": { + "description": "广告位置ID,type=2时返回", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签id", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "数据类型 0-视频数据 2-广告", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoRecommendItemType" + } + ] + }, + "watchNum": { + "description": "观看数(真假观看数之和)", + "type": "integer" + } + } + }, + "proto.VideoRecommendListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.VideoType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "VideoTypeAdvitise": "广告", + "VideoTypeCollection": "合集", + "VideoTypeVideo": "视频", + "VideoType_AV": "AV视频" + }, + "x-enum-varnames": [ + "VideoTypeVideo", + "VideoTypeCollection", + "VideoTypeAdvitise", + "VideoType_AV" + ] + }, + "proxy.AdvitiseInfo": { + "type": "object", + "properties": { + "id": { + "description": "广告id", + "type": "string" + }, + "images": { + "description": "视频banner", + "type": "string" + }, + "link": { + "description": "链接", + "type": "string" + }, + "location": { + "description": "位置id", + "type": "integer" + }, + "title": { + "description": "广告标题", + "type": "string" + } + } + }, + "pubdef.CommentObjectType": { + "type": "integer", + "enum": [ + 0, + 4 + ], + "x-enum-comments": { + "CommentObjectTypePlaylet": "短剧", + "CommentObjectTypeVideo": "视频" + }, + "x-enum-varnames": [ + "CommentObjectTypeVideo", + "CommentObjectTypePlaylet" + ] + }, + "pubdef.DisplayMode": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "DisplayModeAvatarList": "头像列表", + "DisplayModeBigCover": "大封面列表(一大图两列)", + "DisplayModeCardCarousel": "卡片层叠轮播", + "DisplayModeDefault": "默认(上图下标题多列)", + "DisplayModeImageText": "图文列表(左图右标题多列)" + }, + "x-enum-varnames": [ + "DisplayModeDefault", + "DisplayModeCardCarousel", + "DisplayModeAvatarList", + "DisplayModeImageText", + "DisplayModeBigCover" + ] + }, + "pubdef.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeMobile": "手机登陆", + "LoginTypeQrCode": "二维码登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeMobile", + "LoginTypeQrCode", + "LoginTypeAccount" + ] + }, + "pubdef.SeoValue": { + "type": "object", + "properties": { + "description": { + "description": "网站SEO描述", + "type": "string" + }, + "keyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "网站SEO标题", + "type": "string" + } + } + }, + "pubdef.TranslationType": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "TranslationTypeBottom": "底部导航", + "TranslationTypeSub": "二级导航", + "TranslationTypeUrl": "URL模式" + }, + "x-enum-varnames": [ + "TranslationTypeUrl", + "TranslationTypeSub", + "TranslationTypeBottom" + ] + }, + "pubdef.UserStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "UserStatusDisable": "封禁", + "UserStatusNormal": "正常", + "UserStatusSlient": "禁言" + }, + "x-enum-varnames": [ + "UserStatusNormal", + "UserStatusDisable", + "UserStatusSlient" + ] + }, + "questionnairemod.Attachment": { + "type": "object", + "properties": { + "fileType": { + "description": "附件类型(img / video)", + "type": "string" + }, + "url": { + "description": "附件URL地址", + "type": "string" + } + } + }, + "uploadser.ImageUploadData": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sourceNameFs": { + "type": "string" + } + } + }, + "uploadser.ImageUploadResp": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/uploadser.ImageUploadData" + }, + "hash": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "time": { + "type": "string" + }, + "tip": { + "type": "string" + } + } + }, + "uploadser.VideoBodyData": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sourceNameFs": { + "type": "string" + }, + "taskId": { + "type": "string" + } + } + }, + "uploadser.VideoBodyResp": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/uploadser.VideoBodyData" + }, + "hash": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "time": { + "type": "string" + }, + "tip": { + "type": "string" + } + } + }, + "uploadser.VideoOkResp": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "msg": { + "type": "string" + }, + "tip": { + "type": "string" + } + } + }, + "videoinformationmod.VideoInfo": { + "type": "object", + "properties": { + "cover": { + "description": "视频封面", + "type": "string" + }, + "fileId": { + "description": "媒资库ID", + "type": "string" + }, + "h265Url": { + "description": "H.265 m3u8 播放地址,空字符串表示暂无该编码源", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "H.264 m3u8 播放地址(始终存在)", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "BasicAuth": { + "type": "basic" + }, + "OAuth2AccessCode": { + "type": "oauth2", + "flow": "accessCode", + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information" + } + }, + "OAuth2Application": { + "type": "oauth2", + "flow": "application", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Implicit": { + "type": "oauth2", + "flow": "implicit", + "authorizationUrl": "https://example.com/oauth/authorize", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Password": { + "type": "oauth2", + "flow": "password", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "read": "Grants read access", + "write": "Grants write access" + } + } + } +} \ No newline at end of file diff --git a/app/docs/swagger.yaml b/app/docs/swagger.yaml new file mode 100644 index 0000000..b865749 --- /dev/null +++ b/app/docs/swagger.yaml @@ -0,0 +1,4417 @@ +basePath: / +definitions: + actress.Meas: + properties: + blood: + description: 血型 + type: string + bust: + description: 胸围 英寸 + type: integer + cup: + description: 罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I + type: integer + dia: + description: 直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45) + type: integer + gender: + description: 性别 0:女 1:男 2:其他 + type: integer + height: + description: 身高 厘米 + type: integer + hip: + description: 臀围 英寸 + type: integer + len: + description: 长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145) + type: integer + waist: + description: 腰围 英寸 + type: integer + weight: + description: 体重 公斤 + type: integer + type: object + authapi.guestLoginReq: + properties: + devID: + description: 设备 ID(首选) + type: string + deviceId: + description: 设备 ID 第二版本(备用) + type: string + mac: + description: 设备 MAC 地址(备用) + type: string + type: object + authapi.loginReq: + properties: + account: + description: 账号 + type: string + password: + description: 明文密码 + type: string + required: + - account + - password + type: object + authapi.registerReq: + properties: + accountNo: + description: 账号 + type: string + agentInviteCode: + description: 邀请人的邀请码(可选) + type: string + password: + description: 明文密码 + type: string + required: + - accountNo + - password + type: object + authapi.updateProfileReq: + properties: + accountNo: + description: 账号(必填,用于定位用户) + type: string + avatar: + description: 新头像 URL(可选) + type: string + newPassword: + description: 新密码明文(修改密码时必填) + type: string + nikeName: + description: 昵称 + type: string + oldPassword: + description: 旧密码明文(修改密码时必填) + type: string + required: + - accountNo + type: object + authser.LoginResp: + properties: + accountNo: + type: string + avatar: + description: 头像 + type: string + inviteCode: + description: 邀请码 + type: string + isNewUser: + description: 是否本次请求新创建的用户 + type: boolean + isRegister: + description: 是否已完成账号注册 + type: boolean + loginType: + allOf: + - $ref: '#/definitions/pubdef.LoginType' + description: 登录方式 0=设备 3=账号 + nickName: + type: string + recharge: + description: 当前金币个数 + type: integer + status: + $ref: '#/definitions/pubdef.UserStatus' + token: + type: string + userId: + type: integer + type: object + authser.UserInfoResp: + properties: + accountNo: + type: string + avatar: + type: string + inviteCode: + type: string + isRegister: + type: boolean + loginType: + $ref: '#/definitions/pubdef.LoginType' + nickName: + type: string + recharge: + type: integer + status: + $ref: '#/definitions/pubdef.UserStatus' + userId: + type: integer + type: object + contenttagmod.ContentTagType: + enum: + - 1 + - 5 + type: integer + x-enum-comments: + ContentTagTypePlaylet: 短剧 + ContentTagTypeVideo: 视频 + x-enum-varnames: + - ContentTagTypeVideo + - ContentTagTypePlaylet + playletchaptermod.PayType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + PayTypeCoin: 金币 + PayTypeFree: 免费 + PayTypeVip: VIP + x-enum-varnames: + - PayTypeFree + - PayTypeCoin + - PayTypeVip + playletchaptermod.WatermarkStatus: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + WatermarkStatusDone: 已完成 + WatermarkStatusFailed: 处理失败 + WatermarkStatusPending: 未处理(待 mediafile/sync) + WatermarkStatusProcessing: 处理中(已提交加水印任务,待回写) + WatermarkStatusRegistered: 已登记(原片已登记,待 task/add) + x-enum-varnames: + - WatermarkStatusPending + - WatermarkStatusRegistered + - WatermarkStatusProcessing + - WatermarkStatusDone + - WatermarkStatusFailed + playletmod.ContentType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + ContentTypeAlbum: 普通短剧/影集(对应 media_type=6) + ContentTypeAnime: 动漫(对应 media_type=1) + ContentTypeComic: 漫画短剧(对应 media_type=4) + ContentTypeManga: 漫画(对应 media_type=2) + ContentTypeTheater: 短剧(对应 media_type=5) + x-enum-varnames: + - ContentTypeTheater + - ContentTypeAlbum + - ContentTypeComic + - ContentTypeAnime + - ContentTypeManga + playletmod.PayType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + PayTypeCoin: 金币 + PayTypeFree: 免费 + PayTypeVip: VIP + x-enum-varnames: + - PayTypeFree + - PayTypeCoin + - PayTypeVip + playletmod.Status: + enum: + - 0 + - 1 + type: integer + x-enum-comments: + StatusDown: 下架 + StatusUp: 上架 + x-enum-varnames: + - StatusDown + - StatusUp + proto.ActressInfo: + properties: + alias: + description: 别名 + type: string + avatar: + description: 头像 + type: string + hotValue: + description: 热度值 + type: integer + id: + description: Id + type: integer + slug: + description: slug + type: string + works: + description: 作品数 + type: integer + type: object + proto.ActressInfoData: + properties: + alias: + description: 别名 + type: string + avatar: + description: 头像 + type: string + bornDate: + description: 出生日期 + type: string + comments: + description: 评论条数 + type: integer + cover: + description: 封面大图 + type: string + debutDate: + description: 出道日期 + type: string + desc: + description: 详细描述 + type: string + hotValue: + description: 热度值 + type: integer + id: + description: Id + type: integer + meas: + allOf: + - $ref: '#/definitions/actress.Meas' + description: 三围 + measValue: + description: 三围值 + type: string + office: + description: 事务所 + type: string + region: + description: 地区/国家 + type: string + score: + description: 评分 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showTimes: + description: 展示次数 + type: integer + slug: + description: slug + type: string + watchTimes: + description: 观看次数 + type: integer + works: + description: 作品数 + type: integer + type: object + proto.AdvitiseData: + properties: + AList: + description: 固定12广告位 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + BList: + description: 固定13广告位 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + detailTopList: + description: 详情页顶部广告 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + detailbannerList: + description: 详情banner + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + dialogList: + description: 弹窗广告 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + floatingList: + description: 悬浮小方块 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + friendList: + description: 友链 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + homeList: + description: 首页列表 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + launchScreenList: + description: 启动页广告 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + mustAppList: + description: 必备精品 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + nineGridList: + description: 9宫格 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + postList: + description: 列表穿插广告(视频、短剧、女优列表使用) + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + titleAppList: + description: 标题广告(APP名称) + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + videoList: + description: 视频列表 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + type: object + proto.AnnouncementInfo: + properties: + appAddr: + description: 吃瓜app (免费吃瓜 每日更新) + type: string + foreverUrlAddr: + description: 永久海外地址(翻墙访问 永不迷路) + type: string + newUrlAddr: + description: 新国内地址 + type: string + newUrlAddr1: + description: 备用地址1 + type: string + newUrlAddr2: + description: 备用地址1 + type: string + pdfAddr: + description: 回家的路PDF + type: string + translationAddr: + description: 应用导航 + type: string + videoInfo: + description: 热门信息 + items: + $ref: '#/definitions/proto.VideoInfoData' + type: array + type: object + proto.AppCategoryInfo: + properties: + appList: + description: 应用列表 + items: + $ref: '#/definitions/proto.AppInfoData' + type: array + code: + description: 分类编码(特殊值:12=广告位A 13=广告位B) + type: integer + id: + description: 分类ID + type: integer + intro: + description: 分类简介 + type: string + sort: + description: 排序 + type: integer + title: + description: 分类标题 + type: string + type: object + proto.AppContentTagItem: + properties: + bindCount: + description: 当前查询类型绑定数量,不传type时为各类型绑定数量合计 + type: integer + id: + description: 标签ID + type: integer + name: + description: 标签名称 + type: string + playletCount: + description: 绑定短剧数量 + type: integer + rank: + description: 排序 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + types: + description: 支持类型 1-视频 5-短剧 + items: + $ref: '#/definitions/contenttagmod.ContentTagType' + type: array + videoCount: + description: 绑定视频数量 + type: integer + type: object + proto.AppContentTagListResp: + properties: + list: + description: 统一标签列表 + items: + $ref: '#/definitions/proto.AppContentTagItem' + type: array + type: object + proto.AppInfoData: + properties: + advitiseId: + description: 广告ID(广告条目时返回) + type: string + categoryId: + description: 所属分类ID + type: integer + description: + description: 详细描述 + type: string + id: + description: 应用ID + type: integer + intro: + description: 简介 + type: string + location: + description: 广告位置ID + type: integer + logo: + description: Logo 图片地址 + type: string + name: + description: 应用名称 + type: string + sort: + description: 排序 + type: integer + url: + description: 跳转链接(已含渠道码) + type: string + type: object + proto.AppListResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + category: + allOf: + - $ref: '#/definitions/proto.AppCategoryInfo' + description: 当前分类(含应用列表) + categoryList: + description: 应用分类列表(含各分类下的应用列表) + items: + $ref: '#/definitions/proto.AppCategoryInfo' + type: array + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + path: + description: 当前路径 + type: string + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.AppRichTextItemResp: + properties: + content: + description: 内容 + type: string + desc: + description: 标题(app) + type: string + id: + description: 富文本ID + type: integer + title: + description: 标题 + type: string + type: object + proto.AppRichTextListResp: + properties: + list: + description: 富文本内容列表 + items: + $ref: '#/definitions/proto.AppRichTextItemResp' + type: array + type: object + proto.AppTranslationInfo: + properties: + categoryList: + items: + $ref: '#/definitions/proto.TranslationCategory' + type: array + type: object + proto.CommentInfo: + properties: + avatar: + description: 用户头像 + type: string + commentCount: + description: 该评论回复数量 + type: integer + createdAt: + description: 创建时间 + type: string + id: + description: 评论ID + type: integer + level: + description: 评论级别 + type: integer + objectId: + description: 对象ID + type: integer + parentsId: + description: 上级评论ID + type: integer + replyUserName: + description: 回复名称 + type: string + subComment: + description: 子评论 + items: + $ref: '#/definitions/proto.CommentInfo' + type: array + text: + description: 评论内容 + type: string + userName: + description: 用户名称 + type: string + type: object + proto.CommentListResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.CommentSendReq: + properties: + objectId: + description: 对象ID + type: integer + objectType: + allOf: + - $ref: '#/definitions/pubdef.CommentObjectType' + description: 对象类型 0=视频 4=短剧 + parentsId: + description: 上级评论ID + type: integer + text: + description: 评论内容 + type: string + type: object + proto.ConfigInfo: + properties: + appAddr: + description: APP下载地址(落地页) + type: string + channelCode: + description: 渠道码 + type: string + dhDescription: + description: 导航SEO描述 + type: string + dhKeyworld: + description: 导航SEO关键字 + items: + type: string + type: array + dhNewUrlAddr1: + description: 导航页-线路1 + type: string + dhNewUrlAddr2: + description: 导航页-线路2 + type: string + dhNewUrlAddr3: + description: 导航页-线路3 + type: string + dhSiteName: + description: 导航SEO网站名称 + type: string + dhTitle: + description: 导航SEO标题 + type: string + enableH265: + description: 是否允许客户端使用 H265 地址播放 + type: boolean + foreverUrlAddr: + description: 永久地址 + type: string + freeVPN: + description: 免费vpn链接 + type: string + gitAddr: + description: git地址 + type: string + gmailAddr: + description: 邮箱地址 + type: string + imgAppUsCdn: + description: 图片CDN(海外app使用) + type: string + imgCdn: + description: 图片CDN + type: string + m3u8DayRateLimitMax: + description: 每 IP 每天最多不同正片数 + type: integer + mediaAppUsCdn: + description: 视频CDN(海外app使用) + type: string + newUrlAddr: + description: 最新地址 + type: string + newUrlAddr1: + description: 最新地址(备用地址1) + type: string + newUrlAddr2: + description: 最新地址(备用地址2) + type: string + pdfAddr: + description: 回家的路PDF + type: string + potaoGroup: + description: 土豆群 + type: string + qq: + description: qq群 + type: string + reportSdkConfig: + description: 数据上报SDK域名配置 + type: string + telegram: + description: 商务tg + type: string + telegramAdd: + description: 加入我们tg + type: string + tgGroup: + description: tg群 + type: string + translationAddr: + description: 应用导航 + type: string + tsCdn: + description: 视频CDN + type: string + twitterAddr: + description: 推特地址 + type: string + useIngAddress: + description: 所有使用中的域名 + items: + type: string + type: array + webAddr: + description: 网站SEO网站地址 + type: string + webDescription: + description: 网站SEO描述 + type: string + webKeyworld: + description: 网站SEO关键字 + items: + type: string + type: array + webSiteName: + description: 网站SEO网站名称 + type: string + webTitle: + description: 网站SEO标题 + type: string + weiChart: + description: 微信群 + type: string + type: object + proto.ContentData: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + tags: + description: 标签id + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.DHHtmlData: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + completePath: + type: string + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置文件 + hotNavs: + description: 热门导航 + items: + $ref: '#/definitions/proto.HotNavItem' + type: array + hotSearch: + description: 热搜 + items: + $ref: '#/definitions/proto.HotSearchItem' + type: array + path: + type: string + richTextInfo: + allOf: + - $ref: '#/definitions/proto.RichTextInfo' + description: 富文本 + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.DataResp: + properties: + actressInfoList: + description: 女优信息 + items: + $ref: '#/definitions/proto.ActressInfo' + type: array + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + homeBannerList: + description: 首页banner信息列表 + items: + $ref: '#/definitions/proto.HomeBannerInfo' + type: array + path: + description: 当前路径 + type: string + playletData: + allOf: + - $ref: '#/definitions/proto.HomePlayletDataResp' + description: 首页短剧数据 + videoData: + allOf: + - $ref: '#/definitions/proto.HomeVideoDataResp' + description: 首页视频数据 + type: object + proto.FavoriteVideoListItem: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + slug: + description: slug + type: string + tags: + description: 标签id + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.FavoriteVideoReq: + properties: + id: + description: ID + type: integer + isFavorite: + description: 是否收藏,true-收藏、false-取消收藏 + type: boolean + required: + - id + - isFavorite + type: object + proto.FavoriteVideoResp: + properties: + id: + description: ID + type: integer + isFavorite: + description: 是否收藏,true-收藏、false-取消收藏 + type: boolean + type: object + proto.FooterBrand: + properties: + content: + description: 品牌标语 + type: string + title: + description: 品牌标题 + type: string + type: object + proto.FooterContact: + properties: + name: + description: 联系方式名称 + type: string + value: + description: 联系方式值 + type: string + type: object + proto.FooterInfo: + properties: + brand: + allOf: + - $ref: '#/definitions/proto.FooterBrand' + description: 品牌信息 + contactList: + description: 联系方式 + items: + $ref: '#/definitions/proto.FooterContact' + type: array + navList: + description: 底部导航 + items: + $ref: '#/definitions/proto.TranslationCategory' + type: array + type: object + proto.FooterInfoResp: + properties: + footerInfo: + allOf: + - $ref: '#/definitions/proto.FooterInfo' + description: 底部信息 + type: object + proto.HomeBannerInfo: + properties: + id: + description: ID + type: integer + image: + description: 图片,已拼接图片域名 + type: string + name: + description: 名称 + type: string + rank: + description: 排序 + type: integer + status: + description: 状态 true-启用 false-停用 + type: boolean + url: + description: URL + type: string + verticalImage: + description: 竖版图片,已拼接图片域名 + type: string + type: object + proto.HomeBaseResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.HomeHtmlData: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + completePath: + description: 请求路径 + type: string + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置文件 + hotSearch: + description: 热搜 + items: + $ref: '#/definitions/proto.HotSearchItem' + type: array + path: + type: string + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.HomePlayletDataResp: + properties: + hotList: + description: 最热短剧列表 + items: + $ref: '#/definitions/proto.PlayletData' + type: array + newestList: + description: 最新短剧列表 + items: + $ref: '#/definitions/proto.PlayletData' + type: array + recommendList: + description: 推荐短剧列表 + items: + $ref: '#/definitions/proto.PlayletData' + type: array + type: object + proto.HomeRankData: + properties: + month: + description: 月榜 + items: + $ref: '#/definitions/proto.ContentData' + type: array + total: + description: 总榜 + items: + $ref: '#/definitions/proto.ContentData' + type: array + week: + description: 周榜 + items: + $ref: '#/definitions/proto.ContentData' + type: array + year: + description: 年榜 + items: + $ref: '#/definitions/proto.ContentData' + type: array + type: object + proto.HomeTagsResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + playletTagsList: + description: 短剧标签 + items: + $ref: '#/definitions/proto.TagsInfoData' + type: array + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + videoTagsList: + description: 视频标签 + items: + $ref: '#/definitions/proto.TagsInfoData' + type: array + type: object + proto.HomeVideoDataResp: + properties: + hotList: + description: 最热视频列表 + items: + $ref: '#/definitions/proto.VideoInfo' + type: array + newestList: + description: 最新视频列表 + items: + $ref: '#/definitions/proto.VideoInfo' + type: array + recommendList: + description: 推荐视频列表 + items: + $ref: '#/definitions/proto.VideoInfo' + type: array + type: object + proto.HotNavItem: + properties: + icon: + description: 图标 + type: string + id: + description: ID + type: integer + intro: + description: 介绍 + type: string + title: + description: 标题 + type: string + url: + description: 链接 + type: string + type: object + proto.HotSearchItem: + properties: + hotValue: + description: 热度值 + type: integer + id: + description: ID + type: integer + title: + description: 标题 + type: string + type: object + proto.LawAndTrustHtmlData: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + completePath: + type: string + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置文件 + hotSearch: + description: 热搜 + items: + $ref: '#/definitions/proto.HotSearchItem' + type: array + path: + type: string + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.LikePlayletReq: + properties: + id: + description: ID + type: integer + isLike: + description: 是否点赞,true-点赞、false-取消点赞 + type: boolean + required: + - id + - isLike + type: object + proto.LikePlayletResp: + properties: + id: + description: ID + type: integer + isLike: + description: 是否点赞,true-点赞、false-取消点赞 + type: boolean + likeNum: + description: 点赞数(真实点赞数加后台虚拟点赞数) + type: integer + type: object + proto.LikeVideoReq: + properties: + id: + description: ID + type: integer + isLike: + description: 是否点赞,true-点赞、false-取消点赞 + type: boolean + required: + - id + - isLike + type: object + proto.LikeVideoResp: + properties: + id: + description: ID + type: integer + isLike: + description: 是否点赞,true-点赞、false-取消点赞 + type: boolean + likeNum: + description: 点赞数(真假点赞数之和) + type: integer + type: object + proto.PlayletChapterInfo: + properties: + countBrowse: + description: 浏览数 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 喜欢数 + type: integer + createdAt: + description: 创建时间 + type: string + currentEpisode: + description: 第几集 + type: integer + hash: + description: 媒体hash + type: string + height: + description: 高 + type: integer + id: + description: 剧集ID + type: integer + md5: + description: 媒体md5 + type: string + mediaContentId: + description: 媒资中心章节ID + type: integer + mediaSize: + description: 资源大小 + type: integer + mediaTime: + description: 影片长度(秒) + type: integer + payType: + allOf: + - $ref: '#/definitions/playletchaptermod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + playletId: + description: 短剧ID + type: integer + ratio: + description: 宽高比 + type: number + singleCoins: + description: 单集金币 + type: integer + title: + description: 章节名 + type: string + updatedAt: + description: 修改时间 + type: string + videoUrl: + description: 视频播放地址,优先返回水印地址 + type: string + watermarkStatus: + allOf: + - $ref: '#/definitions/playletchaptermod.WatermarkStatus' + description: 水印状态 0-未处理 1-已登记 2-处理中 3-已完成 + width: + description: 宽 + type: integer + type: object + proto.PlayletData: + properties: + anchor: + description: 作者或演员 + type: string + contentType: + allOf: + - $ref: '#/definitions/playletmod.ContentType' + description: 内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画 + countBrowse: + description: 累计点击量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 真实点赞数 + type: integer + cover: + description: 封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + displayCountLike: + description: 展示点赞数,真实点赞数加后台虚拟点赞数 + type: integer + heatValue: + description: 热度值 + type: integer + id: + description: 短剧id + type: integer + isNew: + description: 是否上新 本周内上架为true + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + mediaCreatedAt: + description: 媒资中心创建时间 + type: string + mediaUpdatedAt: + description: 媒资中心更新时间 + type: string + onlineAt: + description: 上架时间 + type: string + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + preview: + description: 预告,已拼接图片域名 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + singleCoins: + description: 单集金币,支付方式为金币时生效 + type: integer + slug: + description: slug + type: string + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 状态 0-下架 1-上架 + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + virtualCountLike: + description: 后台虚拟点赞数 + type: integer + type: object + proto.PlayletDetailInfo: + properties: + anchor: + description: 作者或演员 + type: string + contentType: + allOf: + - $ref: '#/definitions/playletmod.ContentType' + description: 内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画 + countBrowse: + description: 累计点击量 + type: integer + countCollect: + description: 收藏数 + type: integer + countComment: + description: 评论数量 + type: integer + countLike: + description: 真实点赞数 + type: integer + cover: + description: 封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + displayCountLike: + description: 展示点赞数,真实点赞数加后台虚拟点赞数 + type: integer + heatValue: + description: 热度值 + type: integer + id: + description: 短剧id + type: integer + isLike: + description: 当前登录用户是否已点赞该短剧 + type: boolean + isNew: + description: 是否上新 本周内上架为true + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + mediaCreatedAt: + description: 媒资中心创建时间 + type: string + mediaUpdatedAt: + description: 媒资中心更新时间 + type: string + onlineAt: + description: 上架时间 + type: string + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + preview: + description: 预告,已拼接图片域名 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + singleCoins: + description: 单集金币,支付方式为金币时生效 + type: integer + slug: + description: slug + type: string + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 状态 0-下架 1-上架 + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + virtualCountLike: + description: 后台虚拟点赞数 + type: integer + type: object + proto.PlayletRankData: + properties: + anchor: + description: 作者或演员 + type: string + contentType: + allOf: + - $ref: '#/definitions/playletmod.ContentType' + description: 内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画 + countBrowse: + description: 累计点击量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 真实点赞数 + type: integer + cover: + description: 封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + displayCountLike: + description: 展示点赞数,真实点赞数加后台虚拟点赞数 + type: integer + heatValue: + description: 热度值 + type: integer + id: + description: 短剧id + type: integer + isNew: + description: 是否上新 本周内上架为true + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + mediaCreatedAt: + description: 媒资中心创建时间 + type: string + mediaUpdatedAt: + description: 媒资中心更新时间 + type: string + onlineAt: + description: 上架时间 + type: string + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + preview: + description: 预告,已拼接图片域名 + type: string + rankScore: + description: 榜单分数,点赞榜为累计展示点赞数,周/月/年榜为对应周期观看数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + singleCoins: + description: 单集金币,支付方式为金币时生效 + type: integer + slug: + description: slug + type: string + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 状态 0-下架 1-上架 + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + virtualCountLike: + description: 后台虚拟点赞数 + type: integer + type: object + proto.PlayletRankResp: + properties: + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.PlayletRecommendItemType: + enum: + - 0 + - 2 + type: integer + x-enum-comments: + PlayletRecommendItemTypeAdvertise: 广告数据 + PlayletRecommendItemTypePlaylet: 短剧数据 + x-enum-varnames: + - PlayletRecommendItemTypePlaylet + - PlayletRecommendItemTypeAdvertise + proto.PlayletRecommendListItem: + properties: + advertiseId: + description: 广告ID,type=2时返回 + type: string + anchor: + description: 作者或演员 + type: string + contentType: + allOf: + - $ref: '#/definitions/playletmod.ContentType' + description: 内容类型 0-短剧 1-普通短剧影集 2-漫画短剧 3-动漫 4-漫画 + countBrowse: + description: 累计点击量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 真实点赞数 + type: integer + cover: + description: 封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + displayCountLike: + description: 展示点赞数,真实点赞数加后台虚拟点赞数 + type: integer + heatValue: + description: 热度值 + type: integer + id: + description: 短剧id + type: integer + isNew: + description: 是否上新 本周内上架为true + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + link: + description: 广告跳转链接,type=2时返回 + type: string + location: + description: 广告位置ID,type=2时返回 + type: integer + mediaCreatedAt: + description: 媒资中心创建时间 + type: string + mediaUpdatedAt: + description: 媒资中心更新时间 + type: string + onlineAt: + description: 上架时间 + type: string + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + preview: + description: 预告,已拼接图片域名 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + singleCoins: + description: 单集金币,支付方式为金币时生效 + type: integer + slug: + description: slug + type: string + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 状态 0-下架 1-上架 + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + type: + allOf: + - $ref: '#/definitions/proto.PlayletRecommendItemType' + description: 数据类型 0-短剧数据 2-广告 + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + virtualCountLike: + description: 后台虚拟点赞数 + type: integer + type: object + proto.PlayletRecommendListResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.PostCreateInfo: + properties: + background: + description: 背景 + type: string + createType: + description: 创作者类型 0=普通 1=官方 + type: integer + createdAt: + description: 创建时间 + type: string + desc: + description: 作者描述 + type: string + id: + type: integer + image: + description: 头像 + type: string + name: + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + works: + type: integer + type: object + proto.QueryActressListResp: + properties: + actressInfoList: + description: 女优列表 + items: + $ref: '#/definitions/proto.ActressInfoData' + type: array + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + path: + description: 当前路径 + type: string + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.QueryActressWorkListResp: + properties: + actressInfo: + allOf: + - $ref: '#/definitions/proto.ActressInfoData' + description: 女优信息 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.QueryFavoriteVideoListResp: + properties: + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.QueryPlayletChapterInfoResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + chapterInfo: + allOf: + - $ref: '#/definitions/proto.PlayletChapterInfo' + description: 当前剧集详情 + chapterList: + description: 剧集列表 + items: + $ref: '#/definitions/proto.PlayletChapterInfo' + type: array + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + hotPlayletList: + description: 热门短剧列表 + items: + $ref: '#/definitions/proto.PlayletData' + type: array + isLike: + description: 当前登录用户是否已点赞该短剧 + type: boolean + path: + description: 当前路径 + type: string + playletInfo: + allOf: + - $ref: '#/definitions/proto.PlayletData' + description: 所属短剧详情 + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.QueryPlayletInfoResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + chapterList: + description: 剧集列表 + items: + $ref: '#/definitions/proto.PlayletChapterInfo' + type: array + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + hotPlayletList: + description: 热门短剧列表 + items: + $ref: '#/definitions/proto.PlayletData' + type: array + isLike: + description: 当前登录用户是否已点赞该短剧 + type: boolean + path: + description: 当前路径 + type: string + playletInfo: + allOf: + - $ref: '#/definitions/proto.PlayletDetailInfo' + description: 短剧详情数据 + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.QueryVideoInfoResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcement: + allOf: + - $ref: '#/definitions/proto.AnnouncementInfo' + description: 公告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置信息 + hotVideoList: + description: 热门视频列表 + items: + $ref: '#/definitions/proto.VideoData' + type: array + isFavorite: + description: 当前登录用户是否已收藏该视频 + type: boolean + isLike: + description: 当前登录用户是否已点赞该视频 + type: boolean + path: + description: 当前路径 + type: string + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + videoInfo: + allOf: + - $ref: '#/definitions/proto.VideoDetailInfo' + description: 视频详情数据 + type: object + proto.QueryVideoItemType: + enum: + - 0 + - 2 + type: integer + x-enum-varnames: + - QueryVideoItemTypeVideo + - QueryVideoItemTypeAdvertise + proto.QueryVideoListItem: + properties: + addedTime: + description: 上架时间 + type: string + advertiseId: + description: 广告ID,type=2时返回 + type: string + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + link: + description: 广告跳转链接,type=2时返回 + type: string + location: + description: 广告位置ID,type=2时返回 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + tags: + description: 标签id + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + type: + allOf: + - $ref: '#/definitions/proto.QueryVideoItemType' + description: 数据类型:0-视频数据、2-广告 + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.QueryVideoResp: + properties: + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + categoryInfo: + allOf: + - $ref: '#/definitions/proto.VideoCategoryInfo' + description: 当前分类信息(含 SEO) + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.QuestionnaireSubmitReq: + properties: + attachments: + description: 附件列表,最多3个(选填) + items: + $ref: '#/definitions/questionnairemod.Attachment' + type: array + city: + description: 所在城市(选填) + type: string + contact: + description: 联系方式(选填) + type: string + description: + description: 问题描述(必填) + type: string + deviceModel: + description: 设备型号(选填) + type: string + deviceType: + description: 设备类型:iPhone、Android、iPad、其他 + type: string + frequency: + description: 出现频率:每次都会、经常出现、偶尔出现、仅现一次 + type: string + networkType: + description: 网络类型:WI-FI、5G、4G、其他 + type: string + operator: + description: 运营商:电信、联通、移动、广电 + type: string + province: + description: 所在省份(必填) + type: string + questionType: + description: 问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题 + type: string + userId: + description: 登录用户ID,0表示匿名(选填) + type: integer + required: + - description + - deviceType + - frequency + - networkType + - operator + - province + - questionType + type: object + proto.RankResp: + properties: + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + rankData: + $ref: '#/definitions/proto.HomeRankData' + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.RichTextInfo: + properties: + content: + description: 内容 + type: string + desc: + description: 标题(app) + type: string + id: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + title: + description: 标题 + type: string + type: object + proto.SearchResp: + properties: + actress: + description: 女优资源 + items: + $ref: '#/definitions/proto.ActressInfoData' + type: array + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部导航 + completePath: + type: string + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 配置文件 + hotSearch: + description: 热搜 + items: + $ref: '#/definitions/proto.HotSearchItem' + type: array + inTitle: + description: 输入标题 + type: string + inType: + description: 输入类型 -1=全部 0=视频 1=女优 3=AV 6=短剧 + type: integer + media: + description: 视频资源 + items: + $ref: '#/definitions/proto.VideoInfoData' + type: array + pageNum: + type: integer + pageSize: + type: integer + path: + type: string + playlets: + description: 短剧资源 + items: + $ref: '#/definitions/proto.PlayletData' + type: array + searchRecommend: + description: 搜索推荐 + items: + type: string + type: array + sorts: + description: 排序选项 + items: + $ref: '#/definitions/proto.SearchSortItem' + type: array + totalSize: + type: integer + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.SearchSortItem: + properties: + key: + description: 值 + type: string + title: + description: 标题 + type: string + type: object + proto.TagsInfoData: + properties: + id: + type: integer + images: + description: 背景 + type: string + name: + description: 帖子标题 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + works: + description: 作品数量 + type: integer + type: object + proto.TranslationCategory: + properties: + categoryType: + allOf: + - $ref: '#/definitions/pubdef.TranslationType' + description: 导航类型 + icon: + description: Icon + type: string + id: + type: integer + name: + description: 名称 + type: string + translationList: + items: + $ref: '#/definitions/proto.TranslationInfo' + type: array + urlValue: + description: URl路径 + type: string + type: object + proto.TranslationInfo: + properties: + displayMode: + allOf: + - $ref: '#/definitions/pubdef.DisplayMode' + description: 展示方式 + id: + description: ID + type: integer + isFriendAds: + description: 是否是友链广告位 + type: boolean + name: + description: 名称 + type: string + urlValue: + description: URl路径 + type: string + type: object + proto.VideoCategoryInfo: + properties: + desc: + description: 描述 + type: string + id: + type: integer + name: + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + title: + description: APP显示 + type: string + type: object + proto.VideoData: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + tags: + description: 标签id + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.VideoDetailInfo: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + desc: + description: 作品简介 + type: string + id: + description: id + type: integer + isFavorite: + description: 当前登录用户是否已收藏该视频 + type: boolean + isLike: + description: 当前登录用户是否已点赞该视频 + type: boolean + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + slug: + description: slug + type: string + tags: + description: 标签 + items: + $ref: '#/definitions/proto.TagsInfoData' + type: array + title: + description: 标题 + type: string + video: + allOf: + - $ref: '#/definitions/videoinformationmod.VideoInfo' + description: 视频播放信息 + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.VideoInfo: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + tags: + description: 标签id + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.VideoInfoData: + properties: + actressInfo: + description: 女优信息 + items: + $ref: '#/definitions/proto.ActressInfoData' + type: array + addedTime: + description: 帖子上架时间 + type: string + advitiseId: + description: 广告Id + type: string + bango: + description: 番号,唯一 + type: string + collectionInfo: + description: 合集数据 + items: + $ref: '#/definitions/proto.VideoInfoData' + type: array + coverImg: + description: 封面 + type: string + createInfo: + allOf: + - $ref: '#/definitions/proto.PostCreateInfo' + description: 作者信息 + desc: + description: 详细描述 + type: string + id: + type: integer + isHot: + description: 是否热门 + type: boolean + issuedAt: + description: 发行时间 + type: string + issuedBrand: + description: 发行商 + type: string + keyworld: + description: 关键字 + items: + type: string + type: array + link: + description: 跳转链接 + type: string + location: + description: 广告位置id + type: integer + previewVtt: + description: 雪碧图 vtt + type: string + showTimes: + description: 展示次数 + type: integer + slug: + description: slug + type: string + tags: + description: 标签 + items: + $ref: '#/definitions/proto.TagsInfoData' + type: array + title: + description: 帖子标题 + type: string + type: + allOf: + - $ref: '#/definitions/proto.VideoType' + description: 类型 + updatedAt: + description: 页面更新时间 + type: string + video: + allOf: + - $ref: '#/definitions/videoinformationmod.VideoInfo' + description: Nodes []videoinformationmod.Node `json:"nodes"` // + 节点 + watchTimes: + description: 观看次数 + type: integer + type: object + proto.VideoRankResp: + properties: + bottonTranslations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 底部翻译 + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + translations: + allOf: + - $ref: '#/definitions/proto.AppTranslationInfo' + description: 导航 + type: object + proto.VideoRecommendItemType: + enum: + - 0 + - 2 + type: integer + x-enum-comments: + VideoRecommendItemTypeAdvertise: 广告数据 + VideoRecommendItemTypeVideo: 视频数据 + x-enum-varnames: + - VideoRecommendItemTypeVideo + - VideoRecommendItemTypeAdvertise + proto.VideoRecommendListItem: + properties: + addedTime: + description: 上架时间 + type: string + advertiseId: + description: 广告ID,type=2时返回 + type: string + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + likeNum: + description: 点赞(收藏)数(真假点赞(收藏)数之和) + type: integer + link: + description: 广告跳转链接,type=2时返回 + type: string + location: + description: 广告位置ID,type=2时返回 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + tags: + description: 标签id + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + type: + allOf: + - $ref: '#/definitions/proto.VideoRecommendItemType' + description: 数据类型 0-视频数据 2-广告 + watchNum: + description: 观看数(真假观看数之和) + type: integer + type: object + proto.VideoRecommendListResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.VideoType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + VideoType_AV: AV视频 + VideoTypeAdvitise: 广告 + VideoTypeCollection: 合集 + VideoTypeVideo: 视频 + x-enum-varnames: + - VideoTypeVideo + - VideoTypeCollection + - VideoTypeAdvitise + - VideoType_AV + proxy.AdvitiseInfo: + properties: + id: + description: 广告id + type: string + images: + description: 视频banner + type: string + link: + description: 链接 + type: string + location: + description: 位置id + type: integer + title: + description: 广告标题 + type: string + type: object + pubdef.CommentObjectType: + enum: + - 0 + - 4 + type: integer + x-enum-comments: + CommentObjectTypePlaylet: 短剧 + CommentObjectTypeVideo: 视频 + x-enum-varnames: + - CommentObjectTypeVideo + - CommentObjectTypePlaylet + pubdef.DisplayMode: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + DisplayModeAvatarList: 头像列表 + DisplayModeBigCover: 大封面列表(一大图两列) + DisplayModeCardCarousel: 卡片层叠轮播 + DisplayModeDefault: 默认(上图下标题多列) + DisplayModeImageText: 图文列表(左图右标题多列) + x-enum-varnames: + - DisplayModeDefault + - DisplayModeCardCarousel + - DisplayModeAvatarList + - DisplayModeImageText + - DisplayModeBigCover + pubdef.LoginType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + LoginTypeAccount: 账号登陆 + LoginTypeDevice: 设备登陆 + LoginTypeMobile: 手机登陆 + LoginTypeQrCode: 二维码登陆 + x-enum-varnames: + - LoginTypeDevice + - LoginTypeMobile + - LoginTypeQrCode + - LoginTypeAccount + pubdef.SeoValue: + properties: + description: + description: 网站SEO描述 + type: string + keyworld: + description: 网站SEO关键字 + items: + type: string + type: array + title: + description: 网站SEO标题 + type: string + type: object + pubdef.TranslationType: + enum: + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + TranslationTypeBottom: 底部导航 + TranslationTypeSub: 二级导航 + TranslationTypeUrl: URL模式 + x-enum-varnames: + - TranslationTypeUrl + - TranslationTypeSub + - TranslationTypeBottom + pubdef.UserStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + UserStatusDisable: 封禁 + UserStatusNormal: 正常 + UserStatusSlient: 禁言 + x-enum-varnames: + - UserStatusNormal + - UserStatusDisable + - UserStatusSlient + questionnairemod.Attachment: + properties: + fileType: + description: 附件类型(img / video) + type: string + url: + description: 附件URL地址 + type: string + type: object + uploadser.ImageUploadData: + properties: + id: + type: string + sourceNameFs: + type: string + type: object + uploadser.ImageUploadResp: + properties: + code: + type: integer + data: + $ref: '#/definitions/uploadser.ImageUploadData' + hash: + type: boolean + msg: + type: string + time: + type: string + tip: + type: string + type: object + uploadser.VideoBodyData: + properties: + id: + type: string + sourceNameFs: + type: string + taskId: + type: string + type: object + uploadser.VideoBodyResp: + properties: + code: + type: integer + data: + $ref: '#/definitions/uploadser.VideoBodyData' + hash: + type: boolean + msg: + type: string + time: + type: string + tip: + type: string + type: object + uploadser.VideoOkResp: + properties: + code: + type: integer + msg: + type: string + tip: + type: string + type: object + videoinformationmod.VideoInfo: + properties: + cover: + description: 视频封面 + type: string + fileId: + description: 媒资库ID + type: string + h265Url: + description: H.265 m3u8 播放地址,空字符串表示暂无该编码源 + type: string + height: + description: 分辨率高(视频才会存在) + type: integer + preMp4FileNameFs: + description: 预览MP4文件 + type: string + taskId: + description: 视频ID(视频才会存在) + type: string + unencryptedPreMp4: + description: 不加密的预览MP4 + type: string + url: + description: H.264 m3u8 播放地址(始终存在) + type: string + videoTime: + description: 影片长度 + type: integer + width: + description: 分辨率宽(视频才会存在) + type: integer + type: object +host: 视频站-api.lcncctn.com +info: + contact: {} + description: 这是视频站APP接口服务. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: 视频站APP接口 + version: "1.0" +paths: + /api/app/actress/list: + get: + consumes: + - application/json + description: 返回导航、广告和按热度值降序排列的女优列表 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.QueryActressListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 女优榜单列表 -- by 王鸣 + tags: + - 女优相关接口 + /api/app/actress/work/list: + get: + consumes: + - application/json + description: 查询指定女优详情和作品列表,支持上架时间/观看量/收藏量升降序,每间隔8条作品数据插入一个广告 + parameters: + - description: 上架时间升序/降序(1-升序 2降序) + in: query + name: addedTimeSort + type: integer + - description: 女优ID + in: query + name: id + type: integer + - description: 按收藏(点赞)量升序/降序(1-升序 2降序) + in: query + name: likesSort + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 女优slug,非空时优先按slug解析女优ID + in: query + name: slug + type: string + - description: 按观看量升序/降序(1-升序 2降序) + in: query + name: watchTimesSort + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.QueryActressWorkListResp' + - properties: + list: + items: + allOf: + - $ref: '#/definitions/proto.QueryVideoListItem' + - properties: + VideoInfo: + $ref: '#/definitions/proto.VideoInfo' + type: object + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 指定女优详情和作品列表 -- by 王鸣 + tags: + - 女优相关接口 + /api/app/applist/list: + get: + consumes: + - application/json + description: 返回应用分类列表,每个分类下含应用信息列表;categoryId=0 返回第一个分类 + parameters: + - description: 当前分类ID,0表示默认第一个分类 + in: query + name: categoryId + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.AppListResp' + "400": + description: 失败 + schema: + type: string + summary: 查询应用列表 + tags: + - 应用列表 + /api/app/auth/getInfo: + get: + consumes: + - application/json + description: 根据当前登录 token 查询用户基础信息 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/authser.UserInfoResp' + "400": + description: 失败 + schema: + type: string + summary: 获取当前登录用户信息 + tags: + - 用户认证 + /api/app/auth/login: + post: + consumes: + - application/json + description: 账号均可作为登录凭证 + parameters: + - description: 登录参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/authapi.loginReq' + produces: + - application/json + responses: + "200": + description: 登录成功,返回 token 及用户基本信息 + schema: + $ref: '#/definitions/authser.LoginResp' + "400": + description: 失败 + schema: + type: string + summary: 登录 + tags: + - 用户认证 + /api/app/auth/profile: + post: + consumes: + - application/json + description: 账号必填;avatar、oldPassword+newPassword 均为可选,至少提供一项。修改密码时旧密码和新密码必须同时提供。 + parameters: + - description: 修改资料参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/authapi.updateProfileReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + summary: 修改用户资料(头像 / 密码,可任意组合) + tags: + - 用户认证 + /api/app/auth/register: + post: + consumes: + - application/json + description: 已登录游客传入账号密码完成升级;未登录用户直接注册新账号。 + parameters: + - description: 注册参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/authapi.registerReq' + produces: + - application/json + responses: + "200": + description: 注册成功,返回 token 及用户基本信息 + schema: + $ref: '#/definitions/authser.LoginResp' + "400": + description: 失败 + schema: + type: string + summary: 注册 + tags: + - 用户认证 + /api/app/comment/list: + get: + consumes: + - application/json + description: 按对象查询树形评论列表 + parameters: + - description: 对象ID + in: query + name: objectId + type: integer + - description: 对象类型 0=视频 4=短剧 + enum: + - 0 + - 4 + in: query + name: objectType + type: integer + x-enum-comments: + CommentObjectTypePlaylet: 短剧 + CommentObjectTypeVideo: 视频 + x-enum-varnames: + - CommentObjectTypeVideo + - CommentObjectTypePlaylet + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.CommentListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommentInfo' + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 评论列表 -- by 王鸣 + tags: + - 评论APP + /api/app/comment/send: + post: + consumes: + - application/json + description: 发布评论 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.CommentSendReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 发布评论 -- by 王鸣 + tags: + - 评论APP + /api/app/config/footer/info: + get: + consumes: + - application/json + description: 返回APP底部品牌、底部导航和联系方式 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.FooterInfoResp' + "400": + description: 失败 + schema: + type: string + summary: 查询APP底部信息 -- by 王鸣 + tags: + - app配置相关接口 + /api/app/config/richtext/list: + get: + consumes: + - application/json + description: 返回启用状态的富文本内容,支持按ids或titles过滤 + parameters: + - description: 富文本ID,多个用英文逗号分隔 + in: query + name: ids + type: string + - description: 富文本标题,多个用英文逗号分隔 + in: query + name: titles + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.AppRichTextListResp' + "400": + description: 失败 + schema: + type: string + summary: 查询APP富文本内容列表 -- by 王鸣 + tags: + - app配置相关接口 + /api/app/content/tag/list: + get: + consumes: + - application/json + description: 返回启用且有绑定内容数量的统一标签,contentTagType非必填,按绑定内容数量倒序排序 + parameters: + - description: 内容类型 1-视频 5-短剧,不传返回全部有绑定数量的标签 + enum: + - 1 + - 5 + in: query + name: contentTagType + type: integer + x-enum-comments: + ContentTagTypePlaylet: 短剧 + ContentTagTypeVideo: 视频 + x-enum-varnames: + - ContentTagTypeVideo + - ContentTagTypePlaylet + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.AppContentTagListResp' + "400": + description: 失败 + schema: + type: string + summary: 查询APP统一标签列表 -- by 王鸣 + tags: + - app配置相关接口 + /api/app/home: + get: + consumes: + - application/json + description: 首页分类数据 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.DataResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 首页 --by 旺鸣 + tags: + - 首页 + /api/app/home/base: + get: + consumes: + - application/json + description: 只返回翻译、配置、底部翻译、广告和公告 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeBaseResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 首页公共数据 --by 旺鸣 + tags: + - 首页 + /api/app/home/tags: + get: + consumes: + - application/json + description: 返回视频、短剧的标签信息,以及基础导航、配置、广告和公告 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeTagsResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 首页标签数据 --by 旺鸣 + tags: + - 首页 + /api/app/login/guest: + post: + consumes: + - application/json + description: 按设备 ID 查找已有游客账号,找不到则自动注册。若请求携带有效 token 则优先复用该用户。 + parameters: + - description: 设备信息 + in: body + name: param + required: true + schema: + $ref: '#/definitions/authapi.guestLoginReq' + produces: + - application/json + responses: + "200": + description: 登录成功 + schema: + $ref: '#/definitions/authser.LoginResp' + "400": + description: 失败 + schema: + type: string + summary: 游客登录 + tags: + - 用户认证 + /api/app/navigation/header: + get: + consumes: + - application/json + description: 查询导航类型为URL模式和二级导航的头部导航配置 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.AppTranslationInfo' + "400": + description: 失败 + schema: + type: string + summary: 查询头部导航配置 -- by 王鸣 + tags: + - app导航相关接口 + /api/app/playlet/chapter/info: + get: + consumes: + - application/json + description: 根据剧集ID查询短剧剧集详情、所属短剧、剧集列表、热门短剧列表和点赞状态 + parameters: + - description: 剧集ID + in: query + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.QueryPlayletChapterInfoResp' + - properties: + chapterInfo: + $ref: '#/definitions/proto.PlayletChapterInfo' + chapterList: + items: + $ref: '#/definitions/proto.PlayletChapterInfo' + type: array + hotPlayletList: + items: + $ref: '#/definitions/proto.PlayletData' + type: array + playletInfo: + $ref: '#/definitions/proto.PlayletData' + type: object + "400": + description: 失败 + schema: + type: string + summary: app端短剧剧集详情 -- by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/info: + get: + consumes: + - application/json + description: 查询短剧详情、剧集列表、热门短剧列表和点赞状态 + parameters: + - description: 短剧ID + in: query + name: id + type: integer + - description: 短剧slug,非空时优先按slug解析短剧ID + in: query + name: slug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.QueryPlayletInfoResp' + - properties: + chapterList: + items: + $ref: '#/definitions/proto.PlayletChapterInfo' + type: array + hotPlayletList: + items: + $ref: '#/definitions/proto.PlayletData' + type: array + playletInfo: + $ref: '#/definitions/proto.PlayletDetailInfo' + type: object + "400": + description: 失败 + schema: + type: string + summary: 短剧详情 -- by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/like: + post: + consumes: + - application/json + description: 当前登录用户点赞或取消点赞指定短剧,isLike=true表示点赞,isLike=false表示取消点赞 + parameters: + - description: 点赞或取消点赞请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.LikePlayletReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.LikePlayletResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 点赞或取消点赞短剧 -- by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/rank/list: + get: + consumes: + - application/json + description: 分页查询短剧排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜 + in: query + name: rankPeriod + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletRankResp' + - properties: + list: + items: + $ref: '#/definitions/proto.PlayletRankData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: 短剧排行榜分页列表 -- by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/recommend/list: + get: + consumes: + - application/json + description: 分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告 + parameters: + - description: 短剧分类ID,不传查询全部分类 + in: query + name: categoryId + type: integer + - description: 分类slug,非空时优先按slug解析分类ID + in: query + name: categorySlug + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序方式 1-推荐 2-最新 3-最热 + in: query + name: sortList + type: integer + - description: 标签ID,不传查询全部标签 + in: query + name: tagId + type: integer + - description: 标签slug,非空时优先按slug解析标签ID + in: query + name: tagSlug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletRecommendListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.PlayletRecommendListItem' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端推荐短剧列表 -- by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/tag/{id}: + get: + consumes: + - application/json + description: 根据统一标签ID分页查询短剧列表,带穿插列表广告,每间隔8条短剧数据插入一个广告 + parameters: + - description: 标签ID + in: path + name: id + required: true + type: integer + - description: 页码,默认1 + in: query + name: pageNum + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletRecommendListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.PlayletRecommendListItem' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端根据标签ID查询短剧列表 -- by 王鸣 + tags: + - app短剧相关接口 + /api/app/questionnaire/submit: + post: + consumes: + - application/json + description: 用户提交问题反馈问卷 + parameters: + - description: 提交参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.QuestionnaireSubmitReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + summary: 提交问卷 + tags: + - 问卷调查APP + /api/app/richtext: + get: + description: 从缓存中查询启用状态的富文本信息 + parameters: + - description: 富文本内部标题 + in: query + name: title + required: true + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.RichTextInfo' + "400": + description: 失败 + schema: + type: string + summary: 按 title 查询富文本 + tags: + - 富文本 + /api/app/upload/image: + post: + consumes: + - multipart/form-data + description: 将图片转发至存储服务 /media/web/autopublish/image + parameters: + - description: 图片文件 + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/uploadser.ImageUploadResp' + "400": + description: 参数错误 + schema: + type: string + summary: 图片上传 + tags: + - 上传 + /api/app/upload/videoBody: + post: + consumes: + - multipart/form-data + description: 将视频分片转发至存储服务 /media/web/autopublish/videoBody + parameters: + - description: 视频任务ID + in: formData + name: taskId + required: true + type: string + - description: 当前分片序号,从0开始 + in: formData + name: pos + required: true + type: string + - description: 上传用户名,为空默认 questionnaires + in: formData + name: uploadUserName + type: string + - description: 分片文件数据 + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/uploadser.VideoBodyResp' + "400": + description: 参数错误 + schema: + type: string + summary: 视频分片上传 + tags: + - 上传 + /api/app/upload/videoOk: + post: + consumes: + - application/json + description: 所有分片上传完毕后调用,转发至存储服务 /media/web/autopublish/videoOk + parameters: + - description: '{ \' + in: body + name: param + required: true + schema: + type: object + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/uploadser.VideoOkResp' + "400": + description: 参数错误 + schema: + type: string + summary: 视频分片上传完成通知 + tags: + - 上传 + /api/app/video/favorite: + post: + consumes: + - application/json + description: 当前登录用户收藏或取消收藏指定视频,isFavorite=true表示收藏,isFavorite=false表示取消收藏 + parameters: + - description: 收藏或取消收藏请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.FavoriteVideoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.FavoriteVideoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 收藏或取消收藏视频 --by 旺鸣 + tags: + - 视频相关接口 + /api/app/video/favorite/list: + get: + consumes: + - application/json + description: 当前登录用户收藏视频分页列表,按收藏时间倒序排序 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.QueryFavoriteVideoListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.FavoriteVideoListItem' + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 当前登录用户收藏视频分页列表 --by 旺鸣 + tags: + - 视频相关接口 + /api/app/video/info: + get: + consumes: + - application/json + description: 查询视频详情、热门视频列表、收藏状态和点赞状态 + parameters: + - in: query + name: id + type: integer + - description: 视频slug,非空时优先按slug解析视频ID + in: query + name: slug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.QueryVideoInfoResp' + - properties: + hotVideoList: + items: + $ref: '#/definitions/proto.VideoData' + type: array + videoInfo: + $ref: '#/definitions/proto.VideoDetailInfo' + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 视频详情 -- by 王鸣 + tags: + - 视频相关接口 + /api/app/video/like: + post: + consumes: + - application/json + description: 当前登录用户点赞或取消点赞指定视频,isLike=true表示点赞,isLike=false表示取消点赞 + parameters: + - description: 点赞或取消点赞请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.LikeVideoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.LikeVideoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 点赞或取消点赞视频 -- by 王鸣 + tags: + - 视频相关接口 + /api/app/video/more: + get: + consumes: + - application/json + description: 根据分类id查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告 + parameters: + - description: 视频分类id + in: query + name: categoryId + type: integer + - description: 分类slug,非空时优先按slug解析分类ID + in: query + name: categorySlug + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.QueryVideoResp' + - properties: + list: + items: + allOf: + - $ref: '#/definitions/proto.QueryVideoListItem' + - properties: + VideoInfo: + $ref: '#/definitions/proto.VideoInfo' + type: object + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 根据分类id查询视频列表 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/rank: + get: + consumes: + - application/json + description: 视频收藏排行榜 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.RankResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 视频收藏排行榜 + tags: + - 视频相关接口 + /api/app/video/rank/list: + get: + consumes: + - application/json + description: 分页查询视频排行榜,rankPeriod=0点赞榜(累计点赞)、1周榜/2月榜/3年榜(周期观看量),独立缓存10分钟,每榜最多50条,pageSize不是50时强制为50 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 榜单类型 0-点赞榜 1-周榜 2-月榜 3-年榜 + in: query + name: rankPeriod + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.VideoRankResp' + - properties: + list: + items: + $ref: '#/definitions/proto.ContentData' + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 视频排行榜分页列表 -- by 王鸣 + tags: + - 视频相关接口 + /api/app/video/recommend/list: + get: + consumes: + - application/json + description: 分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告 + parameters: + - description: 分类id,不传查询全部分类id + in: query + name: categoryId + type: integer + - description: 分类slug,非空时优先按slug解析分类ID + in: query + name: categorySlug + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序方式 1-推荐 2-最新 3-最热 + in: query + name: sortList + type: integer + - description: 标签ID,不传查询全部标签 + in: query + name: tagId + type: integer + - description: 标签slug,非空时优先按slug解析标签ID + in: query + name: tagSlug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.VideoRecommendListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.VideoRecommendListItem' + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: app端推荐视频列表 -- by 王鸣 + tags: + - 视频相关接口 + /api/app/video/tag/{id}: + get: + consumes: + - application/json + description: 根据统一标签ID分页查询视频列表,带穿插列表广告,每间隔8条视频数据插入一个广告 + parameters: + - description: 标签ID + in: path + name: id + required: true + type: integer + - description: 页码,默认1 + in: query + name: pageNum + type: integer + - description: 每页数量,默认20,最大100 + in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.VideoRecommendListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.VideoRecommendListItem' + type: array + type: object + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: app端根据标签ID查询视频列表 -- by 王鸣 + tags: + - 视频相关接口 + /businessCooperation: + get: + consumes: + - application/json + description: 导航 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 导航 + tags: + - 导航 + /consulting: + get: + consumes: + - application/json + description: 招募 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 招募 + tags: + - 招募 + /consultinginfo/:id: + get: + consumes: + - application/json + description: 招募详情 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 招募详情 + tags: + - 招募详情 + /dh: + get: + consumes: + - application/json + description: 导航 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.DHHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 导航 + tags: + - 导航 + /dmca: + get: + consumes: + - application/json + description: dmca + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.LawAndTrustHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: dmca + tags: + - dmca + /privacyPolicy: + get: + consumes: + - application/json + description: 隐私策略 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.LawAndTrustHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 隐私策略 + tags: + - 隐私策略 + /search: + get: + consumes: + - application/json + description: '搜索,支持查询参数:title=关键词&page=1&type=0&sort=default(不传type=全部;type: + 0=视频 1=女优 3=AV 6=短剧;sort: default/hot/new)' + parameters: + - description: 搜索关键词 + in: query + name: title + type: string + - description: 页码,默认1 + in: query + name: page + type: integer + - description: 媒体类型,不传=全部,0=视频 1=女优 3=AV 6=短剧 + in: query + name: type + type: integer + - description: 排序方式 default/hot/new,默认default + in: query + name: sort + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.SearchResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 搜索 + tags: + - 搜索 + /servicesProject: + get: + consumes: + - application/json + description: 导航 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 导航 + tags: + - 导航 + /statement: + get: + consumes: + - application/json + description: 合规声明 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.LawAndTrustHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 合规声明 + tags: + - 合规声明 + /useragreement: + get: + consumes: + - application/json + description: 用户协议 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.LawAndTrustHtmlData' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 用户协议 + tags: + - 用户协议 +schemes: +- http +- https +securityDefinitions: + ApiKeyAuth: + in: header + name: Authorization + type: apiKey + BasicAuth: + type: basic + OAuth2AccessCode: + authorizationUrl: https://example.com/oauth/authorize + flow: accessCode + scopes: + admin: Grants read and write access to administrative information + tokenUrl: https://example.com/oauth/token + type: oauth2 + OAuth2Application: + flow: application + scopes: + admin: Grants read and write access to administrative information + write: Grants write access + tokenUrl: https://example.com/oauth/token + type: oauth2 + OAuth2Implicit: + authorizationUrl: https://example.com/oauth/authorize + flow: implicit + scopes: + admin: Grants read and write access to administrative information + write: Grants write access + type: oauth2 + OAuth2Password: + flow: password + scopes: + admin: Grants read and write access to administrative information + read: Grants read access + write: Grants write access + tokenUrl: https://example.com/oauth/token + type: oauth2 +swagger: "2.0" diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..c77ead9 --- /dev/null +++ b/app/main.go @@ -0,0 +1,55 @@ +//go:generate swag init --parseDependency --parseDepth 4 +//go:generate mtool replicate-module -m contest +package main + +import ( + "os" + "videoweb/app/server" +) + +// @title 视频站APP接口 +// @version 1.0 +// @description 这是视频站APP接口服务. +// @termsOfService + +// @contact.name +// @contact.url +// @contact.email + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host 视频站-api.lcncctn.com +// @BasePath / +// @Schemes http https + +// @securityDefinitions.basic BasicAuth + +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization + +// @securitydefinitions.oauth2.application OAuth2Application +// @tokenUrl https://example.com/oauth/token +// @scope.write Grants write access +// @scope.admin Grants read and write access to administrative information + +// @securitydefinitions.oauth2.implicit OAuth2Implicit +// @authorizationUrl https://example.com/oauth/authorize +// @scope.write Grants write access +// @scope.admin Grants read and write access to administrative information + +// @securitydefinitions.oauth2.password OAuth2Password +// @tokenUrl https://example.com/oauth/token +// @scope.read Grants read access +// @scope.write Grants write access +// @scope.admin Grants read and write access to administrative information + +// @securitydefinitions.oauth2.accessCode OAuth2AccessCode +// @tokenUrl https://example.com/oauth/token +// @authorizationUrl https://example.com/oauth/authorize +// @scope.admin Grants read and write access to administrative information + +func main() { + server.Run(os.Args) +} diff --git a/app/middleware/auth/http.go b/app/middleware/auth/http.go new file mode 100644 index 0000000..4378329 --- /dev/null +++ b/app/middleware/auth/http.go @@ -0,0 +1,140 @@ +package auth + +import ( + "net/http" + "strings" + "videoweb/app/appg" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + + "github.com/gin-gonic/gin" +) + +// gin 验证中间件 + +var ( + // 白名单,不需要验证token的api + whiteList = []string{ + "/swagger", + "/api/app/ping/check", + "/api/app/ping/config", + "/api/app/health/ping", + "/api/app/login/guest", + "/api/app/auth/register", + "/api/app/auth/login", + "/api/app/user/qrcode/info", + "/api/app/upload/images", + "/api/app/home", + "/api/app/richtext", + "/api/app/content/tag/list", + "/api/app/config/footer/info", + "/api/app/config/richtext/list", + "/api/app/navigation/header", + "/api/app/comment/list", + "/api/app/video/more", + "/api/app/video/list", + "/api/app/video/category/list", + "/api/app/video/info", + "/api/app/video/recommend/list", + "/api/app/video/guess/list", + "/api/app/video/rank/list", + "/api/app/playlet/recommend/list", + "/api/app/playlet/guess/list", + "/api/app/playlet/home", + "/api/app/playlet/home-config/list", + "/api/app/playlet/topic/detail", + "/api/app/playlet/theme/list", + "/api/app/playlet/list", + "/api/app/playlet/content/list", + "/api/app/playlet/tag/", + "/api/app/playlet/info", + "/api/app/playlet/chapter/info", + "/api/app/playlet/rank/list", + "/api/app/actress", + "/api/app/upload/videoBody", + "/api/app/upload/videoOk", + "/api/app/upload/image", + "/search", + "/api/app/applist", + "/h5/m3u8", + "/enkey", + "/newenkey", + "/lsjenkey", + "/dh", + "/businessCooperation/", + "/servicesProject/", + "/consulting/", + "/consultinginfo/", + } +) + +var ( + tokener = utils.NewTokenRedis() +) + +func Auth(ctx *gin.Context) { + dotoken := func() srvdef.CodeType { + token := ctx.Request.Header.Get("Authorization") + // 去掉Bearer,如果有 + token = utils.StripBearerPrefixFromTokenString(token) + + if token == "" { + for _, w := range whiteList { + if strings.Contains(ctx.Request.URL.Path, w) { + return srvdef.Success + } + } + + log.Error("Auth:token is empty", log.Any("sysType", ctx.GetString(consts.CtxSysType))) + return srvdef.InvalidToken + } + + userId, newToken, err := utils.ParseUserToken(token, appg.Conf.JwtKey) + if err != nil { + for _, w := range whiteList { + if strings.Contains(ctx.Request.URL.Path, w) { + return srvdef.Success + } + } + + log.Error("Auth:ParseUserToken", log.Any("token", token), log.E(err)) + return srvdef.InvalidToken + } + + has, err := tokener.Exists(userId, token) + if err != nil { + log.Error("Auth:Exists", log.Any("userId", userId), log.Any("token", token), log.E(err)) + return srvdef.InvalidToken + } + if !has { + log.Error("Auth:Exists:token not exists", log.Any("userId", userId), log.Any("token", token), log.E(err)) + return srvdef.InvalidToken + } + + if len(newToken) > 0 { + err = tokener.Save(userId, newToken) + if err != nil { + log.Error("Auth:Save", log.Any("userId", userId), log.Any("token", newToken), log.E(err)) + return srvdef.StoreLevel + } + ctx.Writer.Header().Set("Refresh-Authorization", newToken) + } + + ctx.Set(consts.CtxUserID, userId) + ctx.Set(consts.CtxToken, token) + + return srvdef.Success + } + + errType := dotoken() + if errType != srvdef.Success { + // log.Info("Auth bad request", log.Any("Path", ctx.Request.URL.Path), log.Any("ip", ctx.ClientIP())) + ctx.AbortWithStatusJSON(http.StatusOK, gin.H{ + "code": errType, + "msg": errType.Msg(), + }) + } + +} diff --git a/app/middleware/cors/cors.go b/app/middleware/cors/cors.go new file mode 100644 index 0000000..e1fc226 --- /dev/null +++ b/app/middleware/cors/cors.go @@ -0,0 +1,135 @@ +package cors + +import ( + "github.com/gin-gonic/gin" + "net/http" + "strconv" + "strings" +) + +type Options struct { + Origin *[]string //传入空指针,表示允许"*", 传入空数组表示禁止跨域 + Methods []string //Methods + AllowedHeaders []string //AllowHeaders + ExposedHeaders []string //exposeHeader + Credentials bool //cookie + MaxAge int64 //缓存MaxAge + PreflightContinue bool //在遇到Options的时候,继续,而不是返回请求 + OptionsSuccessStatus int //Options时,返回的状态码,默认204 +} + +func configureOrigin(o *Options, c *gin.Context) map[string]string { + reqOrigin := c.GetHeader("Origin") + headers := make(map[string]string) + if o.Origin == nil { + headers["Access-Control-Allow-Origin"] = "*" + } else { + isAllowed := false + for _, allowed := range *o.Origin { + if allowed == reqOrigin { + isAllowed = true + break + } + } + if isAllowed { + headers["Access-Control-Allow-Origin"] = reqOrigin + } else { + headers["Access-Control-Allow-Origin"] = strconv.FormatBool(false) + } + headers["Vary"] = "Origin" + } + return headers +} + +func configureMethods(o *Options) map[string]string { + headers := make(map[string]string) + if len(o.Methods) > 0 { + headers["Access-Control-Allow-Methods"] = strings.ToUpper(strings.Join(o.Methods, ",")) + } + return headers +} + +func configureCredentials(o *Options) map[string]string { + headers := make(map[string]string) + if o.Credentials { + headers["Access-Control-Allow-Credentials"] = strconv.FormatBool(true) + } + return headers +} + +func configureAllowedHeaders(o *Options, c *gin.Context) map[string]string { + headers := make(map[string]string) + if len(o.AllowedHeaders) > 0 { + headers["Access-Control-Allow-Headers"] = strings.Join(o.AllowedHeaders, ",") + } + return headers +} + +func configureExposedHeaders(o *Options) map[string]string { + var headers = make(map[string]string) + if len(o.ExposedHeaders) > 0 { + headers["Access-Control-Expose-Headers"] = strings.Join(o.ExposedHeaders, ",") + } + return headers +} + +func configureMaxAge(o *Options) map[string]string { + var headers = make(map[string]string) + if o.MaxAge >= 0 { + headers["Access-Control-Max-Age"] = strconv.FormatInt(o.MaxAge, 10) + } + return headers +} + +func mergeMap(m ...map[string]string) map[string]string { + r := make(map[string]string) + for _, t := range m { + for k, v := range t { + r[k] = v + } + } + return r +} + +func applyHeader(c *gin.Context, h map[string]string) { + for k, v := range h { + c.Header(k, v) + } +} + +// Cors 跨域处理 +func Cors(o *Options) gin.HandlerFunc { + if o.OptionsSuccessStatus == 0 { + o.OptionsSuccessStatus = http.StatusNoContent + } + return func(c *gin.Context) { + method := c.Request.Method + //c.Header("Access-Control-Allow-Origin", "*") + //c.Header("Access-Control-Allow-Headers", "Content-Type,AccessToken,X-CSRF-Token,Authorization,Token,Content-Length,Etag,Content-Range,Accept-Ranges,User-Agent,Range,Bucket,Content-Disposition,Signature,X-Forwarded-For,X-Real-Ip") + if method == "OPTIONS" { + h := mergeMap( + configureOrigin(o, c), + configureCredentials(o), + configureMethods(o), + configureAllowedHeaders(o, c), + configureMaxAge(o), + configureExposedHeaders(o), + ) + applyHeader(c, h) + if o.PreflightContinue { + c.Next() + } else { + c.Header("Content-Length", "0") + c.AbortWithStatus(o.OptionsSuccessStatus) + } + } else { + h := mergeMap( + configureOrigin(o, c), + configureCredentials(o), + configureExposedHeaders(o), + ) + applyHeader(c, h) + c.Next() + } + } +} diff --git a/app/middleware/ipblacklist/ipblacklist.go b/app/middleware/ipblacklist/ipblacklist.go new file mode 100644 index 0000000..2cbbcdf --- /dev/null +++ b/app/middleware/ipblacklist/ipblacklist.go @@ -0,0 +1,16 @@ +package ipblacklist + +import ( + "errors" + "github.com/gin-gonic/gin" + "net/http" + "videoweb/app/service/configser" +) + +func IpBlackListMiddleware(ctx *gin.Context) { + ip := ctx.ClientIP() + if configser.CheckIpBlack(ip) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errors.New("login limit")) + return + } +} \ No newline at end of file diff --git a/app/middleware/ipstat/ipstat.go b/app/middleware/ipstat/ipstat.go new file mode 100644 index 0000000..247d4fe --- /dev/null +++ b/app/middleware/ipstat/ipstat.go @@ -0,0 +1,94 @@ +package ipstat + +import ( + "net" + "time" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + + "github.com/gin-gonic/gin" +) + +// IpStatMiddleware 记录访客真实 IP,按天存入 Redis SET 实现去重计数。 +// key 格式:ipstat:{appId}:{yyyymmdd},TTL 49 小时(保证昨日数据次日仍可读)。 +func IpStatMiddleware(ctx *gin.Context) { + ctx.Next() + + normalized := normalizeIP(ipaddr.ResolveClientIP(ctx)) + if normalized == "" { + return + } + + date := time.Now().In(utils.LocBJ).Format("20060102") + key := redisconst.IpStatKey(pubdef.AppId, date) + + isNew, err := redisSAddNX(key, normalized) + if err != nil { + log.Warn("IpStatMiddleware SAdd failed", log.Any("key", key), log.E(err)) + return + } + if isNew { + // 仅新增成员时刷新 TTL,避免每次请求都执行 EXPIRE + if err := redis.Expire(key, redisconst.IpStatExpire); err != nil { + log.Warn("IpStatMiddleware Expire failed", log.Any("key", key), log.E(err)) + } + } +} + +// redisSAddNX 添加成员并返回是否为新增(count > 0) +func redisSAddNX(key string, member string) (bool, error) { + err := redis.SAdd(key, member) + if err != nil { + return false, err + } + return true, nil +} + +// normalizeIP 将原始 IP 标准化: +// - 去掉 IPv4-mapped IPv6 前缀(::ffff:) +// - 过滤私有/回环/链路本地地址 +// - 非法或私有 IP 返回空字符串 +func normalizeIP(raw string) string { + ip := net.ParseIP(raw) + if ip == nil { + return "" + } + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + if isPrivateIP(ip) { + return "" + } + return ip.String() +} + +var privateRanges = []net.IPNet{ + parseCIDR("10.0.0.0/8"), + parseCIDR("172.16.0.0/12"), + parseCIDR("192.168.0.0/16"), + parseCIDR("127.0.0.0/8"), + parseCIDR("::1/128"), + parseCIDR("fe80::/10"), + parseCIDR("fc00::/7"), +} + +func parseCIDR(s string) net.IPNet { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return *network +} + +func isPrivateIP(ip net.IP) bool { + for _, r := range privateRanges { + if r.Contains(ip) { + return true + } + } + return false +} diff --git a/app/middleware/monitor/define.go b/app/middleware/monitor/define.go new file mode 100644 index 0000000..482f318 --- /dev/null +++ b/app/middleware/monitor/define.go @@ -0,0 +1,69 @@ +package monitor + +import "time" + +type APIStatis struct { + //路径 + Path string `json:"path"` + //消耗最大时间rawdata + MaxTimeRawData string `json:"maxTimeRawData"` + //调用次数 + CallNum int64 `json:"callNum"` + //最小耗时 + MinTime int64 `json:"minTime"` + //最大耗时 + MaxTime int64 `json:"maxTime"` + //总耗时 + TotalTime int64 `json:"totalTime"` + //每次调用时间 + TimePerCall int64 `json:"timePerCall"` + //每秒调用次数 + CallPerSec int64 `json:"callPerSec"` + //开始时间 + Start time.Time `json:"start"` + //是否发送过报警 + Send int `json:"send"` +} + +// APIStatis 接口统计 +// 每天统计数 +type LogicStatis struct { + //API总调用次数 + ApiCallNum int64 + //API调用总耗时 + ApiTotalTime int64 + //开始时间 + Start time.Time + //接口访问次数 + ApisStatis map[string]*APIStatis +} + +type SPType string + +const ( + SP_ApiCall SPType = "apicall" + SP_GetStatis SPType = "getStatis" +) + +// StatisReq 统计请求数据格式 +type AppStatisReq struct { + Time time.Time `json:"time"` + //获取的api数量 + ApiNum int64 `json:"apiNum"` +} + +type StatisParam struct { + //调用类型 + Type SPType + Param1 string + Param2 int64 + Param3 string +} + +var ( + hourStatises map[string]*LogicStatis + muniteStatises map[string]*LogicStatis + //api调用参数 + apiCallChan chan *StatisParam + //退出 +) diff --git a/app/middleware/monitor/monitor.go b/app/middleware/monitor/monitor.go new file mode 100644 index 0000000..353f75c --- /dev/null +++ b/app/middleware/monitor/monitor.go @@ -0,0 +1,262 @@ +package monitor + +import ( + "bytes" + "fmt" + "io/ioutil" + "math" + "net/http" + "runtime" + "sort" + "time" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/godef" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/tgbot" + "videoweb/pub/redis" + + "github.com/gin-gonic/gin" +) + +// Check 运行监控检查 +func Check(ctx *gin.Context) { + //查看是否有限制登陆 + ip := ctx.ClientIP() + ifKey := redisconst.IpForbitKey(ip) + retValue, _ := redis.Get(ifKey) + if retValue != nil { + //log.Info("Test Check", log.Any("ip", ip), log.Any("retValue", *retValue)) + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "code": srvdef.ErrIpForbid, + "msg": srvdef.ErrIpForbid.Msg(), + }) + return + } + + // Start timer + start := time.Now() + path := ctx.Request.URL.Path + raw := ctx.Request.URL.RawQuery + rawData, err := ctx.GetRawData() + if err != nil { + log.Info("Test get raw data error", log.E(err)) + } + // 将取出来的执行条件放回ctx中 + ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(rawData)) + //recover + func() { + defer func() { + if r := recover(); r != nil { + buf := make([]byte, 2048) + length := runtime.Stack(buf, false) + info := string(buf[:length]) + log.Error("catch a unkown excetion", log.Any("excetion :", info)) + //发送tg消息 + msg := "monitor check crash,the stack:\n" + info + tgbot.SendToProgram("API接口处理宕了", msg) + } + }() + // Process request + ctx.Next() + }() + // Stop timer + stopTime := time.Now() + latency := stopTime.Sub(start).Milliseconds() + if raw != "" { + path = path + "?" + raw + } + //添加记录 + //log.Info("Test", log.Any("rawData", rawData)) + rawString := string(rawData) + addApiCall(path, int64(latency), rawString) + //如果时间大于1秒发送tg消息 + if latency > 4000 { + userId := ctx.GetInt64(consts.CtxUserID) + msg := "" + msg = msg + fmt.Sprintf("path:%s", path) + "\n" + msg = msg + fmt.Sprintf("userId:%d", userId) + "\n" + msg = msg + fmt.Sprintf("userIp:%s", ip) + "\n" + msg = msg + fmt.Sprintf("rawData:%s", string(rawData)) + "\n" + msg = msg + fmt.Sprintf("time:%d", latency) + "\n" + tgbot.SendToProgram("API接口调用超时", msg) + } + //log.Info("monitor:Check", log.Any("latency", latency), log.Any("path", path)) +} + +func init() { + hourStatises = make(map[string]*LogicStatis) + muniteStatises = make(map[string]*LogicStatis) + apiCallChan = make(chan *StatisParam, 1024) + godef.Go(loop) + log.Info("(monitor:init) init success.") +} + +func loop() { + log.Info("(monitor:loop) start...") + defer log.Info("monitor:loop) end") + var param *StatisParam + for { + select { + case param = <-apiCallChan: + onStatis(param) + default: + time.Sleep(time.Millisecond * 10) + } + } +} + +func onStatis(param *StatisParam) { + if param == nil { + return + } + now := time.Now() + hourStr := now.Format("2006-01-02 15") + hourStatis, hourOk := hourStatises[hourStr] + if !hourOk { + hourStatis = &LogicStatis{ + ApisStatis: make(map[string]*APIStatis), + } + hourStatises[hourStr] = hourStatis + } + muniteStr := now.Format("2006-01-02 15:04") + minuteStatis, minuteOk := muniteStatises[muniteStr] + if !minuteOk { + minuteStatis = &LogicStatis{ + ApisStatis: make(map[string]*APIStatis), + } + muniteStatises[muniteStr] = minuteStatis + } + onStatisApiCall(hourStr, param, hourStatis, 10000000) + onStatisApiCall(muniteStr, param, minuteStatis, 20000) + //如果新的一小时,发送上一小时数据 + if !hourOk { + //清空分钟数据 + muniteStatises = make(map[string]*LogicStatis) + lastHour := now.Add(-time.Hour).Format("2006-01-02 15") + hourStatis, hourOk = hourStatises[lastHour] + if hourOk { + msg := fmt.Sprintf("统计时间:%s\n", lastHour) + msg += formatStatis(hourStatis) + tgbot.SendToProgram("API接口调用统计", msg) + } + } +} + +func formatStatis(hourStatis *LogicStatis) string { + apiStatises := make([]*APIStatis, 0) + for _, static := range hourStatis.ApisStatis { + apiStatises = append(apiStatises, static) + } + sort.Slice(apiStatises, func(i, j int) bool { + return apiStatises[i].CallNum > apiStatises[j].CallNum + }) + //发送数据 + msg := "" + maxlen := len(apiStatises) + if maxlen > 10 { + maxlen = 10 + } + for i := 0; i < maxlen; i++ { + apiStatis := apiStatises[i] + msg += formatApiStatis(apiStatis) + } + timePerCall := int64(0) + callPerSec := int64(0) + if hourStatis.ApiCallNum > 0 { + timePerCall = hourStatis.ApiTotalTime / hourStatis.ApiCallNum + diffSecdonds := int64(time.Now().Sub(hourStatis.Start).Seconds()) + if diffSecdonds > 0 { + callPerSec = hourStatis.ApiCallNum / diffSecdonds + } + } + msg += fmt.Sprintf("ApiCallNum:%d \n", hourStatis.ApiCallNum) + msg += fmt.Sprintf("ApiTotalTime:%d \n", hourStatis.ApiTotalTime) + msg += fmt.Sprintf("ApiTimePerCall:%d \n", timePerCall) + msg += fmt.Sprintf("ApiCallPerSec:%d \n", callPerSec) + return msg +} + +func formatApiStatis(apiStatis *APIStatis) string { + msg := "" + if apiStatis.CallNum > 0 { + apiStatis.TimePerCall = apiStatis.TotalTime / apiStatis.CallNum + diffSeconds := int64(time.Now().Sub(apiStatis.Start).Seconds()) + if diffSeconds > 0 { + apiStatis.CallPerSec = apiStatis.CallNum / diffSeconds + } + } + + statisStr := fmt.Sprintf(`path %s: +MaxRawData:%s +CallNum:%d;MinTime:%d;MaxTime:%d;TotalTime:%d;TimePerCall:%d;CallPerSec:%d`, + apiStatis.Path, + apiStatis.MaxTimeRawData, + apiStatis.CallNum, + apiStatis.MinTime, + apiStatis.MaxTime, + apiStatis.TotalTime, + apiStatis.TimePerCall, + apiStatis.CallPerSec) + msg += statisStr + msg += "\n---------------------------------------------------\n" + return msg +} + +// api统计 +func onStatisApiCall(dateStr string, param *StatisParam, + logicStatis *LogicStatis, maxCallNum int64) { + logicStatis.ApiCallNum++ + logicStatis.ApiTotalTime += param.Param2 + apiStatis, ok := logicStatis.ApisStatis[param.Param1] + if !ok { + apiStatis = &APIStatis{ + Path: param.Param1, + MaxTimeRawData: param.Param3, + CallNum: 0, + MinTime: math.MaxInt64, + MaxTime: 0, + TotalTime: 0, + Start: time.Now(), + Send: 0, + } + logicStatis.ApisStatis[param.Param1] = apiStatis + } + apiStatis.CallNum++ + apiStatis.TotalTime += param.Param2 + if param.Param2 > apiStatis.MaxTime { + apiStatis.MaxTime = param.Param2 + apiStatis.MaxTimeRawData = param.Param3 + } + if param.Param2 < apiStatis.MinTime { + apiStatis.MinTime = param.Param2 + } + if apiStatis.CallNum > maxCallNum && apiStatis.Send < 4 { + apiStatis.Send++ + msg := fmt.Sprintf("接口%s每分钟调用超过最大次数%d\n", apiStatis.Path, maxCallNum) + msg += formatApiStatis(apiStatis) + tgbot.SendToProgram("API调用警告", msg) + } +} + +func addApiCall(path string, latency int64, rawData string) { + param := &StatisParam{ + Type: SP_ApiCall, + Param1: path, + Param2: latency, + Param3: rawData, + } + pushParam(param) +} + +func pushParam(param *StatisParam) { + //no block + select { + case apiCallChan <- param: + default: + info := "(monitor:pushParam) failed,chan is full." + log.Info(info) + tgbot.SendToProgram("monitor pushParam failed", info) + } +} diff --git a/app/middleware/reptilerecord/reptilerecord.go b/app/middleware/reptilerecord/reptilerecord.go new file mode 100644 index 0000000..4585c7e --- /dev/null +++ b/app/middleware/reptilerecord/reptilerecord.go @@ -0,0 +1,48 @@ +package reptilerecord + +import ( + "github.com/gin-gonic/gin" + "strings" + "videoweb/pub/common/log" +) + +const ctxKeyIsBot = "isBot" + +var ( + botPatterns = map[string]string{ + "Googlebot": "Googlebot", + "Bingbot": "Bingbot", + "Slurp": "Slurp", + "DuckDuckBot": "DuckDuckBot", + "Baiduspider": "Baiduspider", + "YandexBot": "YandexBot", + "Sogou": "Sogou", + "Exabot": "Exabot", + "facebot": "facebot", + "facebookexternalhit": "facebookexternalhit", + } +) + +func ReptileRecord(c *gin.Context) { + ua := c.Request.UserAgent() + botName := identifyBot(ua) + if botName != "" { + c.Set(ctxKeyIsBot, true) + log.Info("爬虫访问:", log.Any("botName", botName), log.Any("ua", ua), log.Any("path", c.Request.URL.Path), log.Any("ip", c.ClientIP())) + } +} + +// IsBot 返回当前请求是否来自已知搜索引擎爬虫 +func IsBot(c *gin.Context) bool { + v, exists := c.Get(ctxKeyIsBot) + return exists && v.(bool) +} + +func identifyBot(userAgent string) string { + for name, pattern := range botPatterns { + if strings.Contains(userAgent, pattern) { + return name + } + } + return "" +} diff --git a/app/middleware/templatecache/cache.go b/app/middleware/templatecache/cache.go new file mode 100644 index 0000000..e21a0a2 --- /dev/null +++ b/app/middleware/templatecache/cache.go @@ -0,0 +1,116 @@ +package templatecache + +import ( + "bytes" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" +) + +const ( + maxCacheItems = 2000 + maxCacheBytes = 64 << 20 +) + +var ( + Cache *cache.Cache + cacheBytes int64 +) + +// 章节详情、资讯详情等 ID 空间过大,禁止进进程缓存。 +var highCardinalityContains = []string{ + "/chapter/info", + "/consultinginfo/", +} + +func init() { + Cache = cache.New(5*time.Minute, 5*time.Minute) + Cache.OnEvicted(func(_ string, v interface{}) { + if b, ok := v.([]byte); ok { + atomic.AddInt64(&cacheBytes, -int64(len(b))) + } + }) +} + +func shouldBypassCache(path string) bool { + if path == "" { + return false + } + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + for _, part := range highCardinalityContains { + if strings.Contains(path, part) { + return true + } + } + return false +} + +func cacheBudgetAllows(n int) bool { + if n <= 0 { + return false + } + if Cache.ItemCount() >= maxCacheItems { + return false + } + return atomic.LoadInt64(&cacheBytes)+int64(n) <= maxCacheBytes +} + +type templateResponseWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *templateResponseWriter) Write(b []byte) (int, error) { + w.body.Write(b) + return w.ResponseWriter.Write(b) +} + +func WithCache(duration time.Duration) gin.HandlerFunc { + return func(ctx *gin.Context) { + if ctx.Request.Method != "GET" { + ctx.Next() + return + } + if shouldBypassCache(ctx.Request.URL.Path) { + ctx.Next() + return + } + + cacheKey := ctx.Request.URL.String() + if cacheData, exists := Cache.Get(cacheKey); exists { + if item, ok := cacheData.([]byte); ok && len(item) > 0 { + ctx.Data(http.StatusOK, "application/json; charset=utf-8", item) + ctx.Abort() + return + } + } + + writer := &templateResponseWriter{ + body: bytes.NewBuffer(nil), + ResponseWriter: ctx.Writer, + } + ctx.Writer = writer + ctx.Next() + + if ctx.Writer.Status() != 200 { + return + } + raw := writer.body.Bytes() + if !cacheBudgetAllows(len(raw)) { + return + } + copied := append([]byte(nil), raw...) + atomic.AddInt64(&cacheBytes, int64(len(copied))) + cacheDuration := duration + if cacheDuration == 0 { + cacheDuration = time.Minute + } + Cache.Set(cacheKey, copied, cacheDuration) + } +} diff --git a/app/middleware/validator/default-validator.go b/app/middleware/validator/default-validator.go new file mode 100644 index 0000000..daf78ee --- /dev/null +++ b/app/middleware/validator/default-validator.go @@ -0,0 +1,81 @@ +package validator + +import ( + "reflect" + "strings" + "sync" + + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/locales/en" + ut "github.com/go-playground/universal-translator" + "gopkg.in/go-playground/validator.v9" + en_translations "gopkg.in/go-playground/validator.v9/translations/en" +) + +var ( + ValidatorUni *ut.UniversalTranslator +) + +type defaultValidator struct { + once sync.Once + validate *validator.Validate +} + +var v binding.StructValidator = &defaultValidator{} + +func init() { + v.Engine() +} + +func (v *defaultValidator) ValidateStruct(obj interface{}) error { + + if kindOfData(obj) == reflect.Struct { + + v.lazyinit() + + if err := v.validate.Struct(obj); err != nil { + return error(err) + } + } + + return nil +} + +func (v *defaultValidator) Engine() interface{} { + v.lazyinit() + return v.validate +} + +func (v *defaultValidator) lazyinit() { + v.once.Do(func() { + v.validate = validator.New() + + // 依旧使用bingding tag兼容v8 + v.validate.SetTagName("binding") + + // 错误信息转换 + en := en.New() + ValidatorUni = ut.New(en, en) + trans, _ := ValidatorUni.GetTranslator("en") + en_translations.RegisterDefaultTranslations(v.validate, trans) + + v.validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("comment"), ",", 2)[0] + if name == "-" { + return "" + } + return name + }) + }) +} + +func kindOfData(data interface{}) reflect.Kind { + + value := reflect.ValueOf(data) + valueType := value.Kind() + + if valueType == reflect.Ptr { + valueType = value.Elem().Kind() + } + return valueType +} diff --git a/app/middleware/validator/v9.go b/app/middleware/validator/v9.go new file mode 100644 index 0000000..4e2e38e --- /dev/null +++ b/app/middleware/validator/v9.go @@ -0,0 +1,11 @@ +package validator + +import ( + "github.com/gin-gonic/gin/binding" +) + +// 替换gin 默认v8验证器 +// 改为v9 +func init() { + binding.Validator = new(defaultValidator) +} diff --git a/app/middleware/verfyparam/verfyparam.go b/app/middleware/verfyparam/verfyparam.go new file mode 100644 index 0000000..7370c8a --- /dev/null +++ b/app/middleware/verfyparam/verfyparam.go @@ -0,0 +1,23 @@ +package verfyparam + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +var ( + // 白名单,不需要验证token的api + whitelist = map[string]bool{} +) + +func VerifyReplayAttackRequest() gin.HandlerFunc { + return func(ctx *gin.Context) { + for url, ok := range whitelist { + if ok && strings.HasPrefix(ctx.Request.URL.Path, url) { + return + } + } + ctx.Next() + } +} diff --git a/app/middleware/visit/visit.go b/app/middleware/visit/visit.go new file mode 100644 index 0000000..f1f3701 --- /dev/null +++ b/app/middleware/visit/visit.go @@ -0,0 +1,12 @@ +package visit + +import ( + "github.com/gin-gonic/gin" +) + +// 用户访问中间件 +// 记录用户在线量 +// 按5分钟纬度记录到redis中 +func Visit(ctx *gin.Context) { + +} diff --git a/app/router/actress.go b/app/router/actress.go new file mode 100644 index 0000000..231e8c4 --- /dev/null +++ b/app/router/actress.go @@ -0,0 +1,12 @@ +package router + +import ( + "videoweb/app/api/actressapi" + + "github.com/gin-gonic/gin" +) + +func actressRouter(r *gin.Engine) { + r.GET("/api/app/actress/list", actressapi.ActressList) + r.GET("/api/app/actress/work/list", actressapi.QueryActressWorkList) +} diff --git a/app/router/appinfo.go b/app/router/appinfo.go new file mode 100644 index 0000000..28f2d49 --- /dev/null +++ b/app/router/appinfo.go @@ -0,0 +1,11 @@ +package router + +import ( + "videoweb/app/api/appinfoapi" + + "github.com/gin-gonic/gin" +) + +func appInfoRouter(r *gin.Engine) { + r.GET("/api/app/applist/list", appinfoapi.QueryAppList) +} diff --git a/app/router/auth.go b/app/router/auth.go new file mode 100644 index 0000000..672e93a --- /dev/null +++ b/app/router/auth.go @@ -0,0 +1,24 @@ +package router + +import ( + "videoweb/app/api/authapi" + + "github.com/gin-gonic/gin" +) + +func authRouter(r *gin.Engine) { + // 游客登录(无需 token) + login := r.Group("/api/app/login") + { + login.POST("/guest", authapi.GuestLogin) + } + + // 账号注册 / 登录 / 改资料 + auth := r.Group("/api/app/auth") + { + auth.POST("/register", authapi.Register) + auth.POST("/login", authapi.Login) + auth.GET("/getInfo", authapi.GetInfo) + auth.POST("/profile", authapi.UpdateProfile) + } +} diff --git a/app/router/comment.go b/app/router/comment.go new file mode 100644 index 0000000..b567eae --- /dev/null +++ b/app/router/comment.go @@ -0,0 +1,13 @@ +package router + +import ( + "videoweb/app/api/commentapi" + + "github.com/gin-gonic/gin" +) + +func commentRouter(r *gin.Engine) { + r.GET("/api/app/comment/list", commentapi.List) + r.POST("/api/app/comment/send", commentapi.SendComment) + r.POST("/api/app/comment/like/set", commentapi.SetLike) +} diff --git a/app/router/config.go b/app/router/config.go new file mode 100644 index 0000000..610efd8 --- /dev/null +++ b/app/router/config.go @@ -0,0 +1,13 @@ +package router + +import ( + configapi "videoweb/app/api/config" + + "github.com/gin-gonic/gin" +) + +func configRouter(r *gin.Engine) { + r.GET("/api/app/config/footer/info", configapi.FooterInfo) + r.GET("/api/app/config/richtext/list", configapi.RichTextList) + r.GET("/api/app/content/tag/list", configapi.ContentTagList) +} diff --git a/app/router/dh.go b/app/router/dh.go new file mode 100644 index 0000000..b9dad81 --- /dev/null +++ b/app/router/dh.go @@ -0,0 +1,17 @@ +package router + +import ( + "time" + "videoweb/app/api/dhapi" + "videoweb/app/middleware/templatecache" + + "github.com/gin-gonic/gin" +) + +func dhRouter(r *gin.Engine) { + r.GET("/dh/", templatecache.WithCache(time.Minute*1), dhapi.DHApi) + r.GET("/businessCooperation/", templatecache.WithCache(time.Minute*1), dhapi.BusinessCooperationApi) + r.GET("/servicesProject/", templatecache.WithCache(time.Minute*1), dhapi.ServicesProjectApi) + r.GET("/consulting/", templatecache.WithCache(time.Minute*1), dhapi.ConsultingApi) + r.GET("/consultinginfo/:id/", templatecache.WithCache(time.Minute*1), dhapi.ConsultingInfoApi) +} diff --git a/app/router/gin.go b/app/router/gin.go new file mode 100644 index 0000000..78a29b9 --- /dev/null +++ b/app/router/gin.go @@ -0,0 +1,124 @@ +package router + +import ( + "log" + "net/http" + "strings" + "videoweb/app/appg" + "videoweb/app/middleware/auth" + "videoweb/app/middleware/cors" + "videoweb/app/middleware/ipblacklist" + "videoweb/app/middleware/ipstat" + "videoweb/app/middleware/reptilerecord" + "videoweb/app/service/displaycount" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/useragent" + + "github.com/DeanThompson/ginpprof" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + _ "videoweb/app/docs" +) + +func Init() (engine *gin.Engine) { + // 设置启动模式 + switch appg.Conf.RunMod { + case "dev": + gin.SetMode(gin.DebugMode) + case "test": + gin.SetMode(gin.TestMode) + case "prod": + gin.SetMode(gin.ReleaseMode) + default: + gin.SetMode(gin.DebugMode) + } + // 初始化gin引擎 + engine = gin.Default() + engine.HandleMethodNotAllowed = true + engine.MaxMultipartMemory = 60 << 20 + + // 从配置文件读取可信代理列表(字符串,多个 IP 用逗号分隔) + // 配置了则只信任这些 IP 的 X-Forwarded-For,未配置则不信任任何代理(直接用 TCP 连接 IP) + // 配置示例(app.yaml) + // trustedProxies: "10.0.0.1" + // trustedProxies: "10.0.0.1,10.0.0.2" + if appg.Conf.TrustedProxies != "" { + proxies := strings.Split(appg.Conf.TrustedProxies, ",") + for i, p := range proxies { + proxies[i] = strings.TrimSpace(p) + } + _ = engine.SetTrustedProxies(proxies) + log.Printf("SetTrustedProxies: %v", proxies) + } else { + _ = engine.SetTrustedProxies([]string{}) + log.Printf("SetTrustedProxies: none (direct TCP IP mode)") + } + + common.InitGinSecret(appg.Conf.ResKey, consts.RunMod(appg.Conf.RunMod)) + common.SetResponseDataTransform(displaycount.Transform) + // 注册中间件 + registeMiddleware(engine) + // 注册路由 + registeRouter(engine) + + if appg.Conf.RunMod != "prod" { + setGinSwagger(engine) + } + // 是否开启监控服务 + if appg.Conf.Pprof { + ginpprof.Wrap(engine) + } + return +} + +func registeMiddleware(engine *gin.Engine) { + engine.Use( + // gin.Recovery(), + cors.Cors(&cors.Options{ + Origin: nil, // "*" + Methods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodHead, http.MethodDelete}, + AllowedHeaders: []string{"Content-Length", "Content-Type", "Authorization", "X-User-Agent", "device", "api_version", "x-api-key", useragent.SDKCommonHeader}, + ExposedHeaders: []string{"Content-Disposition", "Refresh-Authorization"}, + OptionsSuccessStatus: http.StatusOK, + }), + // 必须是此顺序, happens before原则 + //monitor.Check, + ipblacklist.IpBlackListMiddleware, + reptilerecord.ReptileRecord, + ipstat.IpStatMiddleware, + useragent.UserAgent, + auth.Auth, + // calllimit.Check, + // visit.Visit, + ) +} + +// 注册路由 +func registeRouter(r *gin.Engine) { + homeRouter(r) + videoRouter(r) + commentRouter(r) + searchRouter(r) + dhRouter(r) + lawAndTrustRouter(r) + actressRouter(r) + configRouter(r) + navigationRouter(r) + playletRouter(r) + authRouter(r) + uploadRouter(r) + questionnaireRouter(r) + appInfoRouter(r) + healthRouter(r) + richTextRouter(r) +} + +// 设置swag 接口信息 +func setGinSwagger(router *gin.Engine) { + // 使用相对路径,无需依赖 Host 配置,本地/测试环境均可直接访问 + url := ginSwagger.URL("/swagger/doc.json") + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) +} diff --git a/app/router/health.go b/app/router/health.go new file mode 100644 index 0000000..63140df --- /dev/null +++ b/app/router/health.go @@ -0,0 +1,11 @@ +package router + +import ( + "videoweb/app/api/healthapi" + + "github.com/gin-gonic/gin" +) + +func healthRouter(r *gin.Engine) { + r.POST("/api/app/health/ping", healthapi.HealthCheck) +} diff --git a/app/router/home.go b/app/router/home.go new file mode 100644 index 0000000..99a0594 --- /dev/null +++ b/app/router/home.go @@ -0,0 +1,15 @@ +package router + +import ( + "time" + "videoweb/app/api/home" + "videoweb/app/middleware/templatecache" + + "github.com/gin-gonic/gin" +) + +func homeRouter(r *gin.Engine) { + r.GET("/api/app/home", home.Home) + r.GET("/api/app/home/base", templatecache.WithCache(time.Minute*1), home.HomeBase) + r.GET("/api/app/home/tags", templatecache.WithCache(time.Minute*1), home.HomeTags) +} diff --git a/app/router/lawandtrust.go b/app/router/lawandtrust.go new file mode 100644 index 0000000..0fc6282 --- /dev/null +++ b/app/router/lawandtrust.go @@ -0,0 +1,16 @@ +package router + +import ( + "time" + "videoweb/app/api/lawandtrust" + "videoweb/app/middleware/templatecache" + + "github.com/gin-gonic/gin" +) + +func lawAndTrustRouter(r *gin.Engine) { + r.GET("/useragreement/", templatecache.WithCache(time.Minute*1), lawandtrust.UserAgreementApi) + r.GET("/privacyPolicy/", templatecache.WithCache(time.Minute*1), lawandtrust.PrivacyPolicyApi) + r.GET("/dmca/", templatecache.WithCache(time.Minute*1), lawandtrust.DmcaApi) + r.GET("/statement/", templatecache.WithCache(time.Minute*1), lawandtrust.StatementApi) +} diff --git a/app/router/navigation.go b/app/router/navigation.go new file mode 100644 index 0000000..15cdb75 --- /dev/null +++ b/app/router/navigation.go @@ -0,0 +1,11 @@ +package router + +import ( + "videoweb/app/api/navigationapi" + + "github.com/gin-gonic/gin" +) + +func navigationRouter(r *gin.Engine) { + r.GET("/api/app/navigation/header", navigationapi.HeaderNavigation) +} diff --git a/app/router/playlet.go b/app/router/playlet.go new file mode 100644 index 0000000..313010d --- /dev/null +++ b/app/router/playlet.go @@ -0,0 +1,29 @@ +package router + +import ( + "videoweb/app/api/playletapi" + + "github.com/gin-gonic/gin" +) + +func playletRouter(r *gin.Engine) { + r.GET("/api/app/playlet/home", playletapi.HomePlaylet) + r.GET("/api/app/playlet/home-config/list", playletapi.HomeConfigList) + r.GET("/api/app/playlet/topic/detail", playletapi.TopicDetail) + r.GET("/api/app/playlet/theme/list", playletapi.ThemeList) + r.GET("/api/app/playlet/list", playletapi.List) + r.GET("/api/app/playlet/content/list", playletapi.ContentList) + r.GET("/api/app/playlet/recommend/list", playletapi.RecommendList) + r.GET("/api/app/playlet/guess/list", playletapi.GuessList) + r.GET("/api/app/playlet/tag/:id", playletapi.TagList) + r.GET("/api/app/playlet/info", playletapi.QueryPlayletInfo) + r.GET("/api/app/playlet/chapter/info", playletapi.QueryPlayletChapterInfo) + r.POST("/api/app/playlet/like", playletapi.LikePlaylet) + r.GET("/api/app/playlet/like/list", playletapi.QueryLikePlayletList) + r.POST("/api/app/playlet/favorite", playletapi.FavoritePlaylet) + r.GET("/api/app/playlet/favorite/list", playletapi.QueryFavoritePlayletList) + r.GET("/api/app/playlet/history/list", playletapi.QueryPlayletHistoryList) + r.POST("/api/app/playlet/history", playletapi.ReportPlayletHistory) + r.DELETE("/api/app/playlet/history", playletapi.ClearPlayletHistory) + r.GET("/api/app/playlet/rank/list", playletapi.QueryPlayletRankList) +} diff --git a/app/router/questionnaire.go b/app/router/questionnaire.go new file mode 100644 index 0000000..f248913 --- /dev/null +++ b/app/router/questionnaire.go @@ -0,0 +1,12 @@ +package router + +import ( + "videoweb/app/api/questionnaireapi" + + "github.com/gin-gonic/gin" +) + +func questionnaireRouter(r *gin.Engine) { + r.GET("/questionnaire", questionnaireapi.Page) + r.POST("/api/app/questionnaire/submit", questionnaireapi.Submit) +} diff --git a/app/router/richtext.go b/app/router/richtext.go new file mode 100644 index 0000000..ba46cd1 --- /dev/null +++ b/app/router/richtext.go @@ -0,0 +1,11 @@ +package router + +import ( + "videoweb/app/api/richtextapi" + + "github.com/gin-gonic/gin" +) + +func richTextRouter(router *gin.Engine) { + router.GET("/api/app/richtext", richtextapi.GetByTitle) +} diff --git a/app/router/search.go b/app/router/search.go new file mode 100644 index 0000000..959fa1f --- /dev/null +++ b/app/router/search.go @@ -0,0 +1,15 @@ +package router + +import ( + "time" + "videoweb/app/api/searchapi" + "videoweb/app/middleware/templatecache" + + "github.com/gin-gonic/gin" +) + +func searchRouter(r *gin.Engine) { + + r.GET("/search/", templatecache.WithCache(time.Minute*1), searchapi.SearchApi) + +} diff --git a/app/router/upload.go b/app/router/upload.go new file mode 100644 index 0000000..2107c89 --- /dev/null +++ b/app/router/upload.go @@ -0,0 +1,16 @@ +package router + +import ( + "videoweb/app/api/uploadapi" + + "github.com/gin-gonic/gin" +) + +func uploadRouter(r *gin.Engine) { + group := r.Group("/api/app/upload") + { + group.POST("/videoBody", uploadapi.VideoBody) + group.POST("/videoOk", uploadapi.VideoOk) + group.POST("/image", uploadapi.UploadImage) + } +} diff --git a/app/router/video.go b/app/router/video.go new file mode 100644 index 0000000..5aa8e4c --- /dev/null +++ b/app/router/video.go @@ -0,0 +1,36 @@ +package router + +import ( + "videoweb/app/api/videoapi" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" + + _ "videoweb/app/docs" +) + +func videoRouter(r *gin.Engine) { + + r.GET("/api/app/video/list", videoapi.List) + r.GET("/api/app/video/category/list", videoapi.CategoryList) + r.GET("/api/app/video/recommend/list", videoapi.RecommendList) + r.GET("/api/app/video/guess/list", videoapi.GuessList) + r.GET("/api/app/video/tag/:id", videoapi.TagList) + r.GET("/api/app/video/more", videoapi.QueryVideoMore) + r.GET("/api/app/video/info", videoapi.QueryVideoInfo) + r.POST("/api/app/video/favorite", videoapi.FavoriteVideo) + r.POST("/api/app/video/like", videoapi.LikeVideo) + r.GET("/api/app/video/like/list", videoapi.QueryLikeVideoList) + r.GET("/api/app/video/favorite/list", videoapi.QueryFavoriteVideoList) + r.GET("/api/app/video/history/list", videoapi.QueryVideoHistoryList) + r.POST("/api/app/video/history", videoapi.ReportVideoHistory) + r.DELETE("/api/app/video/history", videoapi.ClearVideoHistory) + r.GET("/api/app/video/rank", videoapi.QueryVideoRank) + r.GET("/api/app/video/rank/list", videoapi.QueryVideoRankList) + r.GET("/h5/m3u8/*key", videoser.GetM3U8Ex) + r.GET("/enkey", videoser.Enkey) + r.GET("/newenkey", videoser.NewEnkey) + r.GET("/lsjenkey", videoser.LsjEnkey) + r.GET("/vtt/*key", videoser.GetVTT) + +} diff --git a/app/server/server.go b/app/server/server.go new file mode 100644 index 0000000..9e6cca0 --- /dev/null +++ b/app/server/server.go @@ -0,0 +1,214 @@ +package server + +import ( + "fmt" + "io/ioutil" + "net/http" + "os" + "os/signal" + "syscall" + "time" + "videoweb/app/appg" + "videoweb/app/router" + "videoweb/app/service/appser" + "videoweb/app/service/configser" + "videoweb/app/service/richtextser" + "videoweb/app/service/translationser" + ipstatsync "videoweb/app/skd/ipstat" + seoskd "videoweb/app/skd/seo" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/log" + "videoweb/pub/common/srvversion" + "videoweb/pub/common/tgbot" + "videoweb/pub/dataReport" + "videoweb/pub/esmanager" + "videoweb/pub/models" + "videoweb/pub/mongos" + "videoweb/pub/redis" + "videoweb/pub/storage/proxy" + + "gopkg.in/yaml.v2" +) + +func init() { + time.Local, _ = time.LoadLocation("Asia/Shanghai") +} +func Run(args []string) { + showVersion() + if err := initConfig(); err != nil { + log.Error("init config failed", log.E(err)) + os.Exit(1) + } + + if err := initMongoDB(); err != nil { + log.Error("init mongodb failed", log.E(err)) + os.Exit(1) + } + + //初始化tg机器人 + if appg.Conf.TgToken != "" { + // tgbot.InitTgBot(appg.Conf.TgToken, "[汁乎App]") + //tgbot.SendToProgram("Test", "Test") + } + detectorV6, err := ipaddr.NewXDBDetectorV6("conf/ip2region_v6.xdb") + if err != nil { + fmt.Printf("创建检测器失败: %v\n", err) + fmt.Println("正在尝试下载数据库文件...") + } + appg.DetectorV6 = detectorV6 + + detectorV4, err := ipaddr.NewXDBDetectorV4("conf/ip2region_v4.xdb") + if err != nil { + fmt.Printf("创建检测器失败: %v\n", err) + fmt.Println("正在尝试下载数据库文件...") + } + appg.DetectorV4 = detectorV4 + proxy.InitBucketVideoAddress(appg.Conf.BucketVideoAddress, appg.Conf.BucketVideoAddressV2) + mongos.StartLid() + initRedis() + initData() + ipstatsync.StartSync() + dataReport.Init(dataReport.Config{ + ApiUrl: appg.Conf.DataReport.Url, + RunMod: appg.Conf.RunMod, + }) + if err := initElasticSearch(); err != nil { + log.Error("init ES failed", log.E(err)) + os.Exit(1) + } + + initHttpServer() + seoskd.Start() + //tgbot.SendToProgram("启动", "app启动完毕") + shutdownHandler() +} + +func initConfig() error { + + buf, err := ioutil.ReadFile("conf/app.yaml") + if err != nil { + return err + } + if err = yaml.Unmarshal(buf, &appg.Conf); err != nil { + return err + } + nameBuf, err := ioutil.ReadFile("conf/names.yaml") + if err != nil { + return err + } + if err = yaml.Unmarshal(nameBuf, &appg.Name); err != nil { + return err + } + + // pictureBuf, err := ioutil.ReadFile("conf/picture.yaml") + // if err != nil { + // return err + // } + + // if err = yaml.Unmarshal(pictureBuf, &appg.Picture); err != nil { + // return err + // } + + return nil +} + +func initMongoDB() error { + shopDb := mongos.NewManager(appg.Conf.Mongodb.Addr) + if err := shopDb.Init(); err != nil { + return err + } + mongos.Shop = shopDb + + ycShopDb := mongos.NewManager(appg.Conf.Mongodb.LogAddr) + if err := ycShopDb.Init(); err != nil { + return err + } + mongos.ShopLog = ycShopDb + return nil +} + +func initElasticSearch() error { + es, err := esmanager.NewManager(appg.Conf.Es.Addr, + appg.Conf.Es.User, appg.Conf.Es.Pass) + if err != nil { + log.Info("new es failed", log.Any("err", err)) + return err + } + if err = es.ClientConnect(); err != nil { + log.Info("connect es failed", log.Any("err", err)) + return err + } + esmanager.Instance = es + log.Info("es:Init Ok!") + return nil +} + +// 数据 +func initData() { + models.Init() + // service.Start() + appg.InitCache() + translationser.Start() + configser.Start() + richtextser.Start() + appser.Start() +} + +func initRedis() error { + if err := redis.Init(redis.Options{ + Addr: appg.Conf.Redis.Addr, + Psw: appg.Conf.Redis.Passwd, + Db: appg.Conf.Redis.Db, + }); err != nil { + log.Error("redis connect err", log.E(err)) + return err + } + return nil +} + +func showVersion() { + log.Info(fmt.Sprintf("sevice version:%s", srvversion.SERVICE_VERSION)) + log.Info(fmt.Sprintf("git version:%s", srvversion.GIT_VERSION)) + log.Info(fmt.Sprintf("build date:%s", srvversion.BUILD_DATE)) + log.Info(fmt.Sprintf("build ip:%s", srvversion.BUILD_IP)) +} + +func initHttpServer() { + addr := fmt.Sprintf(":%s", appg.Conf.ListenAddr) + srv := &http.Server{ + Addr: addr, + Handler: router.Init(), + } + go func() { + // service connections + log.Info("服务已启动", log.Any("地址", addr)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal("listen:", log.E(err)) + } + }() +} + +func shutdownHandler() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Kill) + <-quit + log.Warn("Shutdown... server") + + done := make(chan int) + go func() { + seoskd.Stop() + richtextser.Stop() + configser.Stop() + mongos.Shop.DisConnect() + mongos.ShopLog.DisConnect() + redis.Disconnect() + tgbot.ReleaseTgBot() + close(done) + }() + select { + case <-time.After(5 * time.Second): + log.Warn("shutdown timeout....") + case <-done: + log.Info("graceful shutdown") + } +} diff --git a/app/service/actressser/actress.go b/app/service/actressser/actress.go new file mode 100644 index 0000000..27db68c --- /dev/null +++ b/app/service/actressser/actress.go @@ -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), + }}, + } +} diff --git a/app/service/appser/appinfo.go b/app/service/appser/appinfo.go new file mode 100644 index 0000000..48ef02f --- /dev/null +++ b/app/service/appser/appinfo.go @@ -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 +} diff --git a/app/service/appser/cache.go b/app/service/appser/cache.go new file mode 100644 index 0000000..e88788e --- /dev/null +++ b/app/service/appser/cache.go @@ -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() +} diff --git a/app/service/authser/auth.go b/app/service/authser/auth.go new file mode 100644 index 0000000..c8eab1e --- /dev/null +++ b/app/service/authser/auth.go @@ -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 +} diff --git a/app/service/commentser/comment.go b/app/service/commentser/comment.go new file mode 100644 index 0000000..13bb2c8 --- /dev/null +++ b/app/service/commentser/comment.go @@ -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 +} diff --git a/app/service/commentser/like.go b/app/service/commentser/like.go new file mode 100644 index 0000000..c6f8050 --- /dev/null +++ b/app/service/commentser/like.go @@ -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 +} diff --git a/app/service/configser/announcement.go b/app/service/configser/announcement.go new file mode 100644 index 0000000..239185b --- /dev/null +++ b/app/service/configser/announcement.go @@ -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 +} diff --git a/app/service/configser/catch.go b/app/service/configser/catch.go new file mode 100644 index 0000000..3ea324a --- /dev/null +++ b/app/service/configser/catch.go @@ -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)(]*?\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() +} diff --git a/app/service/configser/cdn.go b/app/service/configser/cdn.go new file mode 100644 index 0000000..4cf3127 --- /dev/null +++ b/app/service/configser/cdn.go @@ -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) +} diff --git a/app/service/configser/content_tag.go b/app/service/configser/content_tag.go new file mode 100644 index 0000000..059e450 --- /dev/null +++ b/app/service/configser/content_tag.go @@ -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 "" + } +} diff --git a/app/service/configser/creater.go b/app/service/configser/creater.go new file mode 100644 index 0000000..e7d7ae2 --- /dev/null +++ b/app/service/configser/creater.go @@ -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, + } +} diff --git a/app/service/configser/h265.go b/app/service/configser/h265.go new file mode 100644 index 0000000..755b34e --- /dev/null +++ b/app/service/configser/h265.go @@ -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 +} diff --git a/app/service/configser/playletrankcache.go b/app/service/configser/playletrankcache.go new file mode 100644 index 0000000..a6e0155 --- /dev/null +++ b/app/service/configser/playletrankcache.go @@ -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 +} diff --git a/app/service/configser/rankcache.go b/app/service/configser/rankcache.go new file mode 100644 index 0000000..3d996d4 --- /dev/null +++ b/app/service/configser/rankcache.go @@ -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 + } +} diff --git a/app/service/configser/rankstat.go b/app/service/configser/rankstat.go new file mode 100644 index 0000000..29e33d8 --- /dev/null +++ b/app/service/configser/rankstat.go @@ -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 // 年榜 +) diff --git a/app/service/configser/sysconfig.go b/app/service/configser/sysconfig.go new file mode 100644 index 0000000..208abee --- /dev/null +++ b/app/service/configser/sysconfig.go @@ -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) +} diff --git a/app/service/configser/tagstat.go b/app/service/configser/tagstat.go new file mode 100644 index 0000000..c4dd965 --- /dev/null +++ b/app/service/configser/tagstat.go @@ -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)) + } +} diff --git a/app/service/configser/topic.go b/app/service/configser/topic.go new file mode 100644 index 0000000..86fb112 --- /dev/null +++ b/app/service/configser/topic.go @@ -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 +} diff --git a/app/service/configser/videorankcache.go b/app/service/configser/videorankcache.go new file mode 100644 index 0000000..13ce7ac --- /dev/null +++ b/app/service/configser/videorankcache.go @@ -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 +} diff --git a/app/service/displaycount/displaycount.go b/app/service/displaycount/displaycount.go new file mode 100644 index 0000000..b55a95a --- /dev/null +++ b/app/service/displaycount/displaycount.go @@ -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 + } +} diff --git a/app/service/healthser/health.go b/app/service/healthser/health.go new file mode 100644 index 0000000..495df99 --- /dev/null +++ b/app/service/healthser/health.go @@ -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 "/" +} diff --git a/app/service/homeser/cache.go b/app/service/homeser/cache.go new file mode 100644 index 0000000..201dac7 --- /dev/null +++ b/app/service/homeser/cache.go @@ -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)) + } +} diff --git a/app/service/homeser/home.go b/app/service/homeser/home.go new file mode 100644 index 0000000..4ca8fc4 --- /dev/null +++ b/app/service/homeser/home.go @@ -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 +} diff --git a/app/service/moreser/mixed.go b/app/service/moreser/mixed.go new file mode 100644 index 0000000..170f270 --- /dev/null +++ b/app/service/moreser/mixed.go @@ -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 +} diff --git a/app/service/navigationser/header.go b/app/service/navigationser/header.go new file mode 100644 index 0000000..038bc33 --- /dev/null +++ b/app/service/navigationser/header.go @@ -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) +} diff --git a/app/service/playletser/favorite.go b/app/service/playletser/favorite.go new file mode 100644 index 0000000..5379e97 --- /dev/null +++ b/app/service/playletser/favorite.go @@ -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 +} diff --git a/app/service/playletser/like_list.go b/app/service/playletser/like_list.go new file mode 100644 index 0000000..3460e11 --- /dev/null +++ b/app/service/playletser/like_list.go @@ -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 +} diff --git a/app/service/playletser/playlet.go b/app/service/playletser/playlet.go new file mode 100644 index 0000000..237722f --- /dev/null +++ b/app/service/playletser/playlet.go @@ -0,0 +1,2589 @@ +package playletser + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/moreser" + "videoweb/app/service/reportser" + "videoweb/app/service/slugser" + "videoweb/app/service/translationser" + "videoweb/app/service/videoser" + "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/common/utils" + "videoweb/pub/models" + "videoweb/pub/models/announcementmod" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletfavoritemod" + "videoweb/pub/models/playlethomeconfigmod" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + "videoweb/pub/storage/proxy" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/gin-gonic/gin" +) + +const ( + playletGuessMinPageSize int64 = 8 + playletGuessMaxPageSize int64 = 100 + playletRecommendDefaultPageSize int64 = 20 + playletRecommendMaxPageSize int64 = 100 + playletRecommendAdInterval int64 = 8 + playletListAdInterval int64 = 4 + playletContentPlayletAdInterval int64 = 4 + playletContentVideoAdInterval int64 = 5 + homePlayletListLimit int64 = 12 + playletDetailHotLimit int64 = 11 +) + +// PlayletGuessList 分页查询猜你喜欢短剧,按观看量降序返回上架短剧 +func PlayletGuessList(ctx context.Context, req *proto.PlayletGuessListReq) (*proto.PlayletGuessListResp, *srvdef.SrvErr) { + if req == nil || req.Id < 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID无效") + } + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletGuessMinPageSize, playletGuessMaxPageSize) + if pageSize < playletGuessMinPageSize { + pageSize = playletGuessMinPageSize + } + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + if req.Id > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": req.Id} + } + total, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletGuessListResp{BasePageResp: response.BasePageResp{ + TotalCount: total, + PageNum: int(pageNum), + PageSize: int(pageSize), + List: make([]*proto.PlayletRecommendListItem, 0), + }} + if total == 0 || pageNum > (total-1)/pageSize+1 { + return resp, nil + } + list, err := findPlayletRecommendPage(ctx, filter, bson.D{ + {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, (pageNum-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + cdn := configser.ImageCdnFromCtx(ctx) + items := make([]*proto.PlayletRecommendListItem, 0, len(list)) + for _, item := range list { + if item != nil { + items = append(items, playletToRecommendListItem(item, cdn, tagNames, themeNames)) + } + } + resp.List = items + return resp, nil +} + +// playletRankAggItem 短剧排行榜聚合结果,只保留榜单展示需要的轻量字段 +type playletRankAggItem struct { + playletmod.Playlet `bson:",inline"` + RankScore int64 `bson:"rankScore"` +} + +type playletRankCount struct { + Total int64 `bson:"total"` +} + +// PlayletThemeList 查询全部已启用短剧题材 +func PlayletThemeList(ctx context.Context) (*proto.PlayletThemeListResp, *srvdef.SrvErr) { + list, err := playletthememod.FindManyPlayletTheme(ctx, + bson.M{playletthememod.FN.PlayletTheme.Status: true}, + options.Find(). + SetProjection(bson.D{ + {Key: playletthememod.FN.PlayletTheme.Id, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Name, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Slug, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Icon, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Rank, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.SeoInfo.Title, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.SeoInfo.Description, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.SeoInfo.Keyworld, Value: 1}, + }). + SetSort(bson.D{ + {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, + {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}, + }), + ) + if err != nil { + return nil, err + } + cdn := configser.ImageCdnFromCtx(ctx) + resp := &proto.PlayletThemeListResp{List: make([]proto.PlayletThemeItem, 0, len(list))} + for _, item := range list { + if item == nil { + continue + } + resp.List = append(resp.List, proto.PlayletThemeItem{ + Id: item.Id, Name: item.Name, Slug: item.Slug, + Icon: netutil.MergeURL(cdn, item.Icon), Rank: item.Rank, SeoInfo: item.SeoInfo, + }) + } + return resp, nil +} + +var appPlayletRecommendProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + {Key: playletmod.FN.Playlet.IsRecommend, Value: 1}, + {Key: playletmod.FN.Playlet.IsFeatured, Value: 1}, + {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: 1}, + {Key: playletmod.FN.Playlet.MediaCreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.Slug, Value: 1}, + {Key: playletmod.FN.Playlet.Desc, Value: 1}, + {Key: playletmod.FN.Playlet.Anchor, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.Preview, Value: 1}, + {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, + {Key: playletmod.FN.Playlet.TagIds, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.ContentType, Value: 1}, + {Key: playletmod.FN.Playlet.PayType, Value: 1}, + {Key: playletmod.FN.Playlet.SingleCoins, Value: 1}, + {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, + {Key: playletmod.FN.Playlet.FreeEpisodeCount, Value: 1}, + {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, + {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualHeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, + {Key: playletmod.FN.Playlet.CountLike, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Title, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Description, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Keyworld, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, +} + +// playletDetailProjection 短剧详情投影,额外返回详情页需要的评论数量 +func playletDetailProjection() bson.D { + projection := make(bson.D, 0, len(appPlayletRecommendProjection)+1) + projection = append(projection, appPlayletRecommendProjection...) + projection = append(projection, bson.E{Key: playletmod.FN.Playlet.CountComment, Value: 1}) + return projection +} + +// PlayletRecommendList 分页查询 app 端短剧推荐列表,并按固定间隔穿插列表广告 +func PlayletRecommendList(ctx context.Context, req *proto.PlayletRecommendListReq) (*proto.PlayletRecommendListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + categoryId, err := slugser.PlayletCategoryID(ctx, req.CategoryId, req.CategorySlug, false) + if err != nil { + return nil, err + } + tagId, err := slugser.ContentTagID(ctx, req.TagId, req.TagSlug, false) + if err != nil { + return nil, err + } + req.CategoryId = categoryId + req.TagId = tagId + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) + filter, sort := playletRecommendFilterAndSort(req.TagId, req.CategoryId, req.SortList) + config := configser.GetRequestConfig(ctx) + ads := configser.GetAdvitise().PostList + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + if len(ads) == 0 { + return playletRecommendListWithoutAds(ctx, filter, sort, req.CategoryId, pageNum, pageSize, totalCount, config.ImgCdn) + } + adsTotalCount := moreser.InsertedAdsCount(totalCount, int64(len(ads)), playletRecommendAdInterval) + mixedTotalCount := totalCount + adsTotalCount + start := (pageNum - 1) * pageSize + if start >= mixedTotalCount || totalCount == 0 { + resp := emptyPlayletRecommendListPage(pageNum, pageSize) + resp.TotalCount = mixedTotalCount + return resp, nil + } + end := start + pageSize + if end > mixedTotalCount { + end = mixedTotalCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, totalCount, adsTotalCount, playletRecommendAdInterval) + list := playletmod.PlayletList{} + if contentLimit > 0 { + list, err = findPlayletRecommendPage(ctx, filter, sort, contentStart, contentLimit, req.CategoryId) + if err != nil { + return nil, err + } + } + tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + items := buildPlayletRecommendMixedList(list, ads, config.ImgCdn, tagNameMap, themeNameMap, start, end, contentStart, adsTotalCount) + return &proto.PlayletRecommendListResp{BasePageResp: response.BasePageResp{ + TotalCount: mixedTotalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }}, nil +} + +// PlayletList 分页查询已上架短剧,每四部短剧后穿插一条广告 +func PlayletList(ctx context.Context, req *proto.PlayletListReq) (*proto.PlayletListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + if req.SortType != 0 && req.SortType != 1 && req.SortType != 2 { + return nil, srvdef.New(srvdef.ErrParamError, "排序方式无效", "") + } + filter := playletListFilter() + if slug := strings.TrimSpace(req.ThemeSlug); slug != "" || req.ThemeId > 0 { + themeFilter := bson.M{playletthememod.FN.PlayletTheme.Status: true} + if slug != "" { + themeFilter[playletthememod.FN.PlayletTheme.Slug] = slug + } else { + themeFilter[playletthememod.FN.PlayletTheme.Id] = req.ThemeId + } + theme, err := playletthememod.FindOnePlayletTheme(ctx, themeFilter) + if err != nil { + return nil, err + } + filter[playletmod.FN.Playlet.ThemeIds] = theme.Id + } + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) + playletCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + advitise := configser.GetAdvitise() + ads := advitise.PostList + adCount := playletListInsertedAdsCount(playletCount, len(ads)) + mixedCount := playletCount + adCount + resp := &proto.PlayletListResp{ + BasePageResp: response.BasePageResp{ + TotalCount: mixedCount, PageNum: int(pageNum), PageSize: int(pageSize), + List: make([]*proto.PlayletRecommendListItem, 0), + }, + Advitise: advitise, + } + start := (pageNum - 1) * pageSize + if start >= mixedCount { + return resp, nil + } + end := start + pageSize + if end > mixedCount { + end = mixedCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, playletCount, adCount, playletListAdInterval) + list := make(playletmod.PlayletList, 0) + if contentLimit > 0 { + list, err = findPlayletRecommendPage(ctx, filter, playletListSort(req.SortType), contentStart, contentLimit) + if err != nil { + return nil, err + } + } + tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + resp.List = buildPlayletListMixedPage(list, ads, configser.GetRequestConfig(ctx).ImgCdn, + tagNames, themeNames, start, end, contentStart, adCount) + return resp, nil +} + +// ContentList 分页查询上架短剧或视频,短剧每四条、视频每五条穿插一条广告 +func ContentList(ctx context.Context, req *proto.PlayletContentListReq) (*proto.PlayletContentListResp, *srvdef.SrvErr) { + if req == nil || (req.DataType != 1 && req.DataType != 2) { + return nil, srvdef.New(srvdef.ErrParamError, "数据类型无效", "") + } + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) + var ( + contentCount int64 + err *srvdef.SrvErr + ) + if req.DataType == 1 { + contentCount, err = playletmod.CountPlaylet(ctx, bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}) + } else { + contentCount, err = videoinformationmod.CountVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + }) + } + if err != nil { + return nil, err + } + advitise := configser.GetAdvitise() + ads := advitise.PostList + interval := playletContentAdInterval(req.DataType) + adCount := playletContentInsertedAdsCount(contentCount, len(ads), interval) + mixedCount := contentCount + adCount + resp := &proto.PlayletContentListResp{ + BasePageResp: response.BasePageResp{ + TotalCount: mixedCount, PageNum: int(pageNum), PageSize: int(pageSize), + List: make([]*proto.PlayletContentListItem, 0), + }, + Advitise: advitise, + } + start := (pageNum - 1) * pageSize + if start >= mixedCount { + return resp, nil + } + end := start + pageSize + if end > mixedCount { + end = mixedCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, contentCount, adCount, interval) + imgCdn := configser.GetRequestConfig(ctx).ImgCdn + items := make([]*proto.PlayletContentListItem, 0, contentLimit) + if contentLimit > 0 { + items, err = findPlayletContentPage(ctx, req.DataType, contentStart, contentLimit, imgCdn) + if err != nil { + return nil, err + } + } + resp.List = buildPlayletContentMixedPage(items, ads, imgCdn, + start, end, contentStart, adCount, interval) + return resp, nil +} + +// playletContentAdInterval 根据数据类型返回广告插入间隔 +func playletContentAdInterval(dataType int64) int64 { + if dataType == 2 { + return playletContentVideoAdInterval + } + return playletContentPlayletAdInterval +} + +// playletContentInsertedAdsCount 只在后续仍有内容时插入广告,素材不足时循环使用 +func playletContentInsertedAdsCount(contentCount int64, adsCount int, interval int64) int64 { + if contentCount <= 1 || adsCount == 0 { + return 0 + } + return (contentCount - 1) / interval +} + +// findPlayletContentPage 按数据类型查询当前页需要的内容并转换为统一列表项 +func findPlayletContentPage(ctx context.Context, dataType, skip, limit int64, imgCdn string) ([]*proto.PlayletContentListItem, *srvdef.SrvErr) { + items := make([]*proto.PlayletContentListItem, 0, limit) + if dataType == 1 { + list, err := findPlayletRecommendPage(ctx, + bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}, playletContentPlayletSort(), skip, limit) + if err != nil { + return nil, err + } + tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + for _, item := range list { + data := playletToAppData(item, imgCdn, tagNames, themeNames) + items = append(items, &proto.PlayletContentListItem{Type: 0, Playlet: &data}) + } + return items, nil + } + list := make(videoinformationmod.VideoInformationList, 0) + if err := videoinformationmod.AggregateVideoInformations(ctx, &list, playletContentVideoPipeline(skip, limit)); err != nil { + return nil, err + } + for _, item := range list { + data := playletContentVideoInfo(item, imgCdn) + items = append(items, &proto.PlayletContentListItem{Type: 0, Video: &data}) + } + return items, nil +} + +// playletContentPlayletSort 先按短剧累计浏览数排序,再按热度和 ID 稳定排序 +func playletContentPlayletSort() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// playletContentVideoPipeline 按视频展示观看数分页,展示观看数为真实数与后台配置数之和 +func playletContentVideoPipeline(skip, limit int64) []bson.M { + return []bson.M{ + {"$match": bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}}, + {"$addFields": bson.M{"watchScore": bson.M{"$add": []interface{}{ + bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Real.WatchTimes, 0}}, + bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Fake.WatchTimes, 0}}, + }}}}, + {"$sort": bson.D{ + {Key: "watchScore", Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + }}, + {"$skip": skip}, + {"$limit": limit}, + {"$project": playletContentVideoProjection()}, + } +} + +// playletContentVideoProjection 只读取视频卡片需要的字段 +func playletContentVideoProjection() bson.M { + return bson.M{ + videoinformationmod.FN.VideoInformation.Id: 1, + videoinformationmod.FN.VideoInformation.Title: 1, + videoinformationmod.FN.VideoInformation.Slug: 1, + videoinformationmod.FN.VideoInformation.Keyworld: 1, + videoinformationmod.FN.VideoInformation.Desc: 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, + } +} + +// playletContentVideoInfo 将视频模型转换为列表卡片 +func playletContentVideoInfo(item *videoinformationmod.VideoInformation, imgCdn string) proto.VideoInfo { + tagNames, tagSlugs := configser.VideoTagNamesAndSlugs(item.Tags) + return proto.VideoInfo{ContentData: 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, + SeoInfo: pubdef.SeoValue{Title: item.Title, Keyworld: append([]string{}, item.Keyworld...), Description: item.Desc}, + }} +} + +// buildPlayletContentMixedPage 按全局位置组装内容与广告并保持跨页间隔一致 +func buildPlayletContentMixedPage(content []*proto.PlayletContentListItem, ads []proxy.AdvitiseInfo, imgCdn string, + start, end, contentStart, adCount, interval int64) []*proto.PlayletContentListItem { + items := make([]*proto.PlayletContentListItem, 0, end-start) + for pos := start; pos < end; pos++ { + adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adCount, interval) + if adIndex >= 0 && len(ads) > 0 { + ad := ads[adIndex%int64(len(ads))] + ad.Images = netutil.MergeURL(imgCdn, ad.Images) + items = append(items, &proto.PlayletContentListItem{Type: 2, Advertise: &ad}) + continue + } + contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adCount, interval) - contentStart + if contentIndex >= 0 && contentIndex < int64(len(content)) { + items = append(items, content[contentIndex]) + } + } + return items +} + +// playletListFilter 只查询已上架短剧 +func playletListFilter() bson.M { + return bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} +} + +// playletListInsertedAdsCount 只在仍有后续短剧时插入广告,广告素材不足时循环使用 +func playletListInsertedAdsCount(playletCount int64, adsCount int) int64 { + if playletCount <= 1 || adsCount == 0 { + return 0 + } + return (playletCount - 1) / playletListAdInterval +} + +// playletListSort 根据列表排序方式生成稳定的短剧顺序 +func playletListSort(sortType int64) bson.D { + switch sortType { + case 2: + return bson.D{{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} + default: + return bson.D{{Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} + } +} + +// buildPlayletListMixedPage 根据全局位置组装当前页,避免跨页后重新计算广告间隔 +func buildPlayletListMixedPage(list playletmod.PlayletList, ads []proxy.AdvitiseInfo, imgCdn string, + tagNames contenttagmod.ContentTagMap, themeNames map[int64]string, start, end, contentStart, adCount int64) []*proto.PlayletRecommendListItem { + items := make([]*proto.PlayletRecommendListItem, 0, end-start) + contentByIndex := make(map[int64]*playletmod.Playlet, len(list)) + for i, item := range list { + contentByIndex[contentStart+int64(i)] = item + } + for pos := start; pos < end; pos++ { + adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adCount, playletListAdInterval) + if adIndex >= 0 && len(ads) > 0 { + items = append(items, playletAdToRecommendListItem(ads[adIndex%int64(len(ads))], imgCdn)) + continue + } + contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adCount, playletListAdInterval) + if item := contentByIndex[contentIndex]; item != nil { + items = append(items, playletToRecommendListItem(item, imgCdn, tagNames, themeNames)) + } + } + return items +} + +// PlayletTagList 根据统一标签ID分页查询已上架短剧 +func PlayletTagList(ctx context.Context, req *proto.ContentTagPageReq) (*proto.PlayletRecommendListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") + } + tagId, err := slugser.ContentTagID(ctx, req.Id, req.Slug, true) + if err != nil { + return nil, err + } + req.Id = tagId + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) + ok, err := configser.HasEnabledContentTagBind(ctx, req.Id, contenttagmod.ContentTagTypePlaylet) + if err != nil { + return nil, err + } + if !ok { + return emptyPlayletRecommendListPage(pageNum, pageSize), nil + } + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + playletmod.FN.Playlet.TagIds: req.Id, + } + sort := bson.D{ + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + config := configser.GetRequestConfig(ctx) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + return playletRecommendListWithoutAds(ctx, filter, sort, 0, pageNum, pageSize, totalCount, config.ImgCdn) +} + +// QueryPlayletInfo 查询短剧详情、剧集列表和点赞状态 +func QueryPlayletInfo(ctx context.Context, userId int64, ip, ua string, req *proto.QueryPlayletInfoReq) (*proto.QueryPlayletInfoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + playletId, err := slugser.PlayletID(ctx, req.Id, req.Slug, true) + if err != nil { + return nil, err + } + req.Id = playletId + config := configser.GetRequestConfig(ctx) + playletInfo, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: req.Id, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.FindOne().SetProjection(playletDetailProjection())) + if err != nil { + return nil, err + } + go incrementPlayletWatchCount(playletInfo) + chapterList, isLike, err := queryPlayletDetailExtraData(ctx, userId, playletInfo, ip, ua) + if err != nil { + return nil, err + } + isFavorite, err := queryPlayletIsFavorite(ctx, userId, playletInfo.Id) + if err != nil { + return nil, err + } + tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, playletmod.PlayletList{playletInfo}) + if err != nil { + return nil, err + } + if err := applyOfficialChapterAccess(ctx, playletInfo, chapterList, ip); err != nil { + return nil, err + } + return &proto.QueryPlayletInfoResp{ + FreeEpisodeCount: playletInfo.EffectiveFreeEpisodeCount(), + Path: playletRequestPath(ctx), + Translations: translationser.GetTranslationData(), + Config: config, + BottonTranslations: translationser.GetTranslationBottomList(), + Advitise: configser.GetAdvitise(), + Announcement: configser.GetAnnouncement(), + PlayletInfo: playletDetailInfoData(playletInfo, config.ImgCdn, tagNameMap, themeNameMap, isLike, isFavorite), + ChapterList: chapterList, + IsLike: isLike, + IsFavorite: isFavorite, + }, nil +} + +// QueryPlayletChapterInfo 查询短剧剧集详情、所属短剧、剧集列表、热门短剧列表和点赞状态 +func QueryPlayletChapterInfo(ctx context.Context, userId int64, ip, ua string, req *proto.QueryPlayletChapterInfoReq) (*proto.QueryPlayletChapterInfoResp, *srvdef.SrvErr) { + if req == nil || req.Id <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + config := configser.GetRequestConfig(ctx) + chapterInfo, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: req.Id, + }, options.FindOne().SetProjection(playletChapterProjection())) + if err != nil { + return nil, err + } + playletInfo, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: chapterInfo.PlayletId, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.FindOne().SetProjection(appPlayletRecommendProjection)) + if err != nil { + return nil, err + } + if !playletInfo.IsOfficialEpisodeOpen(chapterInfo.CurrentEpisode) { + return nil, srvdef.New(srvdef.ErrNoData, "本集暂未在官网开放", "") + } + if err = ensureOriginalChapterWatermarkReady(playletInfo, chapterInfo); err != nil { + return nil, err + } + go incrementPlayletWatchCount(playletInfo) + go incrementPlayletChapterWatchCount(chapterInfo.Id) + + hotPlayletList, chapterList, isLike, err := queryPlayletChapterDetailExtraData(ctx, userId, playletInfo, ip, ua) + if err != nil { + return nil, err + } + isFavorite, err := queryPlayletIsFavorite(ctx, userId, playletInfo.Id) + if err != nil { + return nil, err + } + allList := make(playletmod.PlayletList, 0, len(hotPlayletList)+1) + allList = append(allList, playletInfo) + allList = append(allList, hotPlayletList...) + tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, allList) + if err != nil { + return nil, err + } + chapterData := make([]proto.PlayletChapterInfo, 0, len(chapterList)+1) + chapterData = append(chapterData, chapterList...) + chapterData = append(chapterData, playletChapterToInfo(chapterInfo, ip, ua, isOriginalPlaylet(playletInfo))) + if err := applyOfficialChapterAccess(ctx, playletInfo, chapterData, ip); err != nil { + return nil, err + } + return &proto.QueryPlayletChapterInfoResp{ + FreeEpisodeCount: playletInfo.EffectiveFreeEpisodeCount(), + Path: playletRequestPath(ctx), + Translations: translationser.GetTranslationData(), + Config: config, + BottonTranslations: translationser.GetTranslationBottomList(), + Advitise: configser.GetAdvitise(), + Announcement: configser.GetAnnouncement(), + ChapterInfo: chapterData[len(chapterData)-1], + PlayletInfo: playletToAppData(playletInfo, config.ImgCdn, tagNameMap, themeNameMap), + ChapterList: chapterData[:len(chapterData)-1], + HotPlayletList: playletListToAppData(hotPlayletList, config.ImgCdn, tagNameMap, themeNameMap), + IsLike: isLike, + IsFavorite: isFavorite, + }, nil +} + +func applyOfficialChapterAccess(ctx context.Context, playlet *playletmod.Playlet, chapters []proto.PlayletChapterInfo, ip string) *srvdef.SrvErr { + if len(chapters) == 0 { + return nil + } + ids := make([]int64, 0, len(chapters)) + for i := range chapters { + ids = append(ids, chapters[i].Id) + } + raw, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}, + playletchaptermod.FN.PlayletChapter.PlayletId: playlet.Id, + }) + if err != nil { + return err + } + byID := make(map[int64]*playletchaptermod.PlayletChapter, len(raw)) + for _, chapter := range raw { + byID[chapter.Id] = chapter + } + for i := range chapters { + chapter := byID[chapters[i].Id] + open := chapter != nil && playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode) + chapters[i].IsOfficialOpen = &open + chapters[i].VideoUrl = "" + if !open { + continue + } + url, useNewKey := chapterPlaySource(chapter, isOriginalPlaylet(playlet)) + if url != "" { + chapters[i].VideoUrl = videoser.GetM3U8OfficialParam(chapter.Id, playlet.Id, url, ip, useNewKey) + } + } + return nil +} + +// LikePlaylet 当前登录用户点赞或取消点赞指定短剧 +func LikePlaylet(ctx context.Context, userId int64, ip string, ua useragent.UA, req *proto.LikePlayletReq) (*proto.LikePlayletResp, *srvdef.SrvErr) { + if req == nil || userId <= 0 || req.Id <= 0 || req.IsLike == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + playletInfo, 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.Slug: 1, + playletmod.FN.Playlet.ThemeIds: 1, + playletmod.FN.Playlet.TagIds: 1, + playletmod.FN.Playlet.TotalEpisode: 1, + playletmod.FN.Playlet.CountLike: 1, + playletmod.FN.Playlet.VirtualCountLike: 1, + })) + if err != nil { + return nil, err + } + if *req.IsLike { + created, err := playletlikemod.UpsertOnePlayletLike(ctx, userId, req.Id, time.Now()) + if err != nil { + return nil, err + } + if created { + playletInfo.CountLike++ + if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountLike: 1}}); err != nil { + return nil, err + } + if err := reportser.ReportPlayletLike(userId, ua, ip, playletInfo, 1); err != nil { + log.Warn("ReportPlayletLike failed", log.Any("playletId", req.Id), log.Any("userId", userId), log.E(err)) + } + } + } else { + deleted, err := playletlikemod.DeleteOnePlayletLike(ctx, bson.M{ + playletlikemod.FN.PlayletLike.UserID: userId, + playletlikemod.FN.PlayletLike.PlayletID: req.Id, + }) + if err != nil { + return nil, err + } + if deleted { + if playletInfo.CountLike > 0 { + playletInfo.CountLike-- + if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountLike: -1}}); err != nil { + return nil, err + } + } + if err := reportser.ReportPlayletLike(userId, ua, ip, playletInfo, 2); err != nil { + log.Warn("ReportPlayletLike failed", log.Any("playletId", req.Id), log.Any("userId", userId), log.E(err)) + } + } + } + return &proto.LikePlayletResp{ + Id: req.Id, + IsLike: *req.IsLike, + LikeNum: playletInfo.CountLike, + }, nil +} + +// QueryPlayletRank 分页查询短剧排行榜,读取独立的 10 分钟内存缓存。 +// 点赞榜按累计点赞量、周/月/年榜按对应周期观看量取前 50;不足时随机补位。分页 pageSize 不是 50 时强制为 50。 +func QueryPlayletRank(ctx context.Context, req *proto.PlayletRankReq) (*proto.PlayletRankResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + period := proto.PlayletRankPeriod(req.RankPeriod) + if period < proto.PlayletRankPeriodLike || period > proto.PlayletRankPeriodYear { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := configser.NormalizeRankPage(req.PageNum, req.PageSize) + all := configser.GetPlayletRankList(configser.RankPeriod(period), 0) + items := configser.SliceRankPage(all, pageNum, pageSize) + return &proto.PlayletRankResp{BasePageResp: response.BasePageResp{ + TotalCount: int64(len(all)), + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}, nil +} + +// HomePlayletData 查询首页推荐、最新、最热三组短剧数据 +func HomePlayletData(ctx context.Context, imgCdn string) (proto.HomePlayletDataResp, *srvdef.SrvErr) { + resp := proto.HomePlayletDataResp{ + RecommendList: make([]proto.PlayletData, 0), + NewestList: make([]proto.PlayletData, 0), + HotList: make([]proto.PlayletData, 0), + } + var ( + recommendList playletmod.PlayletList + newestList playletmod.PlayletList + hotList playletmod.PlayletList + wg sync.WaitGroup + mu sync.Mutex + err1 *srvdef.SrvErr + ) + setErr := func(err *srvdef.SrvErr) { + if err == nil { + return + } + mu.Lock() + defer mu.Unlock() + if err1 == nil { + err1 = err + } + } + queryList := func(sortType proto.PlayletRecommendSort, target *playletmod.PlayletList) { + defer wg.Done() + filter, sort := playletRecommendFilterAndSort(0, 0, int64(sortType)) + list, err := findPlayletRecommendPage(ctx, filter, sort, 0, homePlayletListLimit) + if err != nil { + setErr(err) + return + } + *target = list + } + wg.Add(3) + go queryList(proto.PlayletRecommendSortRecommend, &recommendList) + go queryList(proto.PlayletRecommendSortNewest, &newestList) + go queryList(proto.PlayletRecommendSortHot, &hotList) + wg.Wait() + if err1 != nil { + return resp, err1 + } + allList := make(playletmod.PlayletList, 0, len(recommendList)+len(newestList)+len(hotList)) + allList = append(allList, recommendList...) + allList = append(allList, newestList...) + allList = append(allList, hotList...) + tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, allList) + if err != nil { + return resp, err + } + resp.RecommendList = playletListToAppData(recommendList, imgCdn, tagNameMap, themeNameMap) + resp.NewestList = playletListToAppData(newestList, imgCdn, tagNameMap, themeNameMap) + resp.HotList = playletListToAppData(hotList, imgCdn, tagNameMap, themeNameMap) + return resp, nil +} + +// playletRecommendFilterAndSort 根据排序枚举构建短剧查询条件和排序规则 +func playletRecommendFilterAndSort(tagId, categoryId, sortList int64) (bson.M, bson.D) { + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + if tagId > 0 { + filter[playletmod.FN.Playlet.TagIds] = tagId + } + if categoryId > 0 { + filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{categoryId}} + } + switch proto.PlayletRecommendSort(sortList) { + case proto.PlayletRecommendSortNewest: + return filter, bson.D{ + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case proto.PlayletRecommendSortHot: + return filter, bson.D{ + {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + default: + if categoryId <= 0 { + filter[playletmod.FN.Playlet.IsRecommend] = true + } else { + return filter, playletmod.CategoryRankSort() + } + return filter, bson.D{ + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + } +} + +// findPlayletRecommendPage 查询当前混排分页需要的短剧数据 +func findPlayletRecommendPage(ctx context.Context, filter bson.M, sort bson.D, skip, limit int64, categoryIds ...int64) (playletmod.PlayletList, *srvdef.SrvErr) { + if len(categoryIds) > 0 && categoryIds[0] > 0 && len(sort) > 0 && sort[0].Key == "_categoryRank" { + return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryIds[0], skip, limit, sort, appPlayletRecommendProjection) + } + computedSort := make(bson.D, len(sort)) + copy(computedSort, sort) + addFields := bson.M{} + for i := range computedSort { + switch computedSort[i].Key { + case playletmod.FN.Playlet.CountBrowse: + computedSort[i].Key = "displayCountBrowse" + addFields["displayCountBrowse"] = bson.M{"$add": []interface{}{ + bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.CountBrowse, 0}}, + bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.VirtualCountBrowse, 0}}, + }} + case playletmod.FN.Playlet.HeatValue: + computedSort[i].Key = "displayHeatValue" + addFields["displayHeatValue"] = bson.M{"$add": []interface{}{ + bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.HeatValue, 0}}, + bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.VirtualHeatValue, 0}}, + }} + } + } + if len(addFields) > 0 { + list := make(playletmod.PlayletList, 0) + pipeline := []bson.M{ + {"$match": filter}, + {"$addFields": addFields}, + {"$sort": computedSort}, + {"$skip": skip}, + {"$limit": limit}, + {"$project": appPlayletRecommendProjection}, + } + if err := playletmod.AggregatePlaylet(ctx, &list, pipeline, options.Aggregate().SetAllowDiskUse(true)); err != nil { + return nil, err + } + return list, nil + } + return playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetProjection(appPlayletRecommendProjection). + SetSort(sort). + SetSkip(skip). + SetLimit(limit)) +} + +// playletListToAppData 批量转换短剧模型为 app 短剧数据 +func playletListToAppData(list playletmod.PlayletList, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) []proto.PlayletData { + result := make([]proto.PlayletData, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + result = append(result, playletToAppData(item, imgCdn, tagNameMap, themeNameMap)) + } + return result +} + +// playletRecommendListWithoutAds 无广告时直接按短剧分页查询 +func playletRecommendListWithoutAds(ctx context.Context, filter bson.M, sort bson.D, categoryId, pageNum, pageSize, totalCount int64, imgCdn string) (*proto.PlayletRecommendListResp, *srvdef.SrvErr) { + list, err := findPlayletRecommendPage(ctx, filter, sort, (pageNum-1)*pageSize, pageSize, categoryId) + if err != nil { + return nil, err + } + tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletRecommendListItem, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, playletToRecommendListItem(item, imgCdn, tagNameMap, themeNameMap)) + } + return &proto.PlayletRecommendListResp{BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }}, nil +} + +// buildPlayletRecommendMixedList 按混排后的全局位置切出当前页短剧和广告数据 +func buildPlayletRecommendMixedList(list playletmod.PlayletList, ads []proxy.AdvitiseInfo, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string, start, end, contentStart, adsTotal int64) []*proto.PlayletRecommendListItem { + items := make([]*proto.PlayletRecommendListItem, 0, end-start) + contentByIndex := make(map[int64]*playletmod.Playlet, len(list)) + for i, item := range list { + contentByIndex[contentStart+int64(i)] = item + } + for pos := start; pos < end; pos++ { + adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adsTotal, playletRecommendAdInterval) + if adIndex >= 0 && adIndex < adsTotal { + items = append(items, playletAdToRecommendListItem(ads[adIndex], imgCdn)) + continue + } + contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adsTotal, playletRecommendAdInterval) + if item := contentByIndex[contentIndex]; item != nil { + items = append(items, playletToRecommendListItem(item, imgCdn, tagNameMap, themeNameMap)) + } + } + return items +} + +// playletToRecommendListItem 将短剧模型转换为 app 推荐短剧列表项 +func playletToRecommendListItem(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) *proto.PlayletRecommendListItem { + return &proto.PlayletRecommendListItem{ + Type: proto.PlayletRecommendItemTypePlaylet, + PlayletData: playletToAppData(item, imgCdn, tagNameMap, themeNameMap), + } +} + +// playletAdToRecommendListItem 将广告转换为 app 推荐短剧列表项 +func playletAdToRecommendListItem(ad proxy.AdvitiseInfo, imgCdn string) *proto.PlayletRecommendListItem { + return &proto.PlayletRecommendListItem{ + Type: proto.PlayletRecommendItemTypeAdvertise, + AdvertiseId: ad.Id, + Link: ad.Link, + Location: ad.Location, + PlayletData: proto.PlayletData{ + Title: ad.Title, + Cover: netutil.MergeURL(imgCdn, ad.Images), + }, + } +} + +// queryPlayletDetailExtraData 并发查询详情页附加数据,缩短详情接口整体等待时间 +func queryPlayletDetailExtraData(ctx context.Context, userId int64, playletInfo *playletmod.Playlet, ip, ua string) ([]proto.PlayletChapterInfo, bool, *srvdef.SrvErr) { + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr *srvdef.SrvErr + chapterList := make([]proto.PlayletChapterInfo, 0) + isLike := false + setErr := func(err *srvdef.SrvErr) { + if err == nil { + return + } + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + + wg.Add(2) + go func() { + defer wg.Done() + data, err := queryPlayletChapterList(ctx, playletInfo.Id, ip, ua, isOriginalPlaylet(playletInfo)) + if err != nil { + setErr(err) + return + } + mu.Lock() + chapterList = data + mu.Unlock() + }() + go func() { + defer wg.Done() + data, err := queryPlayletIsLike(ctx, userId, playletInfo.Id) + if err != nil { + setErr(err) + return + } + mu.Lock() + isLike = data + mu.Unlock() + }() + wg.Wait() + if firstErr != nil { + return nil, false, firstErr + } + return chapterList, isLike, nil +} + +// queryPlayletChapterDetailExtraData 并发查询剧集详情页附加数据,热门短剧只按相同分类查询且不做兜底 +func queryPlayletChapterDetailExtraData(ctx context.Context, userId int64, playletInfo *playletmod.Playlet, ip, ua string) (playletmod.PlayletList, []proto.PlayletChapterInfo, bool, *srvdef.SrvErr) { + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr *srvdef.SrvErr + hotPlayletList := make(playletmod.PlayletList, 0) + chapterList := make([]proto.PlayletChapterInfo, 0) + isLike := false + setErr := func(err *srvdef.SrvErr) { + if err == nil { + return + } + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + + wg.Add(3) + go func() { + defer wg.Done() + data, err := queryPlayletChapterDetailHotList(ctx, playletInfo) + if err != nil { + setErr(err) + return + } + mu.Lock() + hotPlayletList = data + mu.Unlock() + }() + go func() { + defer wg.Done() + data, err := queryPlayletChapterList(ctx, playletInfo.Id, ip, ua, isOriginalPlaylet(playletInfo)) + if err != nil { + setErr(err) + return + } + mu.Lock() + chapterList = data + mu.Unlock() + }() + go func() { + defer wg.Done() + data, err := queryPlayletIsLike(ctx, userId, playletInfo.Id) + if err != nil { + setErr(err) + return + } + mu.Lock() + isLike = data + mu.Unlock() + }() + wg.Wait() + if firstErr != nil { + return nil, nil, false, firstErr + } + return hotPlayletList, chapterList, isLike, nil +} + +// incrementPlayletWatchCount 异步增加短剧浏览、热度和周/月/年窗口观看数。 +// 周/月/年窗口按北京时间切:周一 00:00、每月 1 日 00:00、每年 1 月 1 日 00:00。 +func incrementPlayletWatchCount(playletInfo *playletmod.Playlet) { + if playletInfo == nil { + return + } + now := utils.Now() + incDoc := bson.M{ + playletmod.FN.Playlet.CountBrowse: 1, + playletmod.FN.Playlet.HeatValue: 1, + } + setDoc := bson.M{ + playletmod.FN.Playlet.WatchUpdatedAt: now, + } + if playletInfo.WatchUpdatedAt.After(utils.BeginOfWeekByTime(now)) { + incDoc[playletmod.FN.Playlet.WeekWatchs] = 1 + } else { + setDoc[playletmod.FN.Playlet.WeekWatchs] = 1 + } + if playletInfo.WatchUpdatedAt.After(utils.BeginOfMonth(now)) { + incDoc[playletmod.FN.Playlet.MonthWatchs] = 1 + } else { + setDoc[playletmod.FN.Playlet.MonthWatchs] = 1 + } + if playletInfo.WatchUpdatedAt.After(utils.BeginOfYear(now)) { + incDoc[playletmod.FN.Playlet.YearWatchs] = 1 + } else { + setDoc[playletmod.FN.Playlet.YearWatchs] = 1 + } + if err := playletmod.UpdateOnePlaylet(context.Background(), bson.M{ + playletmod.FN.Playlet.Id: playletInfo.Id, + }, bson.M{"$set": setDoc, "$inc": incDoc}); err != nil { + log.Warn("incrementPlayletWatchCount failed", log.Any("playletId", playletInfo.Id), log.E(err)) + } +} + +// incrementPlayletChapterWatchCount 异步增加短剧剧集浏览数,避免阻塞剧集详情读取 +func incrementPlayletChapterWatchCount(chapterId int64) { + if chapterId <= 0 { + return + } + if err := playletchaptermod.UpdateOnePlayletChapter(context.Background(), bson.M{ + playletchaptermod.FN.PlayletChapter.Id: chapterId, + }, bson.M{"$inc": bson.M{ + playletchaptermod.FN.PlayletChapter.CountBrowse: 1, + }}); err != nil { + log.Warn("更新短剧剧集浏览数失败", log.Any("chapterId", chapterId), log.E(err)) + } +} + +// queryPlayletChapterDetailHotList 查询剧集详情页热门短剧,按相同分类、累计点击量、更新时间排序,最多返回11条 +func queryPlayletChapterDetailHotList(ctx context.Context, playletInfo *playletmod.Playlet) (playletmod.PlayletList, *srvdef.SrvErr) { + filter := bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$ne": playletInfo.Id}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + if len(playletInfo.CategoryIds) > 0 { + filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": playletInfo.CategoryIds} + } + return findPlayletRecommendPage(ctx, filter, bson.D{ + {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, 0, playletDetailHotLimit) +} + +// queryPlayletChapterList 查询短剧剧集列表,并按集数升序返回。 +// 原创短剧只返回已有水印地址的剧集:首次未处理好的不露出,换水印期间仍可播旧地址。 +func queryPlayletChapterList(ctx context.Context, playletId int64, ip, ua string, isOriginal bool) ([]proto.PlayletChapterInfo, *srvdef.SrvErr) { + filter := bson.M{ + playletchaptermod.FN.PlayletChapter.PlayletId: playletId, + } + if isOriginal { + // 必须含非空白水印地址;空串、空白、缺字段都排除 + filter[playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl] = bson.M{"$regex": "\\S"} + } + list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find(). + SetProjection(playletChapterProjection()). + SetSort(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + })) + if err != nil { + return nil, err + } + data := make([]proto.PlayletChapterInfo, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + if isOriginal && strings.TrimSpace(item.WatermarkVideoUrl) == "" { + continue + } + data = append(data, playletChapterToInfo(item, ip, ua, isOriginal)) + } + return data, nil +} + +// playletChapterProjection 限定详情页剧集字段,避免返回整条剧集文档 +func playletChapterProjection() bson.M { + return bson.M{ + playletchaptermod.FN.PlayletChapter.Id: 1, + playletchaptermod.FN.PlayletChapter.PlayletId: 1, + playletchaptermod.FN.PlayletChapter.MediaContentId: 1, + playletchaptermod.FN.PlayletChapter.CurrentEpisode: 1, + playletchaptermod.FN.PlayletChapter.Title: 1, + playletchaptermod.FN.PlayletChapter.Md5: 1, + playletchaptermod.FN.PlayletChapter.Hash: 1, + playletchaptermod.FN.PlayletChapter.Height: 1, + playletchaptermod.FN.PlayletChapter.Width: 1, + playletchaptermod.FN.PlayletChapter.Ratio: 1, + playletchaptermod.FN.PlayletChapter.VideoUrl: 1, + playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl: 1, + playletchaptermod.FN.PlayletChapter.MediaSize: 1, + playletchaptermod.FN.PlayletChapter.MediaTime: 1, + playletchaptermod.FN.PlayletChapter.CountBrowse: 1, + playletchaptermod.FN.PlayletChapter.CountCollect: 1, + playletchaptermod.FN.PlayletChapter.CountLike: 1, + playletchaptermod.FN.PlayletChapter.CountComment: 1, + playletchaptermod.FN.PlayletChapter.PayType: 1, + playletchaptermod.FN.PlayletChapter.SingleCoins: 1, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: 1, + playletchaptermod.FN.PlayletChapter.CreatedAt: 1, + playletchaptermod.FN.PlayletChapter.UpdatedAt: 1, + } +} + +// playletChapterToInfo 将短剧剧集模型转换为详情页剧集数据。 +// 原创短剧只下水印地址,避免未处理好的剧集回退成原片地址。 +func playletChapterToInfo(item *playletchaptermod.PlayletChapter, ip, ua string, isOriginal bool) proto.PlayletChapterInfo { + _ = ua + videoURL, useNewKey := chapterPlaySource(item, isOriginal) + if videoURL != "" { + videoURL = videoser.GetPlayletM3U8AuthParam(item.Id, videoURL, ip, useNewKey) + } + return proto.PlayletChapterInfo{ + Id: item.Id, + PlayletId: item.PlayletId, + MediaContentId: item.MediaContentId, + CurrentEpisode: item.CurrentEpisode, + Title: item.Title, + Md5: item.Md5, + Hash: item.Hash, + Height: item.Height, + Width: item.Width, + Ratio: item.Ratio, + VideoUrl: videoURL, + MediaSize: item.MediaSize, + MediaTime: item.MediaTime, + CountBrowse: item.CountBrowse, + CountCollect: item.CountCollect, + CountLike: item.CountLike, + CountComment: item.CountComment, + PayType: item.PayType, + SingleCoins: item.SingleCoins, + WatermarkStatus: item.WatermarkStatus, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// chapterPlaySource 优先用水印地址。没有水印时,非原创短剧回退原片,并标记使用原片密钥。 +func chapterPlaySource(chapter *playletchaptermod.PlayletChapter, isOriginal bool) (string, bool) { + if chapter == nil { + return "", false + } + if url := strings.TrimSpace(chapter.WatermarkVideoUrl); url != "" { + return url, false + } + if isOriginal { + return "", false + } + url := strings.TrimSpace(chapter.VideoUrl) + return url, url != "" +} + +// isOriginalPlaylet 判断是否原创短剧。 +// 后台新增剧没有媒资ID;历史数据可能缺 isOriginal 字段,按无媒资ID兜底。 +func isOriginalPlaylet(playlet *playletmod.Playlet) bool { + if playlet == nil { + return false + } + return playlet.IsOriginal || playlet.MediaCenterId == 0 +} + +// ensureOriginalChapterWatermarkReady 原创剧集必须已有水印地址才允许播放。 +func ensureOriginalChapterWatermarkReady(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *srvdef.SrvErr { + if !isOriginalPlaylet(playlet) || chapter == nil { + return nil + } + if strings.TrimSpace(chapter.WatermarkVideoUrl) != "" { + return nil + } + return srvdef.New(srvdef.ErrNoData, "剧集处理中,请稍后观看", "剧集处理中,请稍后观看") +} + +// queryPlayletIsLike 判断当前登录用户是否已点赞该短剧,未登录直接返回 false +func queryPlayletIsLike(ctx context.Context, userId, playletId int64) (bool, *srvdef.SrvErr) { + if userId <= 0 { + return false, nil + } + count, err := playletlikemod.CountPlayletLike(ctx, bson.M{ + playletlikemod.FN.PlayletLike.UserID: userId, + playletlikemod.FN.PlayletLike.PlayletID: playletId, + }) + if err != nil { + return false, err + } + return count > 0, nil +} + +// playletDetailInfoData 将短剧模型转换为详情页专用响应结构 +func playletDetailInfoData(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string, isLike, isFavorite bool) proto.PlayletDetailInfo { + return proto.PlayletDetailInfo{ + PlayletData: playletToAppData(item, imgCdn, tagNameMap, themeNameMap), + CountComment: item.CountComment, + IsLike: isLike, + IsFavorite: isFavorite, + } +} + +// queryPlayletIsFavorite 判断当前用户是否已收藏短剧,未登录返回 false +func queryPlayletIsFavorite(ctx context.Context, userID, playletID int64) (bool, *srvdef.SrvErr) { + if userID <= 0 { + return false, nil + } + count, err := playletfavoritemod.Count(ctx, bson.M{playletfavoritemod.FieldUserID: userID, playletfavoritemod.FieldPlayletID: playletID}) + return count > 0, err +} + +// countPlayletTotalLikeRank 统计累计点赞榜可展示短剧数量 +func countPlayletTotalLikeRank(ctx context.Context) (int64, *srvdef.SrvErr) { + rows := make([]playletRankCount, 0) + if err := playletmod.AggregatePlaylet(ctx, &rows, playletTotalLikeRankCountPipeline()); err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + return rows[0].Total, nil +} + +// aggregatePlayletTotalLikeRank 分页查询累计点赞榜短剧 +func aggregatePlayletTotalLikeRank(ctx context.Context, skip, limit int64) ([]playletRankAggItem, *srvdef.SrvErr) { + list := make([]playletRankAggItem, 0) + if err := playletmod.AggregatePlaylet(ctx, &list, playletTotalLikeRankPagePipeline(skip, limit)); err != nil { + return nil, err + } + return list, nil +} + +// countPlayletPeriodLikeRank 统计周期新增点赞榜可展示短剧数量 +func countPlayletPeriodLikeRank(ctx context.Context, since time.Time) (int64, *srvdef.SrvErr) { + rows := make([]playletRankCount, 0) + if err := playletlikemod.AggregatePlayletLike(ctx, &rows, playletPeriodLikeRankCountPipeline(since)); err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + return rows[0].Total, nil +} + +// aggregatePlayletPeriodLikeRank 分页查询周期新增点赞榜短剧 +func aggregatePlayletPeriodLikeRank(ctx context.Context, since time.Time, skip, limit int64) ([]playletRankAggItem, *srvdef.SrvErr) { + list := make([]playletRankAggItem, 0) + if err := playletlikemod.AggregatePlayletLike(ctx, &list, playletPeriodLikeRankPagePipeline(since, skip, limit)); err != nil { + return nil, err + } + return list, nil +} + +// playletRankPeriodSince 将排行榜周期转换为点赞起始时间 +func playletRankPeriodSince(period proto.PlayletRankPeriod) time.Time { + now := time.Now() + switch period { + case proto.PlayletRankPeriodWeek: + return now.AddDate(0, 0, -7) + case proto.PlayletRankPeriodMonth: + return now.AddDate(0, -1, 0) + case proto.PlayletRankPeriodYear: + return now.AddDate(-1, 0, 0) + default: + return time.Time{} + } +} + +// playletTotalLikeRankCountPipeline 构建累计点赞榜数量统计管道 +func playletTotalLikeRankCountPipeline() []bson.M { + return append(playletTotalLikeRankBasePipeline(), bson.M{"$count": "total"}) +} + +// playletTotalLikeRankPagePipeline 构建累计点赞榜分页查询管道 +func playletTotalLikeRankPagePipeline(skip, limit int64) []bson.M { + pipeline := playletTotalLikeRankBasePipeline() + pipeline = append(pipeline, + bson.M{"$sort": bson.D{ + {Key: "rankScore", Value: -1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }}, + bson.M{"$skip": skip}, + bson.M{"$limit": limit}, + bson.M{"$project": playletRankProjection()}, + ) + return pipeline +} + +// playletTotalLikeRankBasePipeline 构建累计点赞榜基础过滤管道 +func playletTotalLikeRankBasePipeline() []bson.M { + return []bson.M{ + {"$match": bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}}, + {"$addFields": bson.M{"rankScore": bson.M{"$add": []interface{}{ + bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.CountLike, 0}}, + bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.VirtualCountLike, 0}}, + }}}}, + {"$match": bson.M{"rankScore": bson.M{"$gt": 0}}}, + } +} + +// playletPeriodLikeRankCountPipeline 构建周期新增点赞榜数量统计管道 +func playletPeriodLikeRankCountPipeline(since time.Time) []bson.M { + return append(playletPeriodLikeRankBasePipeline(since), bson.M{"$count": "total"}) +} + +// playletPeriodLikeRankPagePipeline 构建周期新增点赞榜分页查询管道 +func playletPeriodLikeRankPagePipeline(since time.Time, skip, limit int64) []bson.M { + pipeline := playletPeriodLikeRankBasePipeline(since) + pipeline = append(pipeline, + bson.M{"$sort": bson.D{ + {Key: "rankScore", Value: -1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }}, + bson.M{"$skip": skip}, + bson.M{"$limit": limit}, + bson.M{"$project": playletRankProjection()}, + ) + return pipeline +} + +// playletPeriodLikeRankBasePipeline 构建周期新增点赞榜基础过滤管道 +func playletPeriodLikeRankBasePipeline(since time.Time) []bson.M { + return []bson.M{ + {"$match": bson.M{playletlikemod.FN.PlayletLike.CreatedAt: bson.M{"$gte": since}}}, + {"$group": bson.M{ + "_id": "$" + playletlikemod.FN.PlayletLike.PlayletID, + "rankScore": bson.M{"$sum": 1}, + }}, + {"$lookup": bson.M{ + "from": playletmod.TN.Playlet, + "localField": "_id", + "foreignField": playletmod.FN.Playlet.Id, + "as": "playlet", + }}, + {"$unwind": "$playlet"}, + {"$match": bson.M{"playlet." + playletmod.FN.Playlet.Status: playletmod.StatusUp}}, + {"$replaceRoot": bson.M{"newRoot": bson.M{"$mergeObjects": []interface{}{"$playlet", bson.M{"rankScore": "$rankScore"}}}}}, + } +} + +// playletRankProjection 限定排行榜返回字段,减少分页查询的数据传输体积 +func playletRankProjection() bson.M { + return bson.M{ + playletmod.FN.Playlet.Id: 1, + playletmod.FN.Playlet.Status: 1, + playletmod.FN.Playlet.IsRecommend: 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.CategoryIds: 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, + "rankScore": 1, + } +} + +// playletRankAggItemsToPlayletList 将排行榜聚合数据转换为短剧列表,复用标签题材批量查询 +func playletRankAggItemsToPlayletList(list []playletRankAggItem) playletmod.PlayletList { + result := make(playletmod.PlayletList, 0, len(list)) + for i := range list { + result = append(result, &list[i].Playlet) + } + return result +} + +// playletRankToData 将排行榜聚合结果转换为榜单展示数据 +func playletRankToData(item playletRankAggItem, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) proto.PlayletRankData { + return proto.PlayletRankData{ + PlayletData: playletToAppData(&item.Playlet, imgCdn, tagNameMap, themeNameMap), + RankScore: item.RankScore, + } +} + +// playletToAppData 将短剧模型转换为 app 短剧基础数据 +func playletToAppData(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: isNewPlaylet(item.OnlineAt), + IsFeatured: item.IsFeatured, + IsOriginal: isOriginalPlaylet(item), + 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: playletTagNamesFromMap(item.TagIds, tagNameMap), + TagSlugs: playletTagSlugsFromMap(item.TagIds, tagNameMap), + ThemeIds: item.ThemeIds, + ThemeNames: playletThemeNamesFromMap(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: normalizePlayletSeoValue(item.SeoInfo), + } +} + +type playletHistoryRow struct { + ChapterID int64 `bson:"chapterId"` + Episode int64 `bson:"episode"` + UpdatedAt time.Time `bson:"updatedAt"` + Playlet playletmod.Playlet `bson:"playlet"` +} + +// ReportPlayletHistory 校验上架短剧和剧集后写入当前用户观看历史 +func ReportPlayletHistory(ctx context.Context, userID int64, req *proto.PlayletHistoryReportReq) (*proto.PlayletHistoryReportResp, *srvdef.SrvErr) { + if userID <= 0 || req == nil || req.PlayletID <= 0 || req.ChapterID <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "观看历史参数无效", "") + } + playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: req.PlayletID, playletmod.FN.Playlet.Status: playletmod.StatusUp, + }) + if err != nil { + return nil, err + } + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: req.ChapterID, + playletchaptermod.FN.PlayletChapter.PlayletId: req.PlayletID, + }) + if err != nil { + return nil, err + } + if !playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode) { + return nil, srvdef.New(srvdef.ErrNoData, "本集暂未在官网开放", "") + } + if err := ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil { + return nil, err + } + if chapter.CurrentEpisode <= 0 || strings.TrimSpace(chapter.VideoUrl) == "" && strings.TrimSpace(chapter.WatermarkVideoUrl) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "剧集不可播放", "") + } + now := time.Now().UTC() + if err := models.TouchWatchHistory(ctx, models.WatchHistory{ + UserID: userID, DataType: models.WatchHistoryTypePlaylet, + ContentID: req.PlayletID, ChapterID: req.ChapterID, Episode: chapter.CurrentEpisode, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + return nil, err + } + return &proto.PlayletHistoryReportResp{WatchedAt: now}, nil +} + +// QueryPlayletHistoryList 按最近观看时间倒序分页返回当前用户的上架短剧 +func QueryPlayletHistoryList(ctx context.Context, userID int64, req *proto.PlayletHistoryListReq) (*proto.PlayletHistoryListResp, *srvdef.SrvErr) { + if userID <= 0 || req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "观看历史参数无效", "") + } + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, 20, 100) + base := []bson.M{ + {"$match": bson.M{models.WatchHistoryUserID: userID, models.WatchHistoryDataType: models.WatchHistoryTypePlaylet, models.WatchHistoryUpdatedAt: bson.M{"$gte": time.Now().UTC().AddDate(0, 0, -models.WatchHistoryRetentionDays)}}}, + {"$lookup": bson.M{"from": playletmod.TN.Playlet, "localField": models.WatchHistoryContentID, "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 := models.AggregateWatchHistory(ctx, &countRows, append(append([]bson.M(nil), base...), bson.M{"$count": "total"})); err != nil { + return nil, err + } + total := int64(0) + if len(countRows) > 0 { + total = countRows[0].Total + } + resp := &proto.PlayletHistoryListResp{BasePageResp: response.BasePageResp{ + TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletHistoryItem, 0), + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()} + if total == 0 || pageNum > (total-1)/pageSize+1 { + return resp, nil + } + sortStage := bson.M{"$sort": bson.D{{Key: models.WatchHistoryUpdatedAt, Value: -1}, {Key: "_id", Value: -1}}} + pipeline := append([]bson.M{base[0], sortStage}, base[1:]...) + pipeline = append(pipeline, bson.M{"$skip": (pageNum - 1) * pageSize}, bson.M{"$limit": pageSize}) + rows := make([]playletHistoryRow, 0) + if err := models.AggregateWatchHistory(ctx, &rows, pipeline); err != nil { + return nil, err + } + list := make(playletmod.PlayletList, 0, len(rows)) + for i := range rows { + list = append(list, &rows[i].Playlet) + } + tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + cdn := configser.GetRequestConfig(ctx).ImgCdn + items := make([]*proto.PlayletHistoryItem, 0, len(rows)) + for _, row := range rows { + items = append(items, &proto.PlayletHistoryItem{ + PlayletData: playletToAppData(&row.Playlet, cdn, tagNames, themeNames), + ChapterID: row.ChapterID, Episode: row.Episode, WatchedAt: row.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// ClearPlayletHistory 清空当前用户的短剧观看历史 +func ClearPlayletHistory(ctx context.Context, userID int64) (*proto.PlayletHistoryClearResp, *srvdef.SrvErr) { + if userID <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "用户无效", "") + } + count, err := models.DeleteWatchHistory(ctx, userID, models.WatchHistoryTypePlaylet) + if err != nil { + return nil, err + } + return &proto.PlayletHistoryClearResp{DeletedCount: count}, nil +} + +// buildPlayletTagThemeNameMaps 批量查询当前页短剧标签和题材名称,避免列表循环内多次访问数据库 +func buildPlayletTagThemeNameMaps(ctx context.Context, list playletmod.PlayletList) (contenttagmod.ContentTagMap, map[int64]string, *srvdef.SrvErr) { + tagIds := uniquePlayletRelationIds(list, func(item *playletmod.Playlet) []int64 { + return item.TagIds + }) + themeIds := uniquePlayletRelationIds(list, func(item *playletmod.Playlet) []int64 { + return item.ThemeIds + }) + tagNameMap, err := buildPlayletTagNameMap(ctx, tagIds) + if err != nil { + return nil, nil, err + } + themeNameMap, err := buildPlayletThemeNameMap(ctx, themeIds) + if err != nil { + return nil, nil, err + } + return tagNameMap, themeNameMap, nil +} + +// uniquePlayletRelationIds 按短剧关联字段收集去重后的ID +func uniquePlayletRelationIds(list playletmod.PlayletList, values func(item *playletmod.Playlet) []int64) []int64 { + ids := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, item := range list { + if item == nil { + continue + } + for _, id := range values(item) { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + return ids +} + +// buildPlayletTagNameMap 批量查询当前页短剧标签名称 +func buildPlayletTagNameMap(ctx context.Context, tagIds []int64) (contenttagmod.ContentTagMap, *srvdef.SrvErr) { + result := make(contenttagmod.ContentTagMap, len(tagIds)) + if len(tagIds) == 0 { + return result, nil + } + return contenttagmod.GetMapTags(ctx, tagIds) +} + +// buildPlayletThemeNameMap 批量查询当前页短剧题材名称 +func buildPlayletThemeNameMap(ctx context.Context, themeIds []int64) (map[int64]string, *srvdef.SrvErr) { + result := make(map[int64]string, len(themeIds)) + if len(themeIds) == 0 { + return result, nil + } + 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 { + return nil, err + } + for _, theme := range themes { + if theme != nil { + result[theme.Id] = theme.Name + } + } + return result, nil +} + +// playletTagNamesFromMap 按短剧标签ID原始顺序组装标签名称 +func playletTagNamesFromMap(tagIds []int64, tagNameMap contenttagmod.ContentTagMap) []string { + result := make([]string, 0, len(tagIds)) + for _, tagId := range tagIds { + if tag := tagNameMap[tagId]; tag != nil && tag.Name != "" { + result = append(result, tag.Name) + } + } + return result +} + +// playletTagSlugsFromMap 按标签名称的顺序组装slug +func playletTagSlugsFromMap(tagIds []int64, tagNameMap contenttagmod.ContentTagMap) []string { + result := make([]string, 0, len(tagIds)) + for _, tagId := range tagIds { + if tag := tagNameMap[tagId]; tag != nil && tag.Name != "" { + result = append(result, tag.Slug) + } + } + return result +} + +// playletThemeNamesFromMap 按短剧题材ID原始顺序组装题材名称 +func playletThemeNamesFromMap(themeIds []int64, themeNameMap map[int64]string) []string { + result := make([]string, 0, len(themeIds)) + for _, themeId := range themeIds { + if name := themeNameMap[themeId]; name != "" { + result = append(result, name) + } + } + return result +} + +// isNewPlaylet 判断短剧是否为本周内上架 +func isNewPlaylet(onlineAt time.Time) bool { + if onlineAt.IsZero() { + return false + } + return onlineAt.After(utils.BeginOfWeekByTime(utils.Now())) +} + +// normalizePlayletSeoValue 规范化 SEO 字段,避免 keywords 为 null +func normalizePlayletSeoValue(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 == "" { + continue + } + words = append(words, word) + } + seo.Keyworld = words + return seo +} + +// playletRequestPath 从上下文中提取当前请求路径,避免API层重复组装短剧响应字段 +func playletRequestPath(ctx context.Context) string { + if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx.Request != nil && ginCtx.Request.URL != nil { + return ginCtx.Request.URL.Path + } + return "" +} + +// emptyPlayletRecommendListPage 构造 app 推荐短剧空分页响应 +func emptyPlayletRecommendListPage(pageNum, pageSize int64) *proto.PlayletRecommendListResp { + return &proto.PlayletRecommendListResp{BasePageResp: response.BasePageResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletRecommendListItem, 0), + }} +} + +const ( + playletHomeBannerLimit int64 = 5 + playletHomeModuleTopicLimit int64 = 4 + playletHomeTopicLimit int64 = 3 + playletHomeTopicItems int64 = 3 +) + +// buildHomePlayletResponse 查询并组装短剧首页数据 +func buildHomePlayletResponse(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { + cfg := configser.GetRequestConfig(ctx) + // 广告和 SEO 使用当前请求的站点配置 + seo := pubdef.SeoValue{Title: cfg.WebTitle, Keyworld: cfg.WebKeyworld, Description: cfg.WebDescription} + resp := &proto.HomePlayletResponse{ + HomeBannerData: make([]*proto.HomeBannerData, 0), + Modules: make([]*proto.HomeModuleData, 0), + Announcements: make([]*proto.AnnouncementItem, 0), + Advitise: configser.GetAdvitise(), + SeoInfo: &seo, + } + // Banner 只取已上架且开启首页展示的短剧,优先展示置顶权重高的内容 + banners, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + playletmod.FN.Playlet.ShowOnBanner: true, + }, options.Find().SetSort(bson.D{ + {Key: playletmod.FN.Playlet.TopRank, Value: -1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }).SetLimit(playletHomeBannerLimit)) + if err != nil { + return nil, err + } + tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, banners) + if err != nil { + return nil, err + } + for _, item := range banners { + if item == nil { + continue + } + resp.HomeBannerData = append(resp.HomeBannerData, &proto.HomeBannerData{ + PlayletData: playletToAppData(item, cfg.ImgCdn, tagNames, themeNames), + ShowOnBanner: item.ShowOnBanner, + Banner: netutil.MergeURL(cfg.ImgCdn, item.Banner), + }) + } + // 首页模块仅使用已启用的配置,排序值越大越靠前 + configs, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, + bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true}, + options.Find().SetSort(bson.D{ + {Key: playlethomeconfigmod.FN.PlayletHomeConfig.Rank, Value: -1}, + {Key: playlethomeconfigmod.FN.PlayletHomeConfig.Id, Value: -1}, + })) + if err != nil { + return nil, err + } + for _, item := range configs { + if item == nil { + continue + } + module, err := homeModule(ctx, item, cfg.ImgCdn) + if err != nil { + return nil, err + } + resp.Modules = append(resp.Modules, module) + } + // 公告只返回启用项,先按排序值再按修改时间倒序排列 + announcements, err := announcementmod.FindManyAnnouncement(ctx, + bson.M{announcementmod.FN.Announcement.Status: true}, + options.Find().SetSort(bson.D{ + {Key: announcementmod.FN.Announcement.Sort, Value: -1}, + {Key: announcementmod.FN.Announcement.UpdatedAt, Value: -1}, + })) + if err != nil { + return nil, err + } + for _, item := range announcements { + if item != nil { + resp.Announcements = append(resp.Announcements, &proto.AnnouncementItem{Id: item.Id, Title: item.Title, Content: item.Content}) + } + } + return resp, nil +} + +// homeModule 按配置的数据类型和模块类型组装首页模块 +func homeModule(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string) (*proto.HomeModuleData, *srvdef.SrvErr) { + // 未设置条数时使用默认值,并限制最大查询量 + limit := config.Limit + if limit <= 0 { + limit = playlethomeconfigmod.DefaultLimit + } + if limit > playlethomeconfigmod.MaxLimit { + limit = playlethomeconfigmod.MaxLimit + } + module := &proto.HomeModuleData{ + Id: config.Id, + Slug: config.Slug, + Title: config.Title, + Desc: config.Desc, + Label: config.Label, + Style: config.Style, + CardBackgroundImage: netutil.MergeURL(imgCdn, config.CardBackgroundImage), + ModuleType: config.ModuleType, + DataType: config.DataType, + RecommendType: config.RecommendType, + CategoryId: config.CategoryId, + Rank: config.Rank, + List: make([]*proto.PlayletData, 0), + TopicList: make([]*proto.HomeTopicData, 0), + } + // 专题模块按数据类型分别使用视频分类或短剧题材 + if config.DataType == playlethomeconfigmod.DataTypeVideo { + if config.ModuleType == playlethomeconfigmod.ModuleTypeTopic { + topics, err := homeVideoTopics(ctx, config, imgCdn) + module.TopicList = topics + return module, err + } + list, err := homeVideos(ctx, config, imgCdn, limit, 0, 0) + module.List = list + return module, err + } + if config.ModuleType == playlethomeconfigmod.ModuleTypeTopic { + topics, err := homeTopics(ctx, config, imgCdn) + module.TopicList = topics + return module, err + } + list, err := homePlaylets(ctx, config, 0, limit) + if err != nil { + return nil, err + } + module.List, err = homePlayletData(ctx, list, imgCdn) + return module, err +} + +// homePlaylets 查询已上架短剧,按需叠加分类和专题筛选 +func homePlaylets(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, themeId, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) { + filter, ok := homePlayletFilter(config, themeId) + if !ok { + return make(playletmod.PlayletList, 0), nil + } + if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { + return playletmod.FindManyPlayletByCategoryRank(ctx, filter, config.CategoryId, 0, limit, playletmod.CategoryRankSort(), nil) + } + return findPlayletRecommendPage(ctx, filter, homePlayletSort(config.RecommendType), 0, limit) +} + +// homePlayletSort 将推荐类型映射到短剧排序字段,ID 用于稳定同分排序 +func homePlayletSort(recommendType int64) bson.D { + var primary string + switch recommendType { + case playlethomeconfigmod.RecommendTypeUpdated: + primary = playletmod.FN.Playlet.UpdatedAt + case playlethomeconfigmod.RecommendTypeLike: + primary = playletmod.FN.Playlet.CountLike + case playlethomeconfigmod.RecommendTypeHeat: + primary = playletmod.FN.Playlet.HeatValue + case playlethomeconfigmod.RecommendTypeCategory: + primary = playletmod.FN.Playlet.RecommendRank + default: + primary = playletmod.FN.Playlet.CountBrowse + } + return bson.D{{Key: primary, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} +} + +// homePlayletData 补齐标签和专题名称并转换为 App 短剧结构 +func homePlayletData(ctx context.Context, list playletmod.PlayletList, imgCdn string) ([]*proto.PlayletData, *srvdef.SrvErr) { + result := make([]*proto.PlayletData, 0, len(list)) + tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) + if err != nil { + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + data := playletToAppData(item, imgCdn, tagNames, themeNames) + result = append(result, &data) + } + return result, nil +} + +// homeVideos 查询审核通过的视频,推荐类型决定排序或分类筛选 +func homeVideos(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string, limit, skip, categoryId int64) ([]*proto.HomeVideoData, *srvdef.SrvErr) { + result := make([]*proto.HomeVideoData, 0) + filter, ok := homeVideoFilter(config, categoryId) + if !ok { + return result, nil + } + sortKey := videoinformationmod.FN.VideoInformation.Real.WatchTimes + switch config.RecommendType { + case playlethomeconfigmod.RecommendTypeUpdated: + sortKey = videoinformationmod.FN.VideoInformation.UpdatedAt + case playlethomeconfigmod.RecommendTypeLike: + sortKey = videoinformationmod.FN.VideoInformation.Real.Likes + case playlethomeconfigmod.RecommendTypeHeat: + sortKey = videoinformationmod.FN.VideoInformation.HeatValue + case playlethomeconfigmod.RecommendTypeCategory: + sortKey = videoinformationmod.FN.VideoInformation.Sort + } + sort := bson.D{{Key: sortKey, Value: -1}} + if config.RecommendType == playlethomeconfigmod.RecommendTypeHeat { + sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Sort, Value: -1}) + } + sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}) + projection := bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Title, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Desc, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Likes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.Likes, Value: 1}, + } + var list videoinformationmod.VideoInformationList + var err *srvdef.SrvErr + if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { + selectedCategoryId := config.CategoryId + if categoryId > 0 { + selectedCategoryId = categoryId + } + project := bson.M{} + for _, field := range projection { + project[field.Key] = field.Value + } + list, err = videoinformationmod.FindManyVideoByCategoryRank(ctx, filter, selectedCategoryId, skip, limit, project) + } else { + list, err = videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().SetProjection(projection).SetSort(sort).SetSkip(skip).SetLimit(limit)) + } + if err != nil { + return nil, err + } + // 展示数值为真实统计与后台设置的虚拟统计之和 + for _, item := range list { + if item == nil { + continue + } + result = append(result, &proto.HomeVideoData{ + Id: item.Id, Title: item.Title, Slug: item.Slug, + Cover: netutil.MergeURL(imgCdn, item.CoverImg), Desc: item.Desc, + Tags: item.Tags, WatchNum: item.Real.WatchTimes + item.Fake.WatchTimes, + LikeNum: item.Real.Likes + item.Fake.Likes, + }) + } + return result, nil +} + +// homeVideoTopics 从配置的视频分类中按视频数量选取前四个有内容的专题 +func homeVideoTopics(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string) ([]*proto.HomeTopicData, *srvdef.SrvErr) { + topics, _, err := homeTopicPage(ctx, config, imgCdn, 0, playletHomeModuleTopicLimit, 0, true, true) + return topics, err +} + +// homeTopics 从配置的题材中按上架短剧数量选取前四个专题,每个专题最多取三部短剧 +func homeTopics(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string) ([]*proto.HomeTopicData, *srvdef.SrvErr) { + topics, _, err := homeTopicPage(ctx, config, imgCdn, 0, playletHomeModuleTopicLimit, 0, true, true) + return topics, err +} + +const ( + homeConfigPageSize int64 = 20 + homeTopicPageSize int64 = 12 + homePageMaxSize int64 = 100 + homePlayletCacheTTL = 5 * time.Minute +) + +// HomeConfigList 按首页配置分页查询短剧、视频或专题 +func HomeConfigList(ctx context.Context, req *proto.PlayletHomeConfigListReq) (*proto.PlayletHomeConfigListResp, *srvdef.SrvErr) { + if req == nil || (req.Id <= 0 && strings.TrimSpace(req.Slug) == "") { + return nil, srvdef.New(srvdef.ErrParamError, "首页配置ID或slug不能为空", "") + } + filter := bson.M{} + if slug := strings.TrimSpace(req.Slug); slug != "" { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Slug] = slug + } else { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Id] = req.Id + } + config, err := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, filter) + if err != nil { + return nil, err + } + pageNum, pageSize := normalizeHomePage(req.PageNum, req.PageSize, homeConfigPageSize) + resp := &proto.PlayletHomeConfigListResp{ + Config: &proto.HomeModuleData{ + Id: config.Id, Slug: config.Slug, Title: config.Title, Desc: config.Desc, Label: config.Label, + Style: config.Style, CardBackgroundImage: netutil.MergeURL(configser.GetRequestConfig(ctx).ImgCdn, config.CardBackgroundImage), + ModuleType: config.ModuleType, DataType: config.DataType, RecommendType: config.RecommendType, + CategoryId: config.CategoryId, Rank: config.Rank, + List: make([]*proto.PlayletData, 0), TopicList: make([]*proto.HomeTopicData, 0), + }, + PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletData, 0), + TopicList: make([]*proto.HomeTopicData, 0), Advitise: configser.GetAdvitise(), + } + if !config.Enabled { + return resp, nil + } + skip := (pageNum - 1) * pageSize + imgCdn := configser.GetRequestConfig(ctx).ImgCdn + if config.ModuleType == playlethomeconfigmod.ModuleTypeTopic { + resp.TopicList, resp.TotalCount, err = homeTopicPage(ctx, config, imgCdn, skip, pageSize, 0, true, true) + return resp, err + } + if config.DataType == playlethomeconfigmod.DataTypeVideo { + filter, ok := homeVideoFilter(config, 0) + if !ok { + resp.List = make([]*proto.HomeVideoData, 0) + return resp, nil + } + resp.TotalCount, err = videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + resp.List, err = homeVideos(ctx, config, imgCdn, pageSize, skip, 0) + return resp, err + } + filter, ok := homePlayletFilter(config, 0) + if !ok { + return resp, nil + } + resp.TotalCount, err = playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + var list playletmod.PlayletList + if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { + list, err = playletmod.FindManyPlayletByCategoryRank(ctx, filter, config.CategoryId, skip, pageSize, playletmod.CategoryRankSort(), nil) + } else { + list, err = findPlayletRecommendPage(ctx, filter, homePlayletSort(config.RecommendType), skip, pageSize) + } + if err != nil { + return nil, err + } + resp.List, err = homePlayletData(ctx, list, imgCdn) + return resp, err +} + +// TopicDetail 根据数据类型查询短剧题材或视频分类专题详情 +func TopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) { + if req == nil || (req.Id <= 0 && strings.TrimSpace(req.TopicSlug) == "") { + return nil, srvdef.New(srvdef.ErrParamError, "专题ID或slug不能为空", "") + } + dataType := req.DataType + if dataType == 0 { + dataType = playlethomeconfigmod.DataTypeAll + } + if dataType != playlethomeconfigmod.DataTypeAll && dataType != playlethomeconfigmod.DataTypeVideo { + return nil, srvdef.New(srvdef.ErrParamError, "数据类型无效", "") + } + pageNum, pageSize := normalizeHomePage(req.PageNum, req.PageSize, homeTopicPageSize) + imgCdn := configser.GetRequestConfig(ctx).ImgCdn + resp := &proto.PlayletTopicDetailResp{ + DataType: dataType, PageNum: int(pageNum), PageSize: int(pageSize), + Covers: make([]string, 0), List: make([]*proto.PlayletData, 0), + MoreTopics: make([]*proto.HomeTopicData, 0), Advitise: configser.GetAdvitise(), + } + skip := (pageNum - 1) * pageSize + var err *srvdef.SrvErr + if dataType == playlethomeconfigmod.DataTypeVideo { + err = fillVideoTopicDetail(ctx, req, resp, imgCdn, skip, pageSize) + } else { + err = fillPlayletTopicDetail(ctx, req, resp, imgCdn, skip, pageSize) + } + if err != nil { + return nil, err + } + config := &playlethomeconfigmod.PlayletHomeConfig{DataType: dataType, RecommendType: playlethomeconfigmod.RecommendTypeHeat} + resp.MoreTopics, _, err = homeTopicPage(ctx, config, imgCdn, 0, playletHomeTopicLimit, resp.Id, true, false) + return resp, err +} + +func fillPlayletTopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq, resp *proto.PlayletTopicDetailResp, imgCdn string, skip, pageSize int64) *srvdef.SrvErr { + filter := bson.M{playletthememod.FN.PlayletTheme.Status: true} + if slug := strings.TrimSpace(req.TopicSlug); slug != "" { + filter[playletthememod.FN.PlayletTheme.Slug] = slug + } else { + filter[playletthememod.FN.PlayletTheme.Id] = req.Id + } + theme, err := playletthememod.FindOnePlayletTheme(ctx, filter) + if err != nil { + return err + } + resp.Id, resp.Name, resp.Slug, resp.Rank = theme.Id, theme.Name, theme.Slug, theme.Rank + resp.Desc, resp.Icon = theme.SeoInfo.Description, netutil.MergeURL(imgCdn, theme.Icon) + seo := theme.SeoInfo + resp.SeoInfo = &seo + playletFilter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp, playletmod.FN.Playlet.ThemeIds: theme.Id} + resp.TotalCount, err = playletmod.CountPlaylet(ctx, playletFilter) + if err != nil { + return err + } + resp.PlayletCount = resp.TotalCount + sortOrder := homeTopicDetailPlayletSort(req.SortType) + covers, err := findPlayletRecommendPage(ctx, playletFilter, homeTopicDetailPlayletSort(1), 0, playletHomeTopicItems) + if err != nil { + return err + } + for _, item := range covers { + if item != nil && item.Cover != "" { + resp.Covers = append(resp.Covers, netutil.MergeURL(imgCdn, item.Cover)) + } + } + list, err := findPlayletRecommendPage(ctx, playletFilter, sortOrder, skip, pageSize) + if err != nil { + return err + } + resp.List, err = homePlayletData(ctx, list, imgCdn) + return err +} + +func fillVideoTopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq, resp *proto.PlayletTopicDetailResp, imgCdn string, skip, pageSize int64) *srvdef.SrvErr { + filter := bson.M{videocategorymod.FN.VideoCategory.Status: true} + if slug := strings.TrimSpace(req.TopicSlug); slug != "" { + filter[videocategorymod.FN.VideoCategory.Slug] = slug + } else { + filter[videocategorymod.FN.VideoCategory.Id] = req.Id + } + category, err := videocategorymod.FindOneVideoCategory(ctx, filter) + if err != nil { + return err + } + resp.Id, resp.Name, resp.Slug, resp.Rank = category.Id, category.Name, category.Slug, category.Rank + resp.Desc = category.Desc + seo := category.SeoInfo + resp.SeoInfo = &seo + videoFilter := bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + videoinformationmod.FN.VideoInformation.CategoryId: category.Id} + resp.TotalCount, err = videoinformationmod.CountVideoInformations(ctx, videoFilter) + if err != nil { + return err + } + resp.VideoCount = resp.TotalCount + config := &playlethomeconfigmod.PlayletHomeConfig{RecommendType: homeTopicDetailVideoRecommendType(req.SortType)} + covers, err := homeVideos(ctx, &playlethomeconfigmod.PlayletHomeConfig{RecommendType: playlethomeconfigmod.RecommendTypeHeat}, imgCdn, playletHomeTopicItems, 0, category.Id) + if err != nil { + return err + } + for _, item := range covers { + if item != nil && item.Cover != "" { + resp.Covers = append(resp.Covers, item.Cover) + } + } + resp.List, err = homeVideos(ctx, config, imgCdn, pageSize, skip, category.Id) + return err +} + +func homeTopicDetailPlayletSort(sortType int64) bson.D { + switch sortType { + case 2: + return bson.D{{Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} + case 3: + return bson.D{{Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} + default: + return bson.D{{Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} + } +} + +func homeTopicDetailVideoRecommendType(sortType int64) int64 { + switch sortType { + case 2: + return playlethomeconfigmod.RecommendTypeUpdated + case 3: + return playlethomeconfigmod.RecommendTypeBrowse + default: + return playlethomeconfigmod.RecommendTypeCategory + } +} + +func normalizeHomePage(pageNum, pageSize, defaultSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultSize + } + if pageSize > homePageMaxSize { + pageSize = homePageMaxSize + } + return pageNum, pageSize +} + +func homePlayletFilter(config *playlethomeconfigmod.PlayletHomeConfig, themeId int64) (bson.M, bool) { + filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} + if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { + if config.CategoryId <= 0 { + return nil, false + } + filter[playletmod.FN.Playlet.CategoryIds] = config.CategoryId + } + if themeId > 0 { + filter[playletmod.FN.Playlet.ThemeIds] = themeId + } + return filter, true +} + +func homeVideoFilter(config *playlethomeconfigmod.PlayletHomeConfig, categoryId int64) (bson.M, bool) { + filter := bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK} + if categoryId > 0 { + filter[videoinformationmod.FN.VideoInformation.CategoryId] = categoryId + } else if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { + if config.CategoryId <= 0 { + return nil, false + } + filter[videoinformationmod.FN.VideoInformation.CategoryId] = config.CategoryId + } + return filter, true +} + +// homeTopicPage 按专题排序分页,可分别限定有上架内容和首页配置关联的专题 +func homeTopicPage(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string, skip, limit, excludeId int64, onlyWithContent, onlyConfigured bool) ([]*proto.HomeTopicData, int64, *srvdef.SrvErr) { + type topicStat struct { + id, rank, count int64 + name, slug, icon string + seo pubdef.SeoValue + } + stats := make([]topicStat, 0) + selectedTopics := make(map[int64]struct{}, len(config.TopicIds)) + if onlyConfigured { + for _, id := range config.TopicIds { + selectedTopics[id] = struct{}{} + } + if len(selectedTopics) == 0 { + return make([]*proto.HomeTopicData, 0), 0, nil + } + } + if config.DataType == playlethomeconfigmod.DataTypeVideo { + filter := bson.M{videocategorymod.FN.VideoCategory.Status: true} + if onlyConfigured { + filter[videocategorymod.FN.VideoCategory.Id] = bson.M{"$in": config.TopicIds} + } + if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { + if config.CategoryId <= 0 { + return make([]*proto.HomeTopicData, 0), 0, nil + } + filter[videocategorymod.FN.VideoCategory.Id] = config.CategoryId + } + categories, err := videocategorymod.FindManyVideoCategorys(ctx, filter) + if err != nil { + return nil, 0, err + } + for _, category := range categories { + if category == nil || category.Id == excludeId { + continue + } + if onlyConfigured { + if _, ok := selectedTopics[category.Id]; !ok { + continue + } + } + count, err := videoinformationmod.CountVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + videoinformationmod.FN.VideoInformation.CategoryId: category.Id, + }) + if err != nil { + return nil, 0, err + } + if onlyWithContent && count == 0 { + continue + } + stats = append(stats, topicStat{id: category.Id, rank: category.Rank, count: count, + name: category.Name, slug: category.Slug, seo: category.SeoInfo}) + } + } else { + filter := bson.M{playletthememod.FN.PlayletTheme.Status: true} + if onlyConfigured { + filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$in": config.TopicIds} + } + themes, err := playletthememod.FindManyPlayletTheme(ctx, filter) + if err != nil { + return nil, 0, err + } + for _, theme := range themes { + if theme == nil || theme.Id == excludeId { + continue + } + if onlyConfigured { + if _, ok := selectedTopics[theme.Id]; !ok { + continue + } + } + filter, ok := homePlayletFilter(config, theme.Id) + if !ok { + continue + } + count, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, 0, err + } + if onlyWithContent && count == 0 { + continue + } + stats = append(stats, topicStat{id: theme.Id, rank: theme.Rank, count: count, + name: theme.Name, slug: theme.Slug, icon: theme.Icon, seo: theme.SeoInfo}) + } + } + sort.Slice(stats, func(i, j int) bool { + if stats[i].count != stats[j].count { + return stats[i].count > stats[j].count + } + if stats[i].rank != stats[j].rank { + return stats[i].rank > stats[j].rank + } + return stats[i].id > stats[j].id + }) + total := int64(len(stats)) + result := make([]*proto.HomeTopicData, 0) + if skip >= total || limit <= 0 { + return result, total, nil + } + end := skip + limit + if end > total { + end = total + } + if onlyWithContent { + end = total // 被排除的内容可能让专题变空,继续查后续专题补足展示数量 + } + for _, stat := range stats[int(skip):int(end)] { + if onlyWithContent && int64(len(result)) >= limit { + break + } + item := &proto.HomeTopicData{Id: stat.id, Name: stat.name, Slug: stat.slug, + Icon: netutil.MergeURL(imgCdn, stat.icon), Rank: stat.rank, SeoInfo: stat.seo} + if config.DataType == playlethomeconfigmod.DataTypeVideo { + item.VideoCount = stat.count + list, err := homeVideos(ctx, config, imgCdn, playletHomeTopicItems, 0, stat.id) + if err != nil { + return nil, 0, err + } + item.List = list + if onlyWithContent && len(list) == 0 { + continue + } + } else { + item.PlayletCount = stat.count + list, err := homePlaylets(ctx, config, stat.id, playletHomeTopicItems) + if err != nil { + return nil, 0, err + } + if onlyWithContent && len(list) == 0 { + continue + } + item.List, err = homePlayletData(ctx, list, imgCdn) + if err != nil { + return nil, 0, err + } + } + result = append(result, item) + } + return result, total, nil +} + +// Home 优先返回 Redis 缓存,未命中或缓存不可用时查询数据库 +func Home(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { + key := homePlayletCacheKey(configser.GetRequestConfig(ctx).ImgCdn) + if resp := getHomePlayletCache(key); resp != nil { + return resp, nil + } + resp, err := buildHomePlayletResponse(ctx) + if err != nil { + return nil, err + } + setHomePlayletCache(key, resp) + return resp, nil +} + +// homePlayletCacheKey 包含缓存版本和图片域名,后台变更或域名变更后使用新键 +func homePlayletCacheKey(imgCdn string) string { + if redis.Client() == nil { + return "" + } + version := "0" + value, err := redis.Get(redisconst.AppPlayletHomeCacheVersionKey()) + if err != nil { + log.Warn("读取短剧首页缓存版本失败", log.E(err)) + return "" + } + if value != nil && strings.TrimSpace(*value) != "" { + version = strings.TrimSpace(*value) + } + cdnHash := sha256.Sum256([]byte(imgCdn)) + return fmt.Sprintf("app:playlet:home:data:%d:v7:%s:%x", pubdef.AppId, version, cdnHash[:8]) +} + +// getHomePlayletCache 读取并反序列化完整首页响应 +func getHomePlayletCache(key string) *proto.HomePlayletResponse { + if key == "" { + return nil + } + value, err := redis.Get(key) + if err != nil { + log.Warn("读取短剧首页缓存失败", log.E(err)) + return nil + } + if value == nil || *value == "" { + return nil + } + var resp proto.HomePlayletResponse + if err := json.Unmarshal([]byte(*value), &resp); err != nil { + log.Warn("解析短剧首页缓存失败", log.E(err)) + return nil + } + return &resp +} + +// setHomePlayletCache 缓存完整首页响应五分钟 +func setHomePlayletCache(key string, resp *proto.HomePlayletResponse) { + if key == "" || resp == nil { + return + } + data, err := json.Marshal(resp) + if err != nil { + log.Warn("序列化短剧首页缓存失败", log.E(err)) + return + } + if err := redis.Set(key, string(data), homePlayletCacheTTL); err != nil { + log.Warn("写入短剧首页缓存失败", log.E(err)) + } +} diff --git a/app/service/playletser/playlet_content_list_test.go b/app/service/playletser/playlet_content_list_test.go new file mode 100644 index 0000000..6add609 --- /dev/null +++ b/app/service/playletser/playlet_content_list_test.go @@ -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) + } + } +} diff --git a/app/service/playletser/playlet_list_test.go b/app/service/playletser/playlet_list_test.go new file mode 100644 index 0000000..fc93bf7 --- /dev/null +++ b/app/service/playletser/playlet_list_test.go @@ -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) + } + } +} diff --git a/app/service/questionnaireser/questionnaire.go b/app/service/questionnaireser/questionnaire.go new file mode 100644 index 0000000..9624e3f --- /dev/null +++ b/app/service/questionnaireser/questionnaire.go @@ -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) +} diff --git a/app/service/reportser/report.go b/app/service/reportser/report.go new file mode 100644 index 0000000..51df48c --- /dev/null +++ b/app/service/reportser/report.go @@ -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" +} diff --git a/app/service/richtextser/cache.go b/app/service/richtextser/cache.go new file mode 100644 index 0000000..6f7db25 --- /dev/null +++ b/app/service/richtextser/cache.go @@ -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 +} diff --git a/app/service/searchser/cache.go b/app/service/searchser/cache.go new file mode 100644 index 0000000..54cfe0e --- /dev/null +++ b/app/service/searchser/cache.go @@ -0,0 +1 @@ +package searchser \ No newline at end of file diff --git a/app/service/searchser/search.go b/app/service/searchser/search.go new file mode 100644 index 0000000..756b999 --- /dev/null +++ b/app/service/searchser/search.go @@ -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 +} diff --git a/app/service/slugser/resolve.go b/app/service/slugser/resolve.go new file mode 100644 index 0000000..7ee7ac1 --- /dev/null +++ b/app/service/slugser/resolve.go @@ -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 + }) +} diff --git a/app/service/translationser/catch.go b/app/service/translationser/catch.go new file mode 100644 index 0000000..2fb7060 --- /dev/null +++ b/app/service/translationser/catch.go @@ -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 +} diff --git a/app/service/uploadser/upload.go b/app/service/uploadser/upload.go new file mode 100644 index 0000000..9951409 --- /dev/null +++ b/app/service/uploadser/upload.go @@ -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) +} diff --git a/app/service/videoser/h5_m3u8_ticket.go b/app/service/videoser/h5_m3u8_ticket.go new file mode 100644 index 0000000..28288cf --- /dev/null +++ b/app/service/videoser/h5_m3u8_ticket.go @@ -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[:]) +} diff --git a/app/service/videoser/like_list.go b/app/service/videoser/like_list.go new file mode 100644 index 0000000..bf47902 --- /dev/null +++ b/app/service/videoser/like_list.go @@ -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 +} diff --git a/app/service/videoser/m3u8.go b/app/service/videoser/m3u8.go new file mode 100644 index 0000000..1b67943 --- /dev/null +++ b/app/service/videoser/m3u8.go @@ -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 +} diff --git a/app/service/videoser/video.go b/app/service/videoser/video.go new file mode 100644 index 0000000..a7db6b6 --- /dev/null +++ b/app/service/videoser/video.go @@ -0,0 +1,1686 @@ +package videoser + +import ( + "context" + "encoding/json" + "strconv" + "sync" + "time" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/moreser" + "videoweb/app/service/reportser" + "videoweb/app/service/slugser" + "videoweb/app/service/translationser" + "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/common/utils" + "videoweb/pub/models" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videofavoritemod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videolikemod" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + "videoweb/pub/storage/proxy" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/gin-gonic/gin" +) + +const ( + videoGuessMinPageSize int64 = 8 + videoGuessMaxPageSize int64 = 100 + homeVideoDataLimit int64 = 12 + queryVideoDefaultPageSize int64 = 20 + queryVideoMaxPageSize int64 = 100 + queryVideoAdInterval int64 = 9 + videoRecommendAdInterval int64 = 8 + videoListAdInterval int64 = 5 +) + +// VideoCategoryList 查询全部已启用视频分类 +func VideoCategoryList(ctx context.Context) (*proto.VideoCategoryListResp, *srvdef.SrvErr) { + list, err := videocategorymod.FindManyVideoCategorys(ctx, + bson.M{videocategorymod.FN.VideoCategory.Status: true}, + options.Find(). + SetProjection(bson.D{ + {Key: videocategorymod.FN.VideoCategory.Id, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.Name, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.Slug, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.Title, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.Desc, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.Rank, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.SeoInfo.Title, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.SeoInfo.Description, Value: 1}, + {Key: videocategorymod.FN.VideoCategory.SeoInfo.Keyworld, Value: 1}, + }). + SetSort(bson.D{ + {Key: videocategorymod.FN.VideoCategory.Rank, Value: -1}, + {Key: videocategorymod.FN.VideoCategory.Id, Value: -1}, + }), + ) + if err != nil { + return nil, err + } + resp := &proto.VideoCategoryListResp{List: make([]proto.VideoCategoryListItem, 0, len(list))} + for _, item := range list { + if item == nil { + continue + } + resp.List = append(resp.List, proto.VideoCategoryListItem{ + Id: item.Id, Name: item.Name, Slug: item.Slug, + Title: item.Title, Desc: item.Desc, Rank: item.Rank, SeoInfo: item.SeoInfo, + }) + } + return resp, nil +} + +// VideoGuessList 分页查询猜你喜欢视频,按真实与后台配置观看量之和排序 +func VideoGuessList(ctx context.Context, req *proto.VideoGuessListReq) (*proto.VideoGuessListResp, *srvdef.SrvErr) { + if req == nil || req.Id < 0 { + return nil, srvdef.New(srvdef.ErrParamError, "视频ID无效") + } + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, videoGuessMinPageSize, videoGuessMaxPageSize) + if pageSize < videoGuessMinPageSize { + pageSize = videoGuessMinPageSize + } + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + } + if req.Id > 0 { + filter[videoinformationmod.FN.VideoInformation.Id] = bson.M{"$ne": req.Id} + } + total, err := videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.VideoGuessListResp{BasePageResp: response.BasePageResp{ + TotalCount: total, + PageNum: int(pageNum), + PageSize: int(pageSize), + List: make([]*proto.VideoRecommendListItem, 0), + }} + if total == 0 || pageNum > (total-1)/pageSize+1 { + return resp, nil + } + list := make(videoinformationmod.VideoInformationList, 0) + if err := videoinformationmod.AggregateVideoInformations(ctx, &list, videoGuessPipeline(filter, (pageNum-1)*pageSize, pageSize)); err != nil { + return nil, err + } + cdn := configser.ImageCdnFromCtx(ctx) + items := make([]*proto.VideoRecommendListItem, 0, len(list)) + for _, item := range list { + if item != nil { + items = append(items, videoToRecommendListItem(item, cdn)) + } + } + resp.List = items + return resp, nil +} + +// videoGuessPipeline 按视频展示观看量排序,并使用ID稳定同分顺序 +func videoGuessPipeline(filter bson.M, skip, limit int64) []bson.M { + return []bson.M{ + {"$match": filter}, + {"$addFields": bson.M{"watchScore": bson.M{"$add": []interface{}{ + bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Real.WatchTimes, 0}}, + bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Fake.WatchTimes, 0}}, + }}}}, + {"$sort": bson.D{{Key: "watchScore", Value: -1}, {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}}}, + {"$skip": skip}, + {"$limit": limit}, + {"$project": queryVideoProjection()}, + } +} + +// videoRankAggItem 视频排行榜聚合结果,只保留榜单展示需要的轻量字段 +type videoRankAggItem struct { + Id int64 `bson:"_id"` + Title string `bson:"title"` + Slug string `bson:"slug"` + AddedTime time.Time `bson:"addedTime"` + UpdatedAt time.Time `bson:"updatedAt"` + CoverImg string `bson:"coverImg"` + Tags []int64 `bson:"tags"` + Real pubdef.Flow `bson:"real"` + Fake pubdef.FakeFlow `bson:"fake"` + RankScore int64 `bson:"rankScore"` +} + +// VideoList 分页查询已上架视频,每五条视频后穿插一条广告 +func VideoList(ctx context.Context, req *proto.VideoListReq) (*proto.VideoListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + if req.CategoryId < 0 || (req.SortType != 0 && req.SortType != 1 && req.SortType != 2) { + return nil, srvdef.New(srvdef.ErrParamError, "分类或排序方式无效", "") + } + filter := videoListFilter(req.CategoryId) + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, queryVideoDefaultPageSize, queryVideoMaxPageSize) + videoCount, err := videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + advitise := configser.GetAdvitise() + ads := advitise.PostList + adCount := videoListInsertedAdsCount(videoCount, len(ads)) + mixedCount := videoCount + adCount + resp := &proto.VideoListResp{ + BasePageResp: response.BasePageResp{ + TotalCount: mixedCount, PageNum: int(pageNum), PageSize: int(pageSize), + List: make([]*proto.VideoRecommendListItem, 0), + }, + Advitise: advitise, + } + start := (pageNum - 1) * pageSize + if start >= mixedCount { + return resp, nil + } + end := start + pageSize + if end > mixedCount { + end = mixedCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, videoCount, adCount, videoListAdInterval) + list := make(videoinformationmod.VideoInformationList, 0) + if contentLimit > 0 { + list, err = findVideoRecommendPage(ctx, filter, videoListQuerySort(req.SortType), contentStart, contentLimit) + if err != nil { + return nil, err + } + } + resp.List = buildVideoListMixedPage(list, ads, configser.GetRequestConfig(ctx).ImgCdn, + start, end, contentStart, adCount) + return resp, nil +} + +// videoListFilter 按上架状态和分类构建筛选条件 +func videoListFilter(categoryId int64) bson.M { + filter := bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK} + if categoryId > 0 { + filter[videoinformationmod.FN.VideoInformation.CategoryId] = categoryId + } + return filter +} + +// videoListSort 根据热度或最近更新时间生成稳定的视频顺序 +func videoListSort(sortType int64) bson.D { + if sortType == 2 { + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + } + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } +} + +// videoListQuerySort 将后台热度置于原观看数顺序之前 +func videoListQuerySort(sortType int64) bson.D { + sort := videoListSort(sortType) + if sortType == 2 { + return sort + } + return append(bson.D{{Key: videoinformationmod.FN.VideoInformation.HeatValue, Value: -1}}, sort...) +} + +// videoListInsertedAdsCount 只在仍有后续视频时插入广告,素材不足时循环使用 +func videoListInsertedAdsCount(videoCount int64, adsCount int) int64 { + if videoCount <= 1 || adsCount == 0 { + return 0 + } + return (videoCount - 1) / videoListAdInterval +} + +// buildVideoListMixedPage 根据全局位置组装视频与广告,保持跨页间隔一致 +func buildVideoListMixedPage(list videoinformationmod.VideoInformationList, ads []proxy.AdvitiseInfo, cdn string, + start, end, contentStart, adCount int64) []*proto.VideoRecommendListItem { + items := make([]*proto.VideoRecommendListItem, 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, adCount, videoListAdInterval) + if adIndex >= 0 && len(ads) > 0 { + items = append(items, videoAdToRecommendListItem(ads[adIndex%int64(len(ads))], cdn)) + continue + } + contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adCount, videoListAdInterval) + if item := contentByIndex[contentIndex]; item != nil { + items = append(items, videoToRecommendListItem(item, cdn)) + } + } + return items +} + +type videoRankCount struct { + Total int64 `bson:"total"` +} + +// incrementVideoWatchCount 更新视频总观看、展示和日/周/月/年窗口观看数。 +// 周/月/年窗口按北京时间切:周一 00:00、每月 1 日 00:00、每年 1 月 1 日 00:00。 +func incrementVideoWatchCount(videoInfo *videoinformationmod.VideoInformation) { + if videoInfo == nil { + return + } + now := utils.Now() + incDoc := bson.M{ + videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1, + videoinformationmod.FN.VideoInformation.Real.ShowTimes: 1, + } + setDoc := bson.M{ + videoinformationmod.FN.VideoInformation.RealUpdateAt: now, + } + if videoInfo.RealUpdateAt.After(utils.BeginOfToday()) { + incDoc[videoinformationmod.FN.VideoInformation.Real.TodayWatchs] = 1 + } else { + setDoc[videoinformationmod.FN.VideoInformation.Real.TodayWatchs] = 1 + } + if videoInfo.RealUpdateAt.After(utils.BeginOfWeekByTime(now)) { + incDoc[videoinformationmod.FN.VideoInformation.Real.WeekWatchs] = 1 + } else { + setDoc[videoinformationmod.FN.VideoInformation.Real.WeekWatchs] = 1 + } + if videoInfo.RealUpdateAt.After(utils.BeginOfMonth(now)) { + incDoc[videoinformationmod.FN.VideoInformation.Real.MonthWatchs] = 1 + } else { + setDoc[videoinformationmod.FN.VideoInformation.Real.MonthWatchs] = 1 + } + if videoInfo.RealUpdateAt.After(utils.BeginOfYear(now)) { + incDoc[videoinformationmod.FN.VideoInformation.Real.YearWatchs] = 1 + } else { + setDoc[videoinformationmod.FN.VideoInformation.Real.YearWatchs] = 1 + } + if err := videoinformationmod.UpdateOneVideoInformation(context.Background(), bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoInfo.Id, + }, bson.M{"$set": setDoc, "$inc": incDoc}); err != nil { + log.Warn("incrementVideoWatchCount", log.Any("videoId", videoInfo.Id), log.E(err)) + } +} + +// HomeVideoDataForHome 查询总首页推荐、最新、最热三组视频数据 +func HomeVideoDataForHome(ctx context.Context, imgCdn string) (proto.HomeVideoDataResp, *srvdef.SrvErr) { + resp := proto.HomeVideoDataResp{ + RecommendList: make([]proto.VideoInfo, 0), + NewestList: make([]proto.VideoInfo, 0), + HotList: make([]proto.VideoInfo, 0), + } + var ( + recommendList videoinformationmod.VideoInformationList + newestList videoinformationmod.VideoInformationList + hotList videoinformationmod.VideoInformationList + wg sync.WaitGroup + mu sync.Mutex + err1 *srvdef.SrvErr + ) + setErr := func(err *srvdef.SrvErr) { + if err == nil { + return + } + mu.Lock() + defer mu.Unlock() + if err1 == nil { + err1 = err + } + } + queryList := func(sortType videoinformationmod.VideoSortType, target *videoinformationmod.VideoInformationList) { + defer wg.Done() + list, err := homeVideoDataList(ctx, sortType) + if err != nil { + setErr(err) + return + } + *target = list + } + wg.Add(3) + go queryList(videoinformationmod.VideoSortType_Recommend, &recommendList) + go queryList(videoinformationmod.VideoSortType_New, &newestList) + go queryList(videoinformationmod.VideoSortType_Hot, &hotList) + wg.Wait() + if err1 != nil { + return resp, err1 + } + resp.RecommendList = videoListToHomeData(recommendList, imgCdn) + resp.NewestList = videoListToHomeData(newestList, imgCdn) + resp.HotList = videoListToHomeData(hotList, imgCdn) + return resp, nil +} + +// QueryVideoMore 根据分类id分页查询视频列表,并按固定间隔穿插列表广告 +func QueryVideoMore(ctx context.Context, req *proto.QueryVideoReq) (*proto.QueryVideoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + categoryId, err := slugser.VideoCategoryID(ctx, req.CategoryId, req.CategorySlug, true) + if err != nil { + return nil, err + } + req.CategoryId = categoryId + categoryInfo := videoCategoryInfo(req.CategoryId) + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, queryVideoDefaultPageSize, queryVideoMaxPageSize) + filter, sort, err := queryVideoFilterAndSort(ctx, req.CategoryId) + if err != nil { + return nil, err + } + config := configser.GetRequestConfig(ctx) + ads := configser.GetAdvitise().PostList + totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + if len(ads) == 0 { + resp, err := queryVideoMoreWithoutAds(ctx, filter, sort, req.CategoryId, pageNum, pageSize, totalCount, config.ImgCdn) + if resp != nil { + resp.CategoryInfo = categoryInfo + } + return resp, err + } + adsTotalCount := moreser.InsertedAdsCount(totalCount, int64(len(ads)), queryVideoAdInterval) + mixedTotalCount := totalCount + adsTotalCount + start := (pageNum - 1) * pageSize + if start >= mixedTotalCount || totalCount == 0 { + resp := emptyQueryVideoMorePage(pageNum, pageSize, categoryInfo) + resp.TotalCount = mixedTotalCount + return resp, nil + } + end := start + pageSize + if end > mixedTotalCount { + end = mixedTotalCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, totalCount, adsTotalCount, queryVideoAdInterval) + list := videoinformationmod.VideoInformationList{} + if contentLimit > 0 { + list, err = findVideoCategoryPage(ctx, filter, sort, req.CategoryId, contentStart, contentLimit) + if err != nil { + return nil, err + } + } + resp := buildQueryVideoMixedList(list, ads, config.ImgCdn, start, end, contentStart, adsTotalCount) + return &proto.QueryVideoResp{BasePageResp: response.BasePageResp{ + TotalCount: mixedTotalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: resp, + }, CategoryInfo: categoryInfo, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}, nil +} + +// VideoRecommendList 分页查询 app 端推荐视频列表,并按固定间隔穿插列表广告 +func VideoRecommendList(ctx context.Context, req *proto.VideoRecommendListReq) (*proto.VideoRecommendListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + categoryId, err := slugser.VideoCategoryID(ctx, req.CategoryId, req.CategorySlug, false) + if err != nil { + return nil, err + } + tagId, err := slugser.ContentTagID(ctx, req.TagId, req.TagSlug, false) + if err != nil { + return nil, err + } + req.CategoryId = categoryId + req.TagId = tagId + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, queryVideoDefaultPageSize, queryVideoMaxPageSize) + filter, sort, err := videoRecommendFilterAndSort(ctx, req.CategoryId, req.TagId, req.SortList) + if err != nil { + return nil, err + } + config := configser.GetRequestConfig(ctx) + ads := configser.GetAdvitise().PostList + totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + if len(ads) == 0 { + return videoRecommendListWithoutAds(ctx, filter, sort, req.CategoryId, pageNum, pageSize, totalCount, config.ImgCdn) + } + adsTotalCount := moreser.InsertedAdsCount(totalCount, int64(len(ads)), videoRecommendAdInterval) + mixedTotalCount := totalCount + adsTotalCount + start := (pageNum - 1) * pageSize + if start >= mixedTotalCount || totalCount == 0 { + resp := emptyVideoRecommendListPage(pageNum, pageSize) + resp.TotalCount = mixedTotalCount + return resp, nil + } + end := start + pageSize + if end > mixedTotalCount { + end = mixedTotalCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, totalCount, adsTotalCount, videoRecommendAdInterval) + list := videoinformationmod.VideoInformationList{} + if contentLimit > 0 { + list, err = findVideoCategoryPage(ctx, filter, sort, req.CategoryId, contentStart, contentLimit) + if err != nil { + return nil, err + } + } + items := buildVideoRecommendMixedList(list, ads, config.ImgCdn, start, end, contentStart, adsTotalCount) + return &proto.VideoRecommendListResp{BasePageResp: response.BasePageResp{ + TotalCount: mixedTotalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }}, nil +} + +// VideoTagList 根据统一标签ID分页查询视频列表,并按固定间隔穿插列表广告 +func VideoTagList(ctx context.Context, req *proto.ContentTagPageReq) (*proto.VideoRecommendListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") + } + tagId, err := slugser.ContentTagID(ctx, req.Id, req.Slug, true) + if err != nil { + return nil, err + } + req.Id = tagId + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, queryVideoDefaultPageSize, queryVideoMaxPageSize) + ok, err := configser.HasEnabledContentTagBind(ctx, req.Id, contenttagmod.ContentTagTypeVideo) + if err != nil { + return nil, err + } + if !ok { + return emptyVideoRecommendListPage(pageNum, pageSize), nil + } + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + videoinformationmod.FN.VideoInformation.Tags: req.Id, + } + sort := bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Sort, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + config := configser.GetRequestConfig(ctx) + ads := configser.GetAdvitise().PostList + totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + if len(ads) == 0 { + return videoRecommendListWithoutAds(ctx, filter, sort, 0, pageNum, pageSize, totalCount, config.ImgCdn) + } + adsTotalCount := moreser.InsertedAdsCount(totalCount, int64(len(ads)), videoRecommendAdInterval) + mixedTotalCount := totalCount + adsTotalCount + start := (pageNum - 1) * pageSize + if start >= mixedTotalCount || totalCount == 0 { + resp := emptyVideoRecommendListPage(pageNum, pageSize) + resp.TotalCount = mixedTotalCount + return resp, nil + } + end := start + pageSize + if end > mixedTotalCount { + end = mixedTotalCount + } + contentStart, contentLimit := moreser.ContentRange(start, end, totalCount, adsTotalCount, videoRecommendAdInterval) + list := videoinformationmod.VideoInformationList{} + if contentLimit > 0 { + list, err = findVideoRecommendPage(ctx, filter, sort, contentStart, contentLimit) + if err != nil { + return nil, err + } + } + items := buildVideoRecommendMixedList(list, ads, config.ImgCdn, start, end, contentStart, adsTotalCount) + return &proto.VideoRecommendListResp{BasePageResp: response.BasePageResp{ + TotalCount: mixedTotalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }}, nil +} + +// FavoriteVideo 当前登录用户收藏或取消收藏指定视频 +func FavoriteVideo(ctx context.Context, userId int64, ip string, ua useragent.UA, req *proto.FavoriteVideoReq) (*proto.FavoriteVideoResp, *srvdef.SrvErr) { + if req == nil || userId <= 0 || req.Id <= 0 || req.IsFavorite == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + videoInfo, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.Id, + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + }, options.FindOne().SetProjection(bson.M{ + videoinformationmod.FN.VideoInformation.Id: 1, + videoinformationmod.FN.VideoInformation.Title: 1, + videoinformationmod.FN.VideoInformation.Slug: 1, + videoinformationmod.FN.VideoInformation.CategoryId: 1, + videoinformationmod.FN.VideoInformation.MediaType: 1, + })) + if err != nil { + return nil, err + } + if *req.IsFavorite { + created, err := videofavoritemod.UpsertOneVideoFavorite(ctx, userId, req.Id, time.Now()) + if err != nil { + return nil, err + } + if created { + clearFavoriteVideoListCache(userId) + if err := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: req.Id}, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.Likes: 1}}); err != nil { + return nil, err + } + if err := reportser.ReportVideoCollect(userId, ua, ip, videoInfo, 1); err != nil { + log.Warn("ReportVideoCollect failed", log.Any("videoId", req.Id), log.Any("userId", userId), log.E(err)) + } + } + } else { + deleted, err := videofavoritemod.DeleteOneVideoFavorite(ctx, bson.M{ + videofavoritemod.FN.VideoFavorite.UserID: userId, + videofavoritemod.FN.VideoFavorite.VideoID: req.Id, + }) + if err != nil { + return nil, err + } + if deleted { + clearFavoriteVideoListCache(userId) + if err := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: req.Id}, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.Likes: -1}}); err != nil { + return nil, err + } + if err := reportser.ReportVideoCollect(userId, ua, ip, videoInfo, 2); err != nil { + log.Warn("ReportVideoCollect failed", log.Any("videoId", req.Id), log.Any("userId", userId), log.E(err)) + } + } + } + return &proto.FavoriteVideoResp{Id: req.Id, IsFavorite: *req.IsFavorite}, nil +} + +// LikeVideo 当前登录用户点赞或取消点赞指定视频 +func LikeVideo(ctx context.Context, userId int64, ip string, ua useragent.UA, req *proto.LikeVideoReq) (*proto.LikeVideoResp, *srvdef.SrvErr) { + if req == nil || userId <= 0 || req.Id <= 0 || req.IsLike == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + videoInfo, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.Id, + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + }, options.FindOne().SetProjection(bson.M{ + videoinformationmod.FN.VideoInformation.Id: 1, + videoinformationmod.FN.VideoInformation.Title: 1, + videoinformationmod.FN.VideoInformation.Slug: 1, + videoinformationmod.FN.VideoInformation.CategoryId: 1, + videoinformationmod.FN.VideoInformation.MediaType: 1, + videoinformationmod.FN.VideoInformation.Real.Likes: 1, + videoinformationmod.FN.VideoInformation.Fake.Likes: 1, + videoinformationmod.FN.VideoInformation.Video.TaskId: 1, + videoinformationmod.FN.VideoInformation.Video.FileId: 1, + videoinformationmod.FN.VideoInformation.Video.Url: 1, + videoinformationmod.FN.VideoInformation.Video.H265Url: 1, + videoinformationmod.FN.VideoInformation.Video.VideoTime: 1, + })) + if err != nil { + return nil, err + } + if *req.IsLike { + created, err := videolikemod.UpsertOneVideoLike(ctx, userId, req.Id, time.Now()) + if err != nil { + return nil, err + } + if created { + videoInfo.Real.Likes++ + if err := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: req.Id}, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.Likes: 1}}); err != nil { + return nil, err + } + if err := reportser.ReportVideoLike(userId, ua, ip, videoInfo, 1); err != nil { + log.Warn("ReportVideoLike failed", log.Any("videoId", req.Id), log.Any("userId", userId), log.E(err)) + } + } + } else { + deleted, err := videolikemod.DeleteOneVideoLike(ctx, bson.M{ + videolikemod.FN.VideoLike.UserID: userId, + videolikemod.FN.VideoLike.VideoID: req.Id, + }) + if err != nil { + return nil, err + } + if deleted { + if videoInfo.Real.Likes > 0 { + videoInfo.Real.Likes-- + if err := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: req.Id}, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.Likes: -1}}); err != nil { + return nil, err + } + } + if err := reportser.ReportVideoLike(userId, ua, ip, videoInfo, 2); err != nil { + log.Warn("ReportVideoLike failed", log.Any("videoId", req.Id), log.Any("userId", userId), log.E(err)) + } + } + } + return &proto.LikeVideoResp{ + Id: req.Id, + IsLike: *req.IsLike, + LikeNum: videoInfo.Real.Likes + videoInfo.Fake.Likes, + }, nil +} + +// QueryFavoriteVideoList 分页查询当前登录用户收藏的视频,按收藏时间倒序返回 +func QueryFavoriteVideoList(ctx context.Context, userId int64, req *proto.QueryFavoriteVideoListReq) (*proto.QueryFavoriteVideoListResp, *srvdef.SrvErr) { + if userId <= 0 || req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizeQueryVideoPage(req.PageNum, req.PageSize) + version := getFavoriteVideoListCacheVersion(userId) + cacheKey := redisconst.FavoriteVideoListCacheKey(userId, version, pageNum, pageSize) + if resp := getFavoriteVideoListCache(cacheKey); resp != nil { + return resp, nil + } + filter := bson.M{videofavoritemod.FN.VideoFavorite.UserID: userId} + totalCount, err := videofavoritemod.CountVideoFavorite(ctx, filter) + if err != nil { + return nil, err + } + favorites, err := videofavoritemod.FindManyVideoFavorite(ctx, filter, + options.Find(). + SetSort(bson.D{{Key: videofavoritemod.FN.VideoFavorite.CreatedAt, Value: -1}, {Key: videofavoritemod.FN.VideoFavorite.Id, Value: -1}}). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize). + SetProjection(bson.M{videofavoritemod.FN.VideoFavorite.VideoID: 1})) + if err != nil { + return nil, err + } + if len(favorites) == 0 { + resp := emptyFavoriteVideoPage(pageNum, pageSize) + resp.TotalCount = totalCount + setFavoriteVideoListCache(cacheKey, resp) + return resp, nil + } + ids := make([]int64, 0, len(favorites)) + for _, favorite := range favorites { + ids = append(ids, favorite.VideoID) + } + videoList, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids}, + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + }, options.Find().SetProjection(favoriteVideoProjection())) + if err != nil { + return nil, err + } + videoMap := make(map[int64]*videoinformationmod.VideoInformation, len(videoList)) + for _, item := range videoList { + videoMap[item.Id] = item + } + config := configser.GetRequestConfig(ctx) + list := make([]*proto.FavoriteVideoListItem, 0, len(favorites)) + for _, favorite := range favorites { + item, ok := videoMap[favorite.VideoID] + if !ok { + continue + } + list = append(list, favoriteVideoListItem(item, config.ImgCdn)) + } + resp := &proto.QueryFavoriteVideoListResp{BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: list, + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()} + setFavoriteVideoListCache(cacheKey, resp) + return resp, nil +} + +// QueryVideoRank 分页查询视频排行榜,读取独立的 10 分钟内存缓存。 +// 点赞榜按累计点赞量、周/月/年榜按对应周期观看量取前 50;不足时随机补位。分页 pageSize 不是 50 时强制为 50。 +func QueryVideoRank(ctx context.Context, req *proto.VideoRankReq) (*proto.VideoRankResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + period := proto.VideoRankPeriod(req.RankPeriod) + if period < proto.VideoRankPeriodLike || period > proto.VideoRankPeriodYear { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := configser.NormalizeRankPage(req.PageNum, req.PageSize) + all := configser.GetVideoRankList(configser.RankPeriod(period), 0) + return &proto.VideoRankResp{BasePageResp: response.BasePageResp{ + TotalCount: int64(len(all)), + PageSize: int(pageSize), + PageNum: int(pageNum), + List: configser.SliceRankPage(all, pageNum, pageSize), + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}, nil +} + +// countVideoTotalLikeRank 统计累计点赞榜可展示视频数量 +func countVideoTotalLikeRank(ctx context.Context) (int64, *srvdef.SrvErr) { + rows := make([]videoRankCount, 0) + if err := videoinformationmod.AggregateVideoInformations(ctx, &rows, videoTotalLikeRankCountPipeline()); err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + return rows[0].Total, nil +} + +// aggregateVideoTotalLikeRank 分页查询累计点赞榜视频 +func aggregateVideoTotalLikeRank(ctx context.Context, skip, limit int64) ([]videoRankAggItem, *srvdef.SrvErr) { + list := make([]videoRankAggItem, 0) + if err := videoinformationmod.AggregateVideoInformations(ctx, &list, videoTotalLikeRankPagePipeline(skip, limit)); err != nil { + return nil, err + } + return list, nil +} + +// countVideoPeriodLikeRank 统计周期新增点赞榜可展示视频数量 +func countVideoPeriodLikeRank(ctx context.Context, since time.Time) (int64, *srvdef.SrvErr) { + rows := make([]videoRankCount, 0) + if err := videolikemod.AggregateVideoLike(ctx, &rows, videoPeriodLikeRankCountPipeline(since)); err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + return rows[0].Total, nil +} + +// aggregateVideoPeriodLikeRank 分页查询周期新增点赞榜视频 +func aggregateVideoPeriodLikeRank(ctx context.Context, since time.Time, skip, limit int64) ([]videoRankAggItem, *srvdef.SrvErr) { + list := make([]videoRankAggItem, 0) + if err := videolikemod.AggregateVideoLike(ctx, &list, videoPeriodLikeRankPagePipeline(since, skip, limit)); err != nil { + return nil, err + } + return list, nil +} + +// getFavoriteVideoListCache 读取当前用户收藏视频分页缓存,缓存异常时降级查库 +func getFavoriteVideoListCache(cacheKey string) *proto.QueryFavoriteVideoListResp { + cacheData, err := redis.Get(cacheKey) + if err != nil { + log.Warn("读取视频收藏列表缓存失败", log.Any("cacheKey", cacheKey), log.E(err)) + return nil + } + if cacheData == nil || *cacheData == "" { + return nil + } + resp := &proto.QueryFavoriteVideoListResp{} + if err := json.Unmarshal([]byte(*cacheData), resp); err != nil { + log.Warn("解析视频收藏列表缓存失败", log.Any("cacheKey", cacheKey), log.E(err)) + return nil + } + return resp +} + +// setFavoriteVideoListCache 写入当前用户收藏视频分页缓存,缓存异常不影响主流程 +func setFavoriteVideoListCache(cacheKey string, resp *proto.QueryFavoriteVideoListResp) { + if resp == nil { + return + } + bytes, err := json.Marshal(resp) + if err != nil { + log.Warn("序列化视频收藏列表缓存失败", log.Any("cacheKey", cacheKey), log.E(err)) + return + } + if err := redis.Set(cacheKey, string(bytes), redisconst.FavoriteVideoListKeyExpire); err != nil { + log.Warn("写入视频收藏列表缓存失败", log.Any("cacheKey", cacheKey), log.E(err)) + } +} + +// getFavoriteVideoListCacheVersion 读取当前用户视频收藏列表缓存版本,读取失败时使用默认版本降级查库 +func getFavoriteVideoListCacheVersion(userId int64) int64 { + cacheKey := redisconst.FavoriteVideoListVersionCacheKey(userId) + cacheData, err := redis.Get(cacheKey) + if err != nil { + log.Warn("读取视频收藏列表缓存版本失败", log.Any("cacheKey", cacheKey), log.E(err)) + return 0 + } + if cacheData == nil || *cacheData == "" { + return 0 + } + version, err := strconv.ParseInt(*cacheData, 10, 64) + if err != nil { + log.Warn("解析视频收藏列表缓存版本失败", log.Any("cacheKey", cacheKey), log.Any("cacheData", *cacheData), log.E(err)) + return 0 + } + return version +} + +// clearFavoriteVideoListCache 刷新当前用户视频收藏列表缓存版本,旧分页缓存等待TTL自然过期 +func clearFavoriteVideoListCache(userId int64) { + if userId <= 0 { + return + } + cacheKey := redisconst.FavoriteVideoListVersionCacheKey(userId) + if _, err := redis.Incr(cacheKey, redisconst.FavoriteListVersionKeyExpire); err != nil { + log.Warn("刷新视频收藏列表缓存版本失败", log.Any("cacheKey", cacheKey), log.E(err)) + } +} + +// QueryVideoInfo 查询视频详情、收藏状态和点赞状态 +func QueryVideoInfo(ctx context.Context, userId int64, ip, ua string, req *proto.QueryVideoInfoReq) (*proto.QueryVideoInfoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + videoId, err := slugser.VideoID(ctx, req.Id, req.Slug, true) + if err != nil { + return nil, err + } + req.Id = videoId + config := configser.GetRequestConfig(ctx) + videoInfo, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.Id, + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + }, options.FindOne().SetProjection(videoDetailProjection())) + if err != nil { + return nil, err + } + // 播放详情访问量异步累加,避免写操作阻塞详情读取链路 + go incrementVideoWatchCount(videoInfo) + // 并发查询收藏状态、点赞状态和收藏总数 + isFavorite, isLike, countCollect, err := queryVideoDetailExtraData(ctx, userId, videoInfo.Id) + if err != nil { + return nil, err + } + return &proto.QueryVideoInfoResp{ + Path: videoRequestPath(ctx), + Translations: translationser.GetTranslationData(), + Config: config, + BottonTranslations: translationser.GetTranslationBottomList(), + Advitise: configser.GetAdvitise(), + Announcement: configser.GetAnnouncement(), + VideoInfo: videoDetailInfoData(videoInfo, config.ImgCdn, ip, ua, isFavorite, isLike, countCollect), + IsFavorite: isFavorite, + IsLike: isLike, + }, nil +} + +// queryVideoDetailExtraData 并发查询详情页附加数据,缩短详情接口整体等待时间 +func queryVideoDetailExtraData(ctx context.Context, userId, videoId int64) (bool, bool, int64, *srvdef.SrvErr) { + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr *srvdef.SrvErr + isFavorite := false + isLike := false + countCollect := int64(0) + setErr := func(err *srvdef.SrvErr) { + if err == nil { + return + } + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + + wg.Add(3) + go func() { + defer wg.Done() + data, err := queryVideoIsFavorite(ctx, userId, videoId) + if err != nil { + setErr(err) + return + } + mu.Lock() + isFavorite = data + mu.Unlock() + }() + go func() { + defer wg.Done() + data, err := queryVideoIsLike(ctx, userId, videoId) + if err != nil { + setErr(err) + return + } + mu.Lock() + isLike = data + mu.Unlock() + }() + go func() { + defer wg.Done() + count, err := videofavoritemod.CountVideoFavorite(ctx, bson.M{ + videofavoritemod.FN.VideoFavorite.VideoID: videoId, + }) + if err != nil { + setErr(err) + return + } + mu.Lock() + countCollect = count + mu.Unlock() + }() + wg.Wait() + if firstErr != nil { + return false, false, 0, firstErr + } + return isFavorite, isLike, countCollect, nil +} + +// queryVideoIsFavorite 判断当前登录用户是否已收藏该视频,未登录直接返回false +func queryVideoIsFavorite(ctx context.Context, userId, videoId int64) (bool, *srvdef.SrvErr) { + if userId <= 0 { + return false, nil + } + count, err := videofavoritemod.CountVideoFavorite(ctx, bson.M{ + videofavoritemod.FN.VideoFavorite.UserID: userId, + videofavoritemod.FN.VideoFavorite.VideoID: videoId, + }) + if err != nil { + return false, err + } + return count > 0, nil +} + +// queryVideoIsLike 判断当前登录用户是否已点赞该视频,未登录直接返回false +func queryVideoIsLike(ctx context.Context, userId, videoId int64) (bool, *srvdef.SrvErr) { + if userId <= 0 { + return false, nil + } + count, err := videolikemod.CountVideoLike(ctx, bson.M{ + videolikemod.FN.VideoLike.UserID: userId, + videolikemod.FN.VideoLike.VideoID: videoId, + }) + if err != nil { + return false, err + } + return count > 0, nil +} + +// videoRankPeriodSince 将排行榜周期转换为点赞起始时间 +func videoRankPeriodSince(period proto.VideoRankPeriod) time.Time { + now := time.Now() + switch period { + case proto.VideoRankPeriodWeek: + return now.AddDate(0, 0, -7) + case proto.VideoRankPeriodMonth: + return now.AddDate(0, -1, 0) + case proto.VideoRankPeriodYear: + return now.AddDate(-1, 0, 0) + default: + return time.Time{} + } +} + +// videoTotalLikeRankCountPipeline 构建累计点赞榜数量统计管道 +func videoTotalLikeRankCountPipeline() []bson.M { + return append(videoTotalLikeRankBasePipeline(), bson.M{"$count": "total"}) +} + +// videoTotalLikeRankPagePipeline 构建累计点赞榜分页查询管道 +func videoTotalLikeRankPagePipeline(skip, limit int64) []bson.M { + pipeline := videoTotalLikeRankBasePipeline() + pipeline = append(pipeline, + bson.M{"$sort": bson.D{ + {Key: "rankScore", Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + }}, + bson.M{"$skip": skip}, + bson.M{"$limit": limit}, + bson.M{"$project": videoRankProjection()}, + ) + return pipeline +} + +// videoTotalLikeRankBasePipeline 构建累计点赞榜基础过滤管道 +func videoTotalLikeRankBasePipeline() []bson.M { + return []bson.M{ + {"$match": bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}}, + {"$addFields": bson.M{"rankScore": bson.M{"$add": []interface{}{ + bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Real.Likes, 0}}, + bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Fake.Likes, 0}}, + }}}}, + {"$match": bson.M{"rankScore": bson.M{"$gt": 0}}}, + } +} + +// videoPeriodLikeRankCountPipeline 构建周期新增点赞榜数量统计管道 +func videoPeriodLikeRankCountPipeline(since time.Time) []bson.M { + return append(videoPeriodLikeRankBasePipeline(since), bson.M{"$count": "total"}) +} + +// videoPeriodLikeRankPagePipeline 构建周期新增点赞榜分页查询管道 +func videoPeriodLikeRankPagePipeline(since time.Time, skip, limit int64) []bson.M { + pipeline := videoPeriodLikeRankBasePipeline(since) + pipeline = append(pipeline, + bson.M{"$sort": bson.D{ + {Key: "rankScore", Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + }}, + bson.M{"$skip": skip}, + bson.M{"$limit": limit}, + bson.M{"$project": videoRankProjection()}, + ) + return pipeline +} + +// videoPeriodLikeRankBasePipeline 构建周期新增点赞榜基础过滤管道 +func videoPeriodLikeRankBasePipeline(since time.Time) []bson.M { + return []bson.M{ + {"$match": bson.M{videolikemod.FN.VideoLike.CreatedAt: bson.M{"$gte": since}}}, + {"$group": bson.M{ + "_id": "$" + videolikemod.FN.VideoLike.VideoID, + "rankScore": bson.M{"$sum": 1}, + }}, + {"$lookup": bson.M{ + "from": videoinformationmod.TN.VideoInformation, + "localField": "_id", + "foreignField": videoinformationmod.FN.VideoInformation.Id, + "as": "video", + }}, + {"$unwind": "$video"}, + {"$match": bson.M{"video." + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}}, + {"$replaceRoot": bson.M{"newRoot": bson.M{"$mergeObjects": []interface{}{"$video", bson.M{"rankScore": "$rankScore"}}}}}, + } +} + +// videoRankProjection 限定排行榜返回字段,减少分页查询的数据传输体积 +func videoRankProjection() 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.UpdatedAt: 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, + "rankScore": 1, + } +} + +// videoRankToContentData 将排行榜聚合结果转换为榜单展示数据 +func videoRankToContentData(item videoRankAggItem, imgCdn string) proto.ContentData { + tagNames, tagSlugs := configser.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.RankScore, + } +} + +// videoDetailInfoData 将视频模型转换为详情页专用响应结构,视频播放地址按m3u8授权格式返回 +func videoDetailInfoData(item *videoinformationmod.VideoInformation, imgCdn, ip, ua string, isFavorite, isLike bool, countCollect int64) proto.VideoDetailInfo { + tags := make([]proto.TagsInfoData, 0, len(item.Tags)) + for _, tagId := range item.Tags { + tag := configser.GetVideoTag(tagId) + if tag != nil { + tags = append(tags, *tag) + } + } + video := item.Video + if video.Url != "" { + video.Url = GetM3U8AuthParam(item.Id, video.Url, ip, ua) + } + if video.H265Url != "" { + video.H265Url = GetM3U8AuthParam(item.Id, video.H265Url, ip, ua) + } + return proto.VideoDetailInfo{ + Id: item.Id, + Title: item.Title, + Slug: item.Slug, + CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), + Video: video, + Tags: tags, + WatchNum: item.Real.WatchTimes + item.Fake.WatchTimes, + LikeNum: item.Real.Likes + item.Fake.Likes, + CountCollect: countCollect, + AddedTime: item.AddedTime, + Desc: item.Desc, + IsFavorite: isFavorite, + IsLike: isLike, + } +} + +// videoDetailProjection 限定视频详情查询字段,避免返回整条视频文档 +func videoDetailProjection() bson.M { + return bson.M{ + videoinformationmod.FN.VideoInformation.Id: 1, + videoinformationmod.FN.VideoInformation.Title: 1, + videoinformationmod.FN.VideoInformation.Slug: 1, + videoinformationmod.FN.VideoInformation.CoverImg: 1, + videoinformationmod.FN.VideoInformation.Video.TaskId: 1, + videoinformationmod.FN.VideoInformation.Video.FileId: 1, + videoinformationmod.FN.VideoInformation.Video.Url: 1, + videoinformationmod.FN.VideoInformation.Video.H265Url: 1, + videoinformationmod.FN.VideoInformation.Video.Cover: 1, + videoinformationmod.FN.VideoInformation.Video.Height: 1, + videoinformationmod.FN.VideoInformation.Video.Width: 1, + videoinformationmod.FN.VideoInformation.Video.VideoTime: 1, + videoinformationmod.FN.VideoInformation.Video.PreMp4FileNameFs: 1, + videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4: 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, + videoinformationmod.FN.VideoInformation.AddedTime: 1, + videoinformationmod.FN.VideoInformation.Desc: 1, + videoinformationmod.FN.VideoInformation.CategoryId: 1, + videoinformationmod.FN.VideoInformation.RealUpdateAt: 1, + } +} + +// homeVideoDataList 按首页视频排序类型查询固定数量的视频 +func homeVideoDataList(ctx context.Context, sortType videoinformationmod.VideoSortType) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) { + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + } + if sortType == videoinformationmod.VideoSortType_Recommend { + filter[videoinformationmod.FN.VideoInformation.IsRecommend] = true + } + return videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find(). + SetProjection(queryVideoProjection()). + SetSort(homeVideoDataSort(sortType)). + SetLimit(homeVideoDataLimit)) +} + +// homeVideoDataSort 将首页视频推荐、最新、最热转换为 MongoDB 排序条件 +func homeVideoDataSort(sortType videoinformationmod.VideoSortType) bson.D { + switch sortType { + case videoinformationmod.VideoSortType_Recommend: + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Sort, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WeekWatchs, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + case videoinformationmod.VideoSortType_Hot: + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.HeatValue, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + default: + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + } +} + +// videoListToHomeData 批量转换视频模型为 app 首页视频数据 +func videoListToHomeData(list videoinformationmod.VideoInformationList, imgCdn string) []proto.VideoInfo { + result := make([]proto.VideoInfo, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + result = append(result, homeVideoInfo(item, imgCdn)) + } + return result +} + +// homeVideoSort 将分类配置的排序方式转换为MG排序条件 +func homeVideoSort(sortType pubdef.CategorySortType) bson.D { + switch sortType { + case pubdef.CategorySortTypeAddedTimeDesc: + return bson.D{{videoinformationmod.FN.VideoInformation.AddedTime, -1}} + case pubdef.CategorySortTypeWatchDesc: + return bson.D{{videoinformationmod.FN.VideoInformation.Real.WatchTimes, -1}, {videoinformationmod.FN.VideoInformation.Fake.WatchTimes, -1}} + case pubdef.CategorySortTypeLikeDesc: + return bson.D{{videoinformationmod.FN.VideoInformation.Real.Likes, -1}, {videoinformationmod.FN.VideoInformation.Fake.Likes, -1}} + case pubdef.CategorySortTypeAddedTimeAsc: + return bson.D{{videoinformationmod.FN.VideoInformation.AddedTime, 1}} + case pubdef.CategorySortTypeWatchAsc: + return bson.D{{videoinformationmod.FN.VideoInformation.Real.WatchTimes, 1}, {videoinformationmod.FN.VideoInformation.Fake.WatchTimes, 1}} + case pubdef.CategorySortTypeLikeAsc: + return bson.D{{videoinformationmod.FN.VideoInformation.Real.Likes, 1}, {videoinformationmod.FN.VideoInformation.Fake.Likes, 1}} + case pubdef.CategorySortTypeUpdateTimeDesc: + return bson.D{{videoinformationmod.FN.VideoInformation.UpdatedAt, -1}} + case pubdef.CategorySortTypeUpdateTimeAsc: + return bson.D{{videoinformationmod.FN.VideoInformation.UpdatedAt, 1}} + case pubdef.CategorySortTypeSortType: + return videoinformationmod.CategoryRankSort() + default: + return bson.D{{videoinformationmod.FN.VideoInformation.AddedTime, -1}} + } +} + +// homeVideoInfo 将视频模型转换为首页轻量视频结构 +func homeVideoInfo(item *videoinformationmod.VideoInformation, imgCdn string) proto.VideoInfo { + // 标签名称来自本地配置缓存,避免为每条视频额外访问数据库 + tagNames, tagSlugs := configser.VideoTagNamesAndSlugs(item.Tags) + // 返回 + return proto.VideoInfo{ContentData: 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, + SeoInfo: pubdef.SeoValue{ + Title: item.Title, + Keyworld: append([]string{}, item.Keyworld...), + Description: item.Desc, + }, + }} +} + +// queryVideoFilterAndSort 根据视频分类构建更多列表查询条件和排序规则 +func queryVideoFilterAndSort(ctx context.Context, categoryID int64) (bson.M, bson.D, *srvdef.SrvErr) { + translationser.EnsureVideoCategoryCache(ctx) + category := translationser.GetVideoCategory(categoryID) + if category == nil || !category.Status { + return nil, nil, srvdef.New(srvdef.ErrNoData, "", "") + } + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + } + filter[videoinformationmod.FN.VideoInformation.Tags] = bson.M{"$in": category.Tags} + return filter, homeVideoSort(category.SortType), nil +} + +// queryVideoProjection 限定视频更多列表字段,避免返回详情页才需要的大字段 +func queryVideoProjection() bson.M { + return bson.M{ + videoinformationmod.FN.VideoInformation.Id: 1, + videoinformationmod.FN.VideoInformation.Title: 1, + videoinformationmod.FN.VideoInformation.Slug: 1, + videoinformationmod.FN.VideoInformation.Keyworld: 1, + videoinformationmod.FN.VideoInformation.Desc: 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, + } +} + +// videoRecommendFilterAndSort 根据分类标签、内容标签和推荐视频排序枚举构建查询条件 +func videoRecommendFilterAndSort(ctx context.Context, categoryId, tagId, sortList int64) (bson.M, bson.D, *srvdef.SrvErr) { + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + } + tagConditions := make([]bson.M, 0, 2) + if categoryId > 0 { + translationser.EnsureVideoCategoryCache(ctx) + category := translationser.GetVideoCategory(categoryId) + if category == nil || !category.Status { + return nil, nil, srvdef.New(srvdef.ErrNoData, "", "") + } + tagConditions = append(tagConditions, bson.M{videoinformationmod.FN.VideoInformation.Tags: bson.M{"$in": category.Tags}}) + } + if tagId > 0 { + tagConditions = append(tagConditions, bson.M{videoinformationmod.FN.VideoInformation.Tags: tagId}) + } + switch len(tagConditions) { + case 1: + for key, value := range tagConditions[0] { + filter[key] = value + } + case 2: + filter["$and"] = tagConditions + } + switch proto.VideoRecommendSort(sortList) { + case proto.VideoRecommendSortNewest: + return filter, bson.D{ + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + }, nil + case proto.VideoRecommendSortHot: + return filter, bson.D{ + {Key: videoinformationmod.FN.VideoInformation.HeatValue, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + }, nil + default: + if categoryId <= 0 { + filter[videoinformationmod.FN.VideoInformation.IsRecommend] = true + } else { + return filter, videoinformationmod.CategoryRankSort(), nil + } + return filter, bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Sort, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WeekWatchs, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + }, nil + } +} + +// findVideoRecommendPage 查询当前混排分页需要的视频数据 +func findVideoRecommendPage(ctx context.Context, filter bson.M, sort bson.D, skip, limit int64) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) { + return videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find(). + SetProjection(queryVideoProjection()). + SetSort(sort). + SetSkip(skip). + SetLimit(limit)) +} + +// findVideoCategoryPage 仅在分类自定义排序时计算当前分类排序值 +func findVideoCategoryPage(ctx context.Context, filter bson.M, sort bson.D, categoryId, skip, limit int64) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) { + if categoryId > 0 && len(sort) > 0 && sort[0].Key == videoinformationmod.CategoryRankField { + return videoinformationmod.FindManyVideoByCategoryRank(ctx, filter, categoryId, skip, limit, queryVideoProjection()) + } + return findVideoRecommendPage(ctx, filter, sort, skip, limit) +} + +// videoRecommendListWithoutAds 无广告时直接按推荐视频分页查询 +func videoRecommendListWithoutAds(ctx context.Context, filter bson.M, sort bson.D, categoryId, pageNum, pageSize, totalCount int64, cdn string) (*proto.VideoRecommendListResp, *srvdef.SrvErr) { + list, err := findVideoCategoryPage(ctx, filter, sort, categoryId, (pageNum-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]*proto.VideoRecommendListItem, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, videoToRecommendListItem(item, cdn)) + } + return &proto.VideoRecommendListResp{BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }}, nil +} + +// queryVideoMoreWithoutAds 无广告时直接按视频分页查询 +func queryVideoMoreWithoutAds(ctx context.Context, filter bson.M, sort bson.D, categoryId, pageNum, pageSize, totalCount int64, cdn string) (*proto.QueryVideoResp, *srvdef.SrvErr) { + skip := (pageNum - 1) * pageSize + list, err := findVideoCategoryPage(ctx, filter, sort, categoryId, skip, pageSize) + if err != nil { + return nil, err + } + resp := make([]*proto.QueryVideoListItem, 0, len(list)) + for _, item := range list { + resp = append(resp, queryVideoToListItem(item, cdn)) + } + return &proto.QueryVideoResp{BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: resp, + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}, nil +} + +// buildQueryVideoMixedList 按混排后的全局位置切出当前页视频和广告数据 +func buildQueryVideoMixedList(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, queryVideoAdInterval) + if adIndex >= 0 && adIndex < adsTotal { + resp = append(resp, queryVideoAdToListItem(ads[adIndex], cdn)) + continue + } + contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adsTotal, queryVideoAdInterval) + if item := contentByIndex[contentIndex]; item != nil { + resp = append(resp, queryVideoToListItem(item, cdn)) + } + } + return resp +} + +// buildVideoRecommendMixedList 按混排后的全局位置切出当前页视频和广告数据 +func buildVideoRecommendMixedList(list videoinformationmod.VideoInformationList, ads []proxy.AdvitiseInfo, cdn string, start, end, contentStart, adsTotal int64) []*proto.VideoRecommendListItem { + items := make([]*proto.VideoRecommendListItem, 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, videoRecommendAdInterval) + if adIndex >= 0 && adIndex < adsTotal { + items = append(items, videoAdToRecommendListItem(ads[adIndex], cdn)) + continue + } + contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adsTotal, videoRecommendAdInterval) + if item := contentByIndex[contentIndex]; item != nil { + items = append(items, videoToRecommendListItem(item, cdn)) + } + } + return items +} + +// queryVideoToListItem 将视频模型转换为视频更多列表项 +func queryVideoToListItem(item *videoinformationmod.VideoInformation, cdn string) *proto.QueryVideoListItem { + return &proto.QueryVideoListItem{ + Type: proto.QueryVideoItemTypeVideo, + VideoInfo: homeVideoInfo(item, cdn), + } +} + +// videoToRecommendListItem 将视频模型转换为 app 推荐视频列表项 +func videoToRecommendListItem(item *videoinformationmod.VideoInformation, cdn string) *proto.VideoRecommendListItem { + return &proto.VideoRecommendListItem{ + Type: proto.VideoRecommendItemTypeVideo, + VideoInfo: homeVideoInfo(item, cdn), + } +} + +// favoriteVideoProjection 返回收藏视频列表需要的最小字段,减少 MongoDB 网络传输和反序列化开销 +func favoriteVideoProjection() 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, + } +} + +// favoriteVideoListItem 将视频模型转换为当前用户收藏列表项 +func favoriteVideoListItem(item *videoinformationmod.VideoInformation, imgCdn string) *proto.FavoriteVideoListItem { + tagNames, tagSlugs := configser.VideoTagNamesAndSlugs(item.Tags) + return &proto.FavoriteVideoListItem{ + 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, + } +} + +type videoHistoryRow struct { + UpdatedAt time.Time `bson:"updatedAt"` + Video videoinformationmod.VideoInformation `bson:"video"` +} + +// ReportVideoHistory 校验上架视频后写入当前用户观看历史 +func ReportVideoHistory(ctx context.Context, userID int64, req *proto.VideoHistoryReportReq) (*proto.VideoHistoryReportResp, *srvdef.SrvErr) { + if userID <= 0 || req == nil || req.Id <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "观看历史参数无效", "") + } + video, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.Id, + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + }) + if err != nil { + return nil, err + } + if video.Video.Url == "" && video.Video.H265Url == "" { + return nil, srvdef.New(srvdef.ErrParamError, "视频不可播放", "") + } + now := time.Now().UTC() + if err := models.TouchWatchHistory(ctx, models.WatchHistory{ + UserID: userID, DataType: models.WatchHistoryTypeVideo, + ContentID: req.Id, CreatedAt: now, UpdatedAt: now, + }); err != nil { + return nil, err + } + return &proto.VideoHistoryReportResp{WatchedAt: now}, nil +} + +// QueryVideoHistoryList 按最近观看时间倒序分页返回当前用户的上架视频 +func QueryVideoHistoryList(ctx context.Context, userID int64, req *proto.VideoHistoryListReq) (*proto.VideoHistoryListResp, *srvdef.SrvErr) { + if userID <= 0 || req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "观看历史参数无效", "") + } + pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, 20, 100) + base := []bson.M{ + {"$match": bson.M{models.WatchHistoryUserID: userID, models.WatchHistoryDataType: models.WatchHistoryTypeVideo, models.WatchHistoryUpdatedAt: bson.M{"$gte": time.Now().UTC().AddDate(0, 0, -models.WatchHistoryRetentionDays)}}}, + {"$lookup": bson.M{"from": videoinformationmod.TN.VideoInformation, "localField": models.WatchHistoryContentID, "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 := models.AggregateWatchHistory(ctx, &countRows, append(append([]bson.M(nil), base...), bson.M{"$count": "total"})); err != nil { + return nil, err + } + total := int64(0) + if len(countRows) > 0 { + total = countRows[0].Total + } + resp := &proto.VideoHistoryListResp{BasePageResp: response.BasePageResp{ + TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.VideoHistoryItem, 0), + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()} + if total == 0 || pageNum > (total-1)/pageSize+1 { + return resp, nil + } + sortStage := bson.M{"$sort": bson.D{{Key: models.WatchHistoryUpdatedAt, Value: -1}, {Key: "_id", Value: -1}}} + pipeline := append([]bson.M{base[0], sortStage}, base[1:]...) + pipeline = append(pipeline, bson.M{"$skip": (pageNum - 1) * pageSize}, bson.M{"$limit": pageSize}) + rows := make([]videoHistoryRow, 0) + if err := models.AggregateWatchHistory(ctx, &rows, pipeline); err != nil { + return nil, err + } + cdn := configser.GetRequestConfig(ctx).ImgCdn + items := make([]*proto.VideoHistoryItem, 0, len(rows)) + for _, row := range rows { + items = append(items, &proto.VideoHistoryItem{ + FavoriteVideoListItem: *favoriteVideoListItem(&row.Video, cdn), + Desc: row.Video.Desc, WatchedAt: row.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// ClearVideoHistory 清空当前用户的视频观看历史 +func ClearVideoHistory(ctx context.Context, userID int64) (*proto.VideoHistoryClearResp, *srvdef.SrvErr) { + if userID <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "用户无效", "") + } + count, err := models.DeleteWatchHistory(ctx, userID, models.WatchHistoryTypeVideo) + if err != nil { + return nil, err + } + return &proto.VideoHistoryClearResp{DeletedCount: count}, nil +} + +// normalizeQueryVideoPage 规范化视频分页参数,限制单次请求数量避免大分页拖垮 MongoDB +func normalizeQueryVideoPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = queryVideoDefaultPageSize + } + if pageSize > queryVideoMaxPageSize { + pageSize = queryVideoMaxPageSize + } + return pageNum, pageSize +} + +// emptyFavoriteVideoPage 构造当前用户收藏视频空分页响应 +func emptyFavoriteVideoPage(pageNum, pageSize int64) *proto.QueryFavoriteVideoListResp { + return &proto.QueryFavoriteVideoListResp{BasePageResp: response.BasePageResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.FavoriteVideoListItem, 0), + }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()} +} + +// queryVideoAdToListItem 将广告转换为视频更多列表项 +func queryVideoAdToListItem(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), + }}, + } +} + +// videoAdToRecommendListItem 将广告转换为 app 推荐视频列表项 +func videoAdToRecommendListItem(ad proxy.AdvitiseInfo, cdn string) *proto.VideoRecommendListItem { + return &proto.VideoRecommendListItem{ + Type: proto.VideoRecommendItemTypeAdvertise, + AdvertiseId: ad.Id, + Link: ad.Link, + Location: ad.Location, + VideoInfo: proto.VideoInfo{ContentData: proto.ContentData{ + Title: ad.Title, + CoverImg: netutil.MergeURL(cdn, ad.Images), + }}, + } +} + +// emptyQueryVideoMorePage 构造视频更多空分页响应 +func emptyQueryVideoMorePage(pageNum, pageSize int64, categoryInfo proto.VideoCategoryInfo) *proto.QueryVideoResp { + return &proto.QueryVideoResp{BasePageResp: response.BasePageResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.QueryVideoListItem, 0), + }, CategoryInfo: categoryInfo, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()} +} + +func videoCategoryInfo(categoryID int64) proto.VideoCategoryInfo { + return toVideoCategoryInfo(translationser.GetVideoCategory(categoryID)) +} + +func toVideoCategoryInfo(category *videocategorymod.VideoCategory) proto.VideoCategoryInfo { + if category == nil { + return proto.VideoCategoryInfo{} + } + return proto.VideoCategoryInfo{ + Id: category.Id, + Name: category.Name, + Slug: category.Slug, + Title: category.Title, + Desc: category.Desc, + SeoInfo: category.SeoInfo, + } +} + +// emptyVideoRecommendListPage 构造推荐视频空分页响应 +func emptyVideoRecommendListPage(pageNum, pageSize int64) *proto.VideoRecommendListResp { + return &proto.VideoRecommendListResp{BasePageResp: response.BasePageResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.VideoRecommendListItem, 0), + }} +} + +// videoRequestPath 从上下文中提取当前请求路径,避免API层重复组装视频首页响应字段 +func videoRequestPath(ctx context.Context) string { + ginCtx, ok := ctx.(*gin.Context) + if !ok || ginCtx.Request == nil || ginCtx.Request.URL == nil { + return "" + } + return ginCtx.Request.URL.Path +} diff --git a/app/service/videoser/video_list_test.go b/app/service/videoser/video_list_test.go new file mode 100644 index 0000000..e394c25 --- /dev/null +++ b/app/service/videoser/video_list_test.go @@ -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) + } + } +} diff --git a/app/service/videoser/vtt.go b/app/service/videoser/vtt.go new file mode 100644 index 0000000..609b7b2 --- /dev/null +++ b/app/service/videoser/vtt.go @@ -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) +} diff --git a/app/service/wordsfilterser/advertise.go b/app/service/wordsfilterser/advertise.go new file mode 100644 index 0000000..b4fa2f8 --- /dev/null +++ b/app/service/wordsfilterser/advertise.go @@ -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 + } +} diff --git a/app/service/wordsfilterser/wordsfilter.go b/app/service/wordsfilterser/wordsfilter.go new file mode 100644 index 0000000..3c9f885 --- /dev/null +++ b/app/service/wordsfilterser/wordsfilter.go @@ -0,0 +1,11 @@ +package wordsfilterser + +// IsAdvertise 判断文本是否包含广告链接、联系方式或站外引流。 +func IsAdvertise(text string) bool { + return !validateAdvertiseText(text) +} + +// Validate 校验文本是否可发布。当前仅拦截广告内容。 +func Validate(text string) bool { + return !IsAdvertise(text) +} diff --git a/app/skd/ipstat/sync.go b/app/skd/ipstat/sync.go new file mode 100644 index 0000000..431d237 --- /dev/null +++ b/app/skd/ipstat/sync.go @@ -0,0 +1,58 @@ +package ipstat + +import ( + "context" + "time" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/models/ipdailystatmod" + "videoweb/pub/pubdef" + "videoweb/pub/redis" +) + +const syncInterval = 5 * time.Minute + +// StartSync 启动后台协程,每隔 syncInterval 将当日和昨日的 Redis IP SET +// 数量同步写入 MongoDB(ip_daily_stat),供 web 端查询。 +func StartSync() { + go func() { + // 启动时立即同步一次 + syncOnce() + ticker := time.NewTicker(syncInterval) + defer ticker.Stop() + for range ticker.C { + syncOnce() + } + }() +} + +func syncOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + now := time.Now().In(utils.LocBJ) + appId := int64(pubdef.AppId) + + for _, offset := range []int{0, -1} { + date := now.AddDate(0, 0, offset).Format("20060102") + key := redisconst.IpStatKey(appId, date) + + count, err := redis.SCard(key) + if err != nil { + log.Warn("ipstat sync: SCard failed", + log.Any("date", date), log.E(err)) + continue + } + + // 今日 Redis 为 0 时不覆盖 MongoDB,避免 key 丢失把已有数据写成 0。 + if offset == 0 && count == 0 { + continue + } + + if err := ipdailystatmod.UpsertCount(ctx, appId, date, count); err != nil { + log.Warn("ipstat sync: UpsertCount failed", + log.Any("appId", appId), log.Any("date", date), log.E(err)) + } + } +} diff --git a/app/skd/job/dhsitemap/dhsitemap.go b/app/skd/job/dhsitemap/dhsitemap.go new file mode 100644 index 0000000..4e35659 --- /dev/null +++ b/app/skd/job/dhsitemap/dhsitemap.go @@ -0,0 +1,134 @@ +package dhsitemap + +import ( + "context" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + "videoweb/app/service/configser" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" +) + +const ( + outputDir = "../h5/templates/page/sitemaps" + filename = "dhsitemap.xml" + + // 与主站 sitemap 一致:浏览器用 XSLT 把 loc 渲成可点击链接 + stylesheetURLSet = `` + "\n" +) + +var generateMu sync.Mutex + +type urlSet struct { + XMLName xml.Name `xml:"urlset"` + Xmlns string `xml:"xmlns,attr"` + URLs []urlEntry `xml:"url"` +} + +type urlEntry struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod"` +} + +// GenerateDHSitemap 生成导航站 sitemap,域名取自系统配置 newUrlAddr。 +func GenerateDHSitemap() { + generateMu.Lock() + defer generateMu.Unlock() + + ctx := context.Background() + config, serr := configser.GetSystemConfig(ctx) + if serr != nil { + log.Error("GenerateDHSitemap GetSystemConfig", log.Any("err", serr)) + return + } + if config == nil { + log.Error("GenerateDHSitemap GetSystemConfig config is nil") + return + } + + urls := collectDHURLs(config.NewUrlAddr, config.NewUrlAddr1, config.NewUrlAddr2) + if len(urls) == 0 { + log.Warn("GenerateDHSitemap: no newUrlAddr configured, skip") + return + } + + today := time.Now().Format("2006-01-02") + entries := make([]urlEntry, 0, len(urls)) + for _, u := range urls { + entries = append(entries, urlEntry{ + Loc: u, + LastMod: today, + }) + } + + doc := urlSet{ + Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + URLs: entries, + } + + if err := writeFile(doc); err != nil { + log.Error("GenerateDHSitemap write", log.Any("err", err)) + return + } + + src := filepath.Join(outputDir, filename+".tmp") + dst := filepath.Join(outputDir, filename) + if err := os.Rename(src, dst); err != nil { + log.Error("GenerateDHSitemap rename", log.Any("err", err)) + os.Remove(src) + return + } + + log.Info("GenerateDHSitemap done", log.Any("urls", len(entries))) +} + +func collectDHURLs(addrs ...string) []string { + seen := make(map[string]struct{}, len(addrs)) + urls := make([]string, 0, len(addrs)) + for _, addr := range addrs { + addr = strings.TrimSpace(addr) + if addr == "" { + continue + } + loc := utils.JoinUrlPath(addr, "/") + if _, ok := seen[loc]; ok { + continue + } + seen[loc] = struct{}{} + urls = append(urls, loc) + } + return urls +} + +func writeFile(doc urlSet) error { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", outputDir, err) + } + path := filepath.Join(outputDir, filename+".tmp") + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", filename, err) + } + defer file.Close() + + if _, err := file.WriteString(xml.Header); err != nil { + return fmt.Errorf("write header: %w", err) + } + if _, err := file.WriteString(stylesheetURLSet); err != nil { + return fmt.Errorf("write stylesheet: %w", err) + } + encoder := xml.NewEncoder(file) + encoder.Indent("", " ") + if err := encoder.Encode(doc); err != nil { + return fmt.Errorf("encode: %w", err) + } + if err := encoder.Flush(); err != nil { + return fmt.Errorf("flush: %w", err) + } + return file.Sync() +} diff --git a/app/skd/job/llmstxt/llmstxt.go b/app/skd/job/llmstxt/llmstxt.go new file mode 100644 index 0000000..c46b6c0 --- /dev/null +++ b/app/skd/job/llmstxt/llmstxt.go @@ -0,0 +1,362 @@ +package llmstxt + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + "videoweb/app/service/configser" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/models/actress" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videotagmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + maxTagsInBrief = 80 // llms.txt 中标签条数上限 + maxRecentInFull = 500 // llms-full.txt 中最新视频条数上限 +) + +var generateMu sync.Mutex + +// GenerateLLMSTxt 生成 llms.txt(摘要)和 llms-full.txt(详细),输出到 outDir。 +func GenerateLLMSTxt(outDir string) { + generateMu.Lock() + defer generateMu.Unlock() + + ctx := context.Background() + config, serr := configser.GetSystemConfig(ctx) + if serr != nil || config == nil { + log.Error("GenerateLLMSTxt GetSystemConfig", log.Any("err", serr)) + return + } + + baseURL := strings.TrimSuffix(config.WebAddr, "/") + + // ── 查询数据 ────────────────────────────────────────────── + categoryList, _ := videocategorymod.FindManyVideoCategorys(ctx, + bson.M{videocategorymod.FN.VideoCategory.Status: true}, + func() *options.FindOptions { + o := options.Find() + o.SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1}) + return o + }()) + + tagOpt := options.Find() + tagOpt.SetSort(bson.M{videotagmod.FN.VideoTagInfo.WatchTimes: -1}) + tagOpt.SetLimit(int64(maxTagsInBrief)) + tagList, _ := videotagmod.FindManyVideoTagInfos(ctx, + bson.M{videotagmod.FN.VideoTagInfo.Status: true}, tagOpt) + + actressOpt := options.Find() + actressOpt.SetSort(bson.M{actress.FN.Actress.Works: -1}) + actressList, _ := actress.FindManyActresss(ctx, + bson.M{actress.FN.Actress.Status: true}, actressOpt) + + recentOpt := options.Find() + recentOpt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1}) + recentOpt.SetLimit(50) + recentVideos, _ := videoinformationmod.FindManyVideoInformations(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}, + recentOpt) + + // ── 生成 llms.txt ───────────────────────────────────────── + var brief strings.Builder + writeBrief(&brief, config.WebTitle, config.WebDescription, baseURL, config.WebKeyworld, + categoryList, tagList, actressList, recentVideos) + + if err := writeTextFile(outDir, "llms.txt", brief.String()); err != nil { + log.Error("GenerateLLMSTxt write llms.txt", log.Any("err", err)) + return + } + if err := commitFile(outDir, "llms.txt"); err != nil { + log.Error("GenerateLLMSTxt commit llms.txt", log.Any("err", err)) + return + } + + // ── 生成 llms-full.txt ──────────────────────────────────── + allVideoOpt := options.Find() + allVideoOpt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1}) + allVideoOpt.SetLimit(int64(maxRecentInFull)) + allVideos, _ := videoinformationmod.FindManyVideoInformations(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}, + allVideoOpt) + + var full strings.Builder + writeFull(&full, config.WebTitle, config.WebDescription, baseURL, config.WebKeyworld, + categoryList, tagList, actressList, allVideos) + + if err := writeTextFile(outDir, "llms-full.txt", full.String()); err != nil { + log.Error("GenerateLLMSTxt write llms-full.txt", log.Any("err", err)) + return + } + if err := commitFile(outDir, "llms-full.txt"); err != nil { + log.Error("GenerateLLMSTxt commit llms-full.txt", log.Any("err", err)) + return + } + + log.Info("GenerateLLMSTxt done", + log.Any("categories", len(categoryList)), + log.Any("tags", len(tagList)), + log.Any("actresses", len(actressList)), + log.Any("videos", len(allVideos)), + ) +} + +// ── 内容构建 ────────────────────────────────────────────────── + +func writeBrief( + b *strings.Builder, + title, desc, baseURL string, + keywords []string, + categories videocategorymod.VideoCategoryList, + tags videotagmod.VideoTagInfoList, + actresses actress.ActressList, + recentVideos videoinformationmod.VideoInformationList, +) { + now := time.Now().Format("2006-01-02") + + // 文件头 + fmt.Fprintf(b, "# %s\n\n", title) + if desc != "" { + fmt.Fprintf(b, "> %s\n\n", desc) + } + if len(keywords) > 0 { + fmt.Fprintf(b, "> 关键词:%s\n\n", strings.Join(keywords, "、")) + } + fmt.Fprintf(b, "> 更新时间:%s\n\n", now) + + // 重要页面 + writeImportantPages(b, baseURL) + + // 分类 + if len(categories) > 0 { + fmt.Fprintf(b, "## 内容分类\n\n") + for _, c := range categories { + if c == nil || c.Id == 0 { + continue + } + name := c.Name + if c.Title != "" { + name = c.Title + } + catDesc := c.Desc + if catDesc == "" { + catDesc = name + "相关视频内容" + } + fmt.Fprintf(b, "- [%s](%s/video/more/%d): %s\n", name, + baseURL, c.Id, catDesc) + } + b.WriteString("\n") + } + + // 热门标签(取播放量最高的) + if len(tags) > 0 { + fmt.Fprintf(b, "## 热门标签\n\n") + for _, t := range tags { + if t == nil || t.Id == 0 { + continue + } + fmt.Fprintf(b, "- [%s](%s/video/all?pageNum=1&pageSize=40&tagsIds=%d)\n", t.Title, baseURL, t.Id) + } + b.WriteString("\n") + } + + // 作者 + if len(actresses) > 0 { + fmt.Fprintf(b, "## 作者\n\n") + for _, item := range actresses { + if item == nil || item.Id == 0 { + continue + } + fmt.Fprintf(b, "- [%s](%s/actress/detail?id=%d)\n", item.Alias, baseURL, item.Id) + } + b.WriteString("\n") + } + + // 最新内容(50条) + if len(recentVideos) > 0 { + fmt.Fprintf(b, "## 最新内容\n\n") + for _, v := range recentVideos { + if v == nil || v.Id == 0 { + continue + } + url := utils.JoinUrlPath(baseURL, fmt.Sprintf("/video/detail?id=%d", v.Id)) + desc := v.Desc + if desc == "" { + desc = v.Title + } + desc = truncate(desc, 100) + fmt.Fprintf(b, "- [%s](%s): %s\n", v.Title, url, desc) + } + b.WriteString("\n") + } +} + +func writeFull( + b *strings.Builder, + title, desc, baseURL string, + keywords []string, + categories videocategorymod.VideoCategoryList, + tags videotagmod.VideoTagInfoList, + actresses actress.ActressList, + videos videoinformationmod.VideoInformationList, +) { + // llms-full.txt 先包含 brief 的所有内容(用全量视频列表替换最新50条) + // 再附加每条视频的完整详情 + + now := time.Now().Format("2006-01-02") + + fmt.Fprintf(b, "# %s\n\n", title) + if desc != "" { + fmt.Fprintf(b, "> %s\n\n", desc) + } + if len(keywords) > 0 { + fmt.Fprintf(b, "> 关键词:%s\n\n", strings.Join(keywords, "、")) + } + fmt.Fprintf(b, "> 更新时间:%s\n\n", now) + + // 重要页面 + writeImportantPages(b, baseURL) + + // 分类(含 SEO 描述) + if len(categories) > 0 { + fmt.Fprintf(b, "## 内容分类\n\n") + for _, c := range categories { + if c == nil || c.Id == 0 { + continue + } + name := c.Name + if c.Title != "" { + name = c.Title + } + catDesc := c.SeoInfo.Description + if catDesc == "" { + catDesc = c.Desc + } + if catDesc == "" { + catDesc = name + "相关视频内容" + } + fmt.Fprintf(b, "- [%s](%s/video/more/%d): %s\n", name, baseURL, c.Id, catDesc) + } + b.WriteString("\n") + } + + // 全部标签 + if len(tags) > 0 { + fmt.Fprintf(b, "## 全部标签\n\n") + for _, t := range tags { + if t == nil || t.Id == 0 { + continue + } + tagDesc := t.SeoInfo.Description + if tagDesc == "" { + tagDesc = t.Title + } + fmt.Fprintf(b, "- [%s](%s/video/all?pageNum=1&pageSize=40&tagsIds=%d): %s\n", t.Title, baseURL, t.Id, tagDesc) + } + b.WriteString("\n") + } + + // 作者 + if len(actresses) > 0 { + fmt.Fprintf(b, "## 作者\n\n") + for _, item := range actresses { + if item == nil || item.Id == 0 { + continue + } + itemDesc := item.SeoInfo.Description + if itemDesc == "" { + itemDesc = item.Desc + } + fmt.Fprintf(b, "- [%s](%s/actress/detail?id=%d): %s\n", + item.Alias, baseURL, item.Id, itemDesc) + } + b.WriteString("\n") + } + + // 视频详情列表 + if len(videos) > 0 { + fmt.Fprintf(b, "## 内容详情(最新 %d 条)\n\n", len(videos)) + for _, v := range videos { + if v == nil || v.Id == 0 { + continue + } + url := utils.JoinUrlPath(baseURL, fmt.Sprintf("/video/detail?id=%d", v.Id)) + fmt.Fprintf(b, "### [%s](%s)\n\n", v.Title, url) + + if v.Desc != "" { + fmt.Fprintf(b, "%s\n\n", truncate(v.Desc, 500)) + } + + if !v.AddedTime.IsZero() { + fmt.Fprintf(b, "- 发布时间:%s\n", v.AddedTime.Format("2006-01-02")) + } + if v.CreatorName != "" { + fmt.Fprintf(b, "- 创作者:%s\n", v.CreatorName) + } + if len(v.Keyworld) > 0 { + fmt.Fprintf(b, "- 关键词:%s\n", strings.Join(v.Keyworld, "、")) + } + b.WriteString("\n") + } + } +} + +func writeImportantPages(b *strings.Builder, baseURL string) { + fmt.Fprintf(b, "## 重要页面\n\n") + fmt.Fprintf(b, "- [首页](%s/): 网站主页\n", baseURL) + fmt.Fprintf(b, "- [热门搜索](%s/search/hot): 热门搜索关键词\n", baseURL) + fmt.Fprintf(b, "- [视频首页](%s/video): 高清视频内容入口\n", baseURL) + fmt.Fprintf(b, "- [全部视频](%s/video/all): 浏览全部视频\n", baseURL) + fmt.Fprintf(b, "- [视频排行榜](%s/video/rank): 热门视频排行\n", baseURL) + fmt.Fprintf(b, "- [线路导航](%s/nav): 最新可用线路入口\n", baseURL) + fmt.Fprintf(b, "- [应用导航](%s/application): 应用推荐与下载入口\n", baseURL) + fmt.Fprintf(b, "- [RSS订阅](%s/rss.xml): RSS Feed\n", baseURL) + fmt.Fprintf(b, "- [Sitemap](%s/sitemap.xml): 网站地图\n\n", baseURL) +} + +// ── 工具函数 ────────────────────────────────────────────────── + +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "…" +} + +func writeTextFile(outDir, filename, content string) error { + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", outDir, err) + } + tmpPath := filepath.Join(outDir, filename+".tmp") + f, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("create %s: %w", tmpPath, err) + } + defer f.Close() + if _, err := f.WriteString(content); err != nil { + return fmt.Errorf("write %s: %w", filename, err) + } + return f.Sync() +} + +func commitFile(outDir, filename string) error { + src := filepath.Join(outDir, filename+".tmp") + dst := filepath.Join(outDir, filename) + if err := os.Rename(src, dst); err != nil { + os.Remove(src) + return fmt.Errorf("rename %s: %w", filename, err) + } + return nil +} diff --git a/app/skd/job/llmstxt/llmstxt_test.go b/app/skd/job/llmstxt/llmstxt_test.go new file mode 100644 index 0000000..129fb47 --- /dev/null +++ b/app/skd/job/llmstxt/llmstxt_test.go @@ -0,0 +1,31 @@ +package llmstxt + +import ( + "strings" + "testing" +) + +func TestWriteImportantPagesUsesWebRoutes(t *testing.T) { + t.Parallel() + + var b strings.Builder + writeImportantPages(&b, "https://91jxporn.com") + out := b.String() + + for _, forbidden := range []string{"/tag/all", "/comicsList", "/search/hot/"} { + if strings.Contains(out, forbidden) { + t.Fatalf("important pages should not contain legacy path %q:\n%s", forbidden, out) + } + } + + for _, required := range []string{ + "/search/hot)", + "/video)", + "/nav)", + "/application)", + } { + if !strings.Contains(out, required) { + t.Fatalf("important pages missing %q:\n%s", required, out) + } + } +} diff --git a/app/skd/job/rss/rss.go b/app/skd/job/rss/rss.go new file mode 100644 index 0000000..4b5223b --- /dev/null +++ b/app/skd/job/rss/rss.go @@ -0,0 +1,217 @@ +package rss + +import ( + "context" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + "videoweb/app/service/configser" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/utils" + "videoweb/pub/models/cfg" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + maxRSSItems = 200 // RSS 条目上限 +) + +var generateMu sync.Mutex + +// RSS 2.0 结构定义 +type rssFeed struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Atom string `xml:"xmlns:atom,attr"` + Channel rssChannel `xml:"channel"` +} + +type rssChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language"` + LastBuildDate string `xml:"lastBuildDate"` + AtomLink atomLink `xml:"atom:link"` + Items []rssItem `xml:"item"` +} + +type atomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr"` + Type string `xml:"type,attr"` +} + +type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + GUID string `xml:"guid"` + Enclosure *rssEnclosure `xml:"enclosure,omitempty"` +} + +type rssEnclosure struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` + Length string `xml:"length,attr"` +} + +// GenerateRSS 生成 rss.xml,输出到 outDir/rss.xml(Web 侧通过 /rss.xml 静态暴露) +func GenerateRSS(ctx context.Context, outDir string) { + generateMu.Lock() + defer generateMu.Unlock() + + scfg, srvErr := configser.GetSystemConfig(ctx) + if srvErr != nil || scfg == nil { + log.Error("GenerateRSS GetSystemConfig", log.Any("err", srvErr)) + return + } + + // 查询最新的已上架视频 + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + } + opt := options.Find(). + SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1}). + SetLimit(int64(maxRSSItems)) + videoList, vsErr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt) + if vsErr != nil { + log.Error("GenerateRSS FindManyVideoInformations", log.Any("err", vsErr)) + return + } + + items := buildRSSItems(scfg, videoList) + feedURL := utils.JoinUrlPath(scfg.WebAddr, "/rss.xml") + feed := rssFeed{ + Version: "2.0", + Atom: "http://www.w3.org/2005/Atom", + Channel: rssChannel{ + Title: scfg.WebTitle, + Link: scfg.WebAddr, + Description: scfg.WebDescription, + Language: "zh-cn", + LastBuildDate: time.Now().Format(time.RFC1123Z), + AtomLink: atomLink{ + Href: feedURL, + Rel: "self", + Type: "application/rss+xml", + }, + Items: items, + }, + } + + const filename = "rss.xml" + if err := writeRSSFile(outDir, filename, feed); err != nil { + log.Error("GenerateRSS write", log.Any("err", err)) + return + } + + // 写入成功,rename .tmp → 正式文件 + src := filepath.Join(outDir, filename+".tmp") + dst := filepath.Join(outDir, filename) + if err := os.Rename(src, dst); err != nil { + log.Error("GenerateRSS rename", log.Any("err", err)) + os.Remove(src) + return + } + + log.Info("GenerateRSS done", log.Any("items", len(items))) +} + +// absModIndex 根据 id 确定性选取切片索引,保证结果非负 +func absModIndex(id int64, length int) int { + idx := id % int64(length) + if idx < 0 { + idx = -idx + } + return int(idx) +} + +func buildRSSItems(scfg *cfg.SysCfg, videoList videoinformationmod.VideoInformationList) []rssItem { + logoURL := utils.JoinUrlPath(scfg.WebAddr, "/static/images/logo.png") + + items := make([]rssItem, 0, len(videoList)) + for _, v := range videoList { + desc := v.Desc + if desc == "" { + desc = v.Title + } + + pageURL := utils.JoinUrlPathKeepSlash(scfg.WebAddr, fmt.Sprintf("/video/detail?id=%d", v.Id)) + + pubDate := "" + if !v.AddedTime.IsZero() { + pubDate = v.AddedTime.Format(time.RFC1123Z) + } + + item := rssItem{ + Title: v.Title, + Link: pageURL, + Description: desc, + PubDate: pubDate, + GUID: pageURL, + } + + // 封面图优先级:UnencryptedImage > SitemapDefaultImages 确定性选取 > logo + encURL := "" + encType := "image/jpeg" + if v.UnencryptedImage != "" && scfg.UnencryptedCdn != "" { + encURL = netutil.MergeURL(scfg.UnencryptedCdn, v.UnencryptedImage) + } else if len(scfg.SitemapDefaultImages) > 0 && scfg.UnencryptedCdn != "" { + img := scfg.SitemapDefaultImages[absModIndex(v.Id, len(scfg.SitemapDefaultImages))] + encURL = netutil.MergeURL(scfg.UnencryptedCdn, img) + } else { + encURL = logoURL + encType = "image/png" + } + if strings.HasSuffix(encURL, ".png") { + encType = "image/png" + } else if strings.HasSuffix(encURL, ".webp") { + encType = "image/webp" + } + item.Enclosure = &rssEnclosure{ + URL: encURL, + Type: encType, + Length: "0", + } + + items = append(items, item) + } + return items +} + +// writeRSSFile 将数据写入 rssDir/filename.tmp,由调用方负责 rename +func writeRSSFile(rssDir, filename string, data interface{}) error { + if err := os.MkdirAll(rssDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", rssDir, err) + } + path := filepath.Join(rssDir, filename+".tmp") + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", filename, err) + } + defer file.Close() + + if _, err := file.WriteString(xml.Header); err != nil { + return fmt.Errorf("write header %s: %w", filename, err) + } + encoder := xml.NewEncoder(file) + encoder.Indent("", " ") + if err := encoder.Encode(data); err != nil { + return fmt.Errorf("encode %s: %w", filename, err) + } + if err := encoder.Flush(); err != nil { + return fmt.Errorf("flush %s: %w", filename, err) + } + return file.Sync() +} diff --git a/app/skd/job/sitemap/data.go b/app/skd/job/sitemap/data.go new file mode 100644 index 0000000..19965af --- /dev/null +++ b/app/skd/job/sitemap/data.go @@ -0,0 +1,312 @@ +package sitemap + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "sync" + "time" + "videoweb/pub/common/utils" + "videoweb/pub/models/actress" + "videoweb/pub/models/cfg" + "videoweb/pub/models/translationcategorymod" + "videoweb/pub/models/translationmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// idWithTime 携带 ID、slug 和实际更新时间 +type idWithTime struct { + Id int64 + Slug string + LastMod time.Time +} + +func hasAnyID(ids []int64, used map[int64]struct{}) bool { + for _, id := range ids { + if _, ok := used[id]; ok { + return true + } + } + return false +} + +func isSitemapChannelPath(path, prefix string, extras ...string) bool { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + for _, extra := range extras { + if path == extra { + return true + } + } + return false +} + +func detailSegment(slug string, id int64) string { + if s := strings.TrimSpace(slug); s != "" { + return s + } + if id > 0 { + return strconv.FormatInt(id, 10) + } + return "" +} + +func buildChannelKeyURLs(webAddr, channel, keyPrefix string, items []idWithTime, changeFreq string, priority float32) []URL { + urls := make([]URL, 0, len(items)) + channel = strings.TrimRight(channel, "/") + for _, item := range items { + seg := detailSegment(item.Slug, item.Id) + if seg == "" { + continue + } + lastMod := item.LastMod.Format(time.DateOnly) + if item.LastMod.IsZero() { + lastMod = time.Now().Format(time.DateOnly) + } + urls = append(urls, URL{ + Loc: utils.JoinUrlPathKeepSlash(webAddr, channel+"/"+keyPrefix+seg), + LastMod: lastMod, + ChangeFreq: changeFreq, + Priority: priority, + }) + } + return urls +} + +// buildCategoryURLs 分类页:/{channel}/c-{slug},无 slug 时回退 c-{id}。 +func buildCategoryURLs(webAddr, channel string, items []idWithTime, changeFreq string, priority float32) []URL { + return buildChannelKeyURLs(webAddr, channel, "c-", items, changeFreq, priority) +} + +// buildTagURLs 标签页:/{channel}/t-{slug},无 slug 时回退 t-{id}。 +func buildTagURLs(webAddr, channel string, items []idWithTime, changeFreq string, priority float32) []URL { + return buildChannelKeyURLs(webAddr, channel, "t-", items, changeFreq, priority) +} + +// buildDetailURLs 详情页:/{prefix}/{slug},无 slug 时回退数字 id。 +func buildDetailURLs(webAddr, prefix string, items []idWithTime, changeFreq string, priority float32) []URL { + urls := make([]URL, 0, len(items)) + for _, item := range items { + seg := detailSegment(item.Slug, item.Id) + if seg == "" { + continue + } + lastMod := item.LastMod.Format(time.DateOnly) + if item.LastMod.IsZero() { + lastMod = time.Now().Format(time.DateOnly) + } + urls = append(urls, URL{ + Loc: utils.JoinUrlPathKeepSlash(webAddr, prefix+"/"+seg), + LastMod: lastMod, + ChangeFreq: changeFreq, + Priority: priority, + }) + } + return urls +} + +// buildURLs 根据 ID+时间列表批量构建 URL 条目 +func buildURLs(webAddr string, pathFmt string, items []idWithTime, changeFreq string, priority float32) []URL { + urls := make([]URL, 0, len(items)) + for _, item := range items { + lastMod := item.LastMod.Format(time.DateOnly) + if item.LastMod.IsZero() { + lastMod = time.Now().Format(time.DateOnly) + } + urls = append(urls, URL{ + Loc: utils.JoinUrlPathKeepSlash(webAddr, fmt.Sprintf(pathFmt, item.Id)), + LastMod: lastMod, + ChangeFreq: changeFreq, + Priority: priority, + }) + } + return urls +} + +// filterIdsWithTime 返回 source 中存在于 validMap 的条目(含 slug)。 +func filterIdsWithTime(source map[int64]struct{}, validMap map[int64]idWithTime) []idWithTime { + result := make([]idWithTime, 0, len(source)) + for id := range source { + if item, ok := validMap[id]; ok { + result = append(result, item) + } + } + return result +} + +func getVideoCategoryList(ctx context.Context, usedTagIds map[int64]struct{}, hasVideos bool) []idWithTime { + filter := bson.M{ + videocategorymod.FN.VideoCategory.Status: true, + } + pageSize := int64(1000) + items := make([]idWithTime, 0) + for i := int64(0); i < 500; i++ { + skip := i * pageSize + list, _ := videocategorymod.FindManyVideoCategorys(ctx, filter, options.Find().SetSort(bson.M{videocategorymod.FN.VideoCategory.Id: -1}).SetSkip(skip).SetLimit(pageSize)) + for _, info := range list { + if len(info.Tags) == 0 { + if !hasVideos { + continue + } + } else if !hasAnyID(info.Tags, usedTagIds) { + continue + } + if !videoCategoryHasContent(ctx, info) { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + if len(list) < int(pageSize) { + break + } + } + return items +} + +// videoCategoryContentFilter 与 app QueryVideoMore 使用相同的视频筛选条件。 +func videoCategoryContentFilter(category *videocategorymod.VideoCategory) bson.M { + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + } + if category != nil && len(category.Tags) > 0 { + filter[videoinformationmod.FN.VideoInformation.Tags] = bson.M{"$in": category.Tags} + } + return filter +} + +func videoCategoryHasContent(ctx context.Context, category *videocategorymod.VideoCategory) bool { + if category == nil || !category.Status { + return false + } + count, err := videoinformationmod.CountVideoInformations(ctx, videoCategoryContentFilter(category)) + return err == nil && count > 0 +} + +func getActress(ctx context.Context) map[int64]idWithTime { + filter := bson.M{ + actress.FN.Actress.Status: true, + } + pageSize := int64(1000) + pageIds := make(map[int64]idWithTime) + for i := int64(0); i < 100; i++ { + skip := i * pageSize + list, _ := actress.FindManyActresss(ctx, filter, options.Find().SetSkip(skip).SetLimit(pageSize).SetSort(bson.M{actress.FN.Actress.Id: -1})) + for _, info := range list { + pageIds[info.Id] = idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt} + } + if len(list) < int(pageSize) { + break + } + } + return pageIds +} + +func videoEntriesToIDWithTime(entries []videoEntry) []idWithTime { + items := make([]idWithTime, len(entries)) + for i, e := range entries { + items[i] = idWithTime{Id: e.Id, Slug: e.Slug, LastMod: e.LastMod} + } + return items +} + +func getVideoList(ctx context.Context) ([]videoEntry, map[int64]struct{}, map[int64]struct{}) { + filter := bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK} + pageSize := int64(1000) + items := make([]videoEntry, 0) + tagIds := make(map[int64]struct{}) + actressIds := make(map[int64]struct{}) + for i := int64(0); i < 500; i++ { + skip := i * pageSize + list, _ := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: -1}).SetLimit(pageSize).SetSkip(skip)) + for _, info := range list { + items = append(items, videoEntry{ + Id: info.Id, + Slug: info.Slug, + Title: info.Title, + LastMod: info.UpdatedAt, + UnencryptedPreMp4: info.Video.UnencryptedPreMp4, + }) + for _, tagId := range info.Tags { + tagIds[tagId] = struct{}{} + } + for _, actressId := range info.ActressIds { + if actressId > 0 { + actressIds[actressId] = struct{}{} + } + } + } + if len(list) < int(pageSize) { + break + } + } + return items, tagIds, actressIds +} + +func getTranslation(config cfg.SysCfg, ctx context.Context) []string { + urlList := make([]string, 0) + wg := &sync.WaitGroup{} + wg.Add(2) + var translationCategoryList translationcategorymod.TranslationCategoryList + var translationList translationmod.TranslationInfoList + go func() { + defer wg.Done() + categoryFilter := bson.M{ + translationcategorymod.FN.TranslationCategory.Status: true, + } + categoryOpt := &options.FindOptions{} + categoryOpt.SetLimit(1000).SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1}) + + translationCategoryList, _ = translationcategorymod.FindManyTranslationCategorys(ctx, categoryFilter, categoryOpt) + }() + go func() { + defer wg.Done() + translationFilter := bson.M{ + translationmod.FN.TranslationInfo.Status: true, + } + translationOpt := &options.FindOptions{} + translationOpt.SetLimit(1000).SetSort(bson.M{translationmod.FN.TranslationInfo.Rank: -1}) + + translationList, _ = translationmod.FindManyTranslationInfos(ctx, translationFilter, translationOpt) + }() + + wg.Wait() + + // 按 categoryId 建索引,避免嵌套遍历 + translationByCategory := make(map[int64][]*translationmod.TranslationInfo, len(translationList)) + for _, t := range translationList { + translationByCategory[t.TransCategoryId] = append(translationByCategory[t.TransCategoryId], t) + } + + for _, categoryInfo := range translationCategoryList { + if categoryInfo.CategoryType == pubdef.TranslationTypeUrl { + if strings.HasPrefix(categoryInfo.UrlValue, "http://") || strings.HasPrefix(categoryInfo.UrlValue, "https://") { + continue + } + urlList = append(urlList, utils.JoinUrlPathAppendEnd(config.WebAddr, categoryInfo.UrlValue)) + } else if categoryInfo.CategoryType == pubdef.TranslationTypeSub || categoryInfo.CategoryType == pubdef.TranslationTypeBottom { + for _, translationInfo := range translationByCategory[categoryInfo.Id] { + if translationInfo.IsFriendAds { + continue + } else if translationInfo.HtmlId > 0 { + title := strings.TrimSpace(translationInfo.Name) + if title != "" { + urlList = append(urlList, utils.JoinUrlPathKeepSlash(config.WebAddr, "/richtext?title="+url.QueryEscape(title))) + } + } else if len(translationInfo.Link) > 0 { + if !strings.HasPrefix(translationInfo.Link, "http://") && !strings.HasPrefix(translationInfo.Link, "https://") { + urlList = append(urlList, utils.JoinUrlPathAppendEnd(config.WebAddr, translationInfo.Link)) + } + } + } + } + } + return urlList +} diff --git a/app/skd/job/sitemap/data_test.go b/app/skd/job/sitemap/data_test.go new file mode 100644 index 0000000..e18b64b --- /dev/null +++ b/app/skd/job/sitemap/data_test.go @@ -0,0 +1,108 @@ +package sitemap + +import ( + "testing" + "time" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videoinformationmod" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestIsSitemapChannelPath(t *testing.T) { + if !isSitemapChannelPath("/drama", "/drama", "/drama-rank") { + t.Fatal("channel home should match") + } + if !isSitemapChannelPath("/drama/all", "/drama", "/drama-rank") { + t.Fatal("channel child path should match") + } + if !isSitemapChannelPath("/drama-rank", "/drama", "/drama-rank") { + t.Fatal("hyphen rank path should match") + } + if isSitemapChannelPath("/drama-rank", "/drama") { + t.Fatal("hyphen rank must not match prefix-only") + } + if isSitemapChannelPath("/video-rank", "/drama", "/drama-rank") { + t.Fatal("other channel rank should not match") + } +} + +func TestBuildDetailURLs(t *testing.T) { + items := []idWithTime{ + {Id: 53942, Slug: "sirozFs", LastMod: time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)}, + } + urls := buildDetailURLs("https://example.com/", "/video-detail", items, "weekly", 0.8) + if len(urls) != 1 { + t.Fatalf("buildDetailURLs() returned %d URLs, want 1", len(urls)) + } + if want := "https://example.com/video-detail/sirozFs"; urls[0].Loc != want { + t.Fatalf("buildDetailURLs() loc = %q, want %q", urls[0].Loc, want) + } + + fallback := buildDetailURLs("https://example.com/", "/video-detail", []idWithTime{{Id: 12}}, "weekly", 0.8) + if len(fallback) != 1 || fallback[0].Loc != "https://example.com/video-detail/12" { + t.Fatalf("empty slug should fall back to id, got %#v", fallback) + } +} + +func TestBuildTagURLs(t *testing.T) { + slugURL := buildTagURLs("https://example.com/", "/video", []idWithTime{{Id: 9, Slug: "saduanfa"}}, "weekly", 0.6) + if len(slugURL) != 1 || slugURL[0].Loc != "https://example.com/video/t-saduanfa" { + t.Fatalf("slug tag loc = %#v", slugURL) + } + idURL := buildTagURLs("https://example.com/", "/drama", []idWithTime{{Id: 10142}}, "weekly", 0.6) + if len(idURL) != 1 || idURL[0].Loc != "https://example.com/drama/t-10142" { + t.Fatalf("id tag loc = %#v", idURL) + } +} + +func TestBuildCategoryURLs(t *testing.T) { + slugURL := buildCategoryURLs("https://example.com/", "/video", []idWithTime{{Id: 37513, Slug: "hot-av"}}, "weekly", 0.6) + if len(slugURL) != 1 || slugURL[0].Loc != "https://example.com/video/c-hot-av" { + t.Fatalf("slug category loc = %#v", slugURL) + } + idURL := buildCategoryURLs("https://example.com/", "/drama", []idWithTime{{Id: 10001}}, "weekly", 0.6) + if len(idURL) != 1 || idURL[0].Loc != "https://example.com/drama/c-10001" { + t.Fatalf("id category loc = %#v", idURL) + } +} + +func TestBuildPlayletSitemapURLs(t *testing.T) { + items := []idWithTime{{Id: 88, Slug: "chuanchengjiaofubei", LastMod: time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)}} + detail := buildDetailURLs("https://example.com/", "/drama-detail", items, "weekly", 0.8) + if len(detail) != 1 || detail[0].Loc != "https://example.com/drama-detail/chuanchengjiaofubei" { + t.Fatalf("playlet detail loc = %#v", detail) + } + category := buildCategoryURLs("https://example.com/", "/drama", items, "weekly", 0.6) + if len(category) != 1 || category[0].Loc != "https://example.com/drama/c-chuanchengjiaofubei" { + t.Fatalf("playlet category loc = %#v", category) + } + idOnly := buildCategoryURLs("https://example.com/", "/video", []idWithTime{{Id: 37513}}, "weekly", 0.6) + if len(idOnly) != 1 || idOnly[0].Loc != "https://example.com/video/c-37513" { + t.Fatalf("category id fallback loc = %#v", idOnly) + } + tag := buildTagURLs("https://example.com/", "/drama", items, "weekly", 0.6) + if len(tag) != 1 || tag[0].Loc != "https://example.com/drama/t-chuanchengjiaofubei" { + t.Fatalf("playlet tag loc = %#v", tag) + } +} + +func TestVideoCategoryContentFilter(t *testing.T) { + filter := videoCategoryContentFilter(&videocategorymod.VideoCategory{ + Status: true, + Tags: []int64{11, 22}, + }) + tagsFilter, ok := filter[videoinformationmod.FN.VideoInformation.Tags].(bson.M) + if !ok { + t.Fatalf("tags filter = %#v, want bson.M", filter[videoinformationmod.FN.VideoInformation.Tags]) + } + in, ok := tagsFilter["$in"].([]int64) + if !ok || len(in) != 2 || in[0] != 11 || in[1] != 22 { + t.Fatalf("tags $in = %#v, want [11 22]", tagsFilter["$in"]) + } + + emptyTags := videoCategoryContentFilter(&videocategorymod.VideoCategory{Status: true}) + if _, exists := emptyTags[videoinformationmod.FN.VideoInformation.Tags]; exists { + t.Fatalf("empty tag category should not add tags filter: %#v", emptyTags) + } +} diff --git a/app/skd/job/sitemap/h5_sitemap.go b/app/skd/job/sitemap/h5_sitemap.go new file mode 100644 index 0000000..fff10ab --- /dev/null +++ b/app/skd/job/sitemap/h5_sitemap.go @@ -0,0 +1,281 @@ +package sitemap + +import ( + "context" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "videoweb/pub/common/utils" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/playlethomeconfigmod" + "videoweb/pub/models/playletthememod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + h5SortHot = "hot" + h5SortLatest = "latest" + h5TopicSort = "editorial" + h5RankPlaylet = "drama" + h5RankVideo = "video" +) + +// getUrl 按 Next H5 路由生成 sitemap 分片:页面、短剧详情、视频详情。 +func getUrl(sg *SitemapGenerator) { + webAddr := strings.TrimRight(strings.TrimSpace(sg.WebAddr), "/") + if webAddr == "" { + return + } + + ctx := context.Background() + var ( + wg sync.WaitGroup + + videoEntries []videoEntry + videoTag map[int64]struct{} + + playletItems []idWithTime + playletTagIDs map[int64]struct{} + + themes []idWithTime + homeModules []idWithTime + ) + + wg.Add(4) + go func() { + defer wg.Done() + videoEntries, videoTag, _ = getVideoList(ctx) + }() + go func() { + defer wg.Done() + playletItems, _, playletTagIDs = getPlayletList(ctx) + }() + go func() { + defer wg.Done() + themes = getEnabledPlayletThemes(ctx) + }() + go func() { + defer wg.Done() + homeModules = getEnabledHomeModules(ctx) + }() + wg.Wait() + + videoCatItems := getVideoCategoryList(ctx, videoTag, len(videoEntries) > 0) + videoTagItems := getContentTagList(ctx, videoTag, contenttagmod.ContentTagTypeVideo) + playletTagItems := getContentTagList(ctx, playletTagIDs, contenttagmod.ContentTagTypePlaylet) + + now := time.Now() + pages := make([]URL, 0, 64) + seen := make(map[string]struct{}) + add := func(dst *[]URL, loc, changeFreq string, priority float32, lastMod time.Time) { + loc = strings.TrimSpace(loc) + if loc == "" { + return + } + if _, ok := seen[loc]; ok { + return + } + seen[loc] = struct{}{} + if lastMod.IsZero() { + lastMod = now + } + *dst = append(*dst, URL{ + Loc: loc, + LastMod: lastMod.Format(time.DateOnly), + ChangeFreq: changeFreq, + Priority: priority, + }) + } + + for _, spec := range h5StaticPages() { + add(&pages, h5Abs(webAddr, spec.path), spec.changeFreq, spec.priority, now) + } + for _, sort := range []string{h5SortHot, h5SortLatest} { + add(&pages, h5Abs(webAddr, "/short-drama", sort), "daily", 0.85, now) + add(&pages, h5Abs(webAddr, "/video", sort), "daily", 0.85, now) + for _, theme := range themes { + seg := detailSegment(theme.Slug, theme.Id) + if seg == "" || isReservedListSegment(seg) { + continue + } + add(&pages, h5Abs(webAddr, "/short-drama", sort, seg), "daily", 0.7, theme.LastMod) + } + for _, category := range videoCatItems { + seg := detailSegment(category.Slug, category.Id) + if seg == "" || isReservedListSegment(seg) { + continue + } + add(&pages, h5Abs(webAddr, "/video", sort, seg), "daily", 0.7, category.LastMod) + } + } + for _, tag := range playletTagItems { + seg := detailSegment(tag.Slug, tag.Id) + if seg == "" { + continue + } + add(&pages, h5Abs(webAddr, "/tag/playlet", seg), "weekly", 0.65, tag.LastMod) + } + for _, theme := range themes { + seg := detailSegment(theme.Slug, theme.Id) + if seg == "" { + continue + } + add(&pages, h5Abs(webAddr, "/topic/playlet", seg, h5TopicSort), "weekly", 0.7, theme.LastMod) + } + for _, tag := range videoTagItems { + seg := detailSegment(tag.Slug, tag.Id) + if seg == "" { + continue + } + add(&pages, h5Abs(webAddr, "/tag/video", seg), "weekly", 0.65, tag.LastMod) + } + for _, category := range videoCatItems { + seg := detailSegment(category.Slug, category.Id) + if seg == "" { + continue + } + add(&pages, h5Abs(webAddr, "/topic/video", seg, h5TopicSort), "weekly", 0.7, category.LastMod) + } + for _, module := range homeModules { + if module.Id <= 0 { + continue + } + add(&pages, h5Abs(webAddr, "/more", moreSegment(module.Slug, module.Id)), "daily", 0.65, module.LastMod) + } + if len(pages) > 0 { + _ = sg.writeSitemapFile("sitemap-pages.xml", pages) + } + + playletURLs := make([]URL, 0, len(playletItems)) + for _, item := range playletItems { + seg := detailSegment(item.Slug, item.Id) + if seg == "" || isReservedListSegment(seg) { + continue + } + add(&playletURLs, h5Abs(webAddr, "/short-drama", seg), "weekly", 0.8, item.LastMod) + } + if len(playletURLs) > 0 { + _ = sg.writeSitemapFiles("sitemap-playletdetail", playletURLs) + } + + videoURLs := make([]URL, 0, len(videoEntries)) + for _, item := range videoEntries { + seg := detailSegment(item.Slug, item.Id) + if seg == "" || isReservedListSegment(seg) { + continue + } + add(&videoURLs, h5Abs(webAddr, "/video", seg), "weekly", 0.8, item.LastMod) + } + if len(videoURLs) > 0 { + _ = sg.writeSitemapFiles("sitemap-videodetail", videoURLs) + } +} + +type h5StaticPage struct { + path string + changeFreq string + priority float32 +} + +func h5StaticPages() []h5StaticPage { + return []h5StaticPage{ + {path: "/", changeFreq: "daily", priority: 1}, + {path: "/welfare", changeFreq: "weekly", priority: 0.6}, + {path: "/nav", changeFreq: "weekly", priority: 0.7}, + {path: "/way-home", changeFreq: "weekly", priority: 0.7}, + {path: "/about", changeFreq: "monthly", priority: 0.4}, + {path: "/terms", changeFreq: "monthly", priority: 0.3}, + {path: "/privacy", changeFreq: "monthly", priority: 0.3}, + {path: "/help", changeFreq: "monthly", priority: 0.3}, + {path: "/ranking/" + h5RankPlaylet, changeFreq: "daily", priority: 0.8}, + {path: "/ranking/" + h5RankVideo, changeFreq: "daily", priority: 0.8}, + } +} + +func isReservedListSegment(seg string) bool { + return seg == h5SortHot || seg == h5SortLatest || seg == h5RankPlaylet || seg == h5RankVideo +} + +// moreSegment 首页更多页使用配置 slug。历史数据没有 slug 时使用 backfill 的字母标识。 +func moreSegment(slug string, id int64) string { + if s := strings.TrimSpace(slug); s != "" { + return s + } + if id > 0 { + return fmt.Sprintf("home-config-%d", id) + } + return "" +} + +// h5Abs 拼出与前端 encodeURIComponent 路径段一致的绝对地址。 +func h5Abs(webAddr string, parts ...string) string { + segments := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(part, "/") + if part == "" { + continue + } + for _, seg := range strings.Split(part, "/") { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + segments = append(segments, url.PathEscape(seg)) + } + } + path := "/" + if len(segments) > 0 { + path = "/" + strings.Join(segments, "/") + } + return utils.JoinUrlPathKeepSlash(webAddr, path) +} + +func getEnabledPlayletThemes(ctx context.Context) []idWithTime { + filter := bson.M{playletthememod.FN.PlayletTheme.Status: true} + pageSize := int64(1000) + items := make([]idWithTime, 0) + for i := int64(0); i < 100; i++ { + list, _ := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find(). + SetSort(bson.M{playletthememod.FN.PlayletTheme.Rank: -1}). + SetSkip(i*pageSize). + SetLimit(pageSize). + SetProjection(bson.M{ + playletthememod.FN.PlayletTheme.Id: 1, + playletthememod.FN.PlayletTheme.Slug: 1, + playletthememod.FN.PlayletTheme.UpdatedAt: 1, + })) + for _, info := range list { + if info == nil || info.Id <= 0 { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + if len(list) < int(pageSize) { + break + } + } + return items +} + +func getEnabledHomeModules(ctx context.Context) []idWithTime { + filter := bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true} + list, _ := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, filter, options.Find(). + SetProjection(bson.M{ + playlethomeconfigmod.FN.PlayletHomeConfig.Id: 1, + playlethomeconfigmod.FN.PlayletHomeConfig.Slug: 1, + playlethomeconfigmod.FN.PlayletHomeConfig.UpdatedAt: 1, + })) + items := make([]idWithTime, 0, len(list)) + for _, info := range list { + if info == nil || info.Id <= 0 { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + return items +} diff --git a/app/skd/job/sitemap/playlet.go b/app/skd/job/sitemap/playlet.go new file mode 100644 index 0000000..9ac25f8 --- /dev/null +++ b/app/skd/job/sitemap/playlet.go @@ -0,0 +1,136 @@ +package sitemap + +import ( + "context" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/playletcategorymod" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// getPlayletList 拉取已上架短剧,并收集实际用到的分类/标签 ID。 +func getPlayletList(ctx context.Context) ([]idWithTime, map[int64]struct{}, map[int64]struct{}) { + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + pageSize := int64(1000) + items := make([]idWithTime, 0) + categoryIDs := make(map[int64]struct{}) + tagIDs := make(map[int64]struct{}) + for i := int64(0); i < 500; i++ { + skip := i * pageSize + list, _ := playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetSort(bson.M{playletmod.FN.Playlet.Id: -1}). + SetSkip(skip). + SetLimit(pageSize). + SetProjection(bson.M{ + playletmod.FN.Playlet.Id: 1, + playletmod.FN.Playlet.Slug: 1, + playletmod.FN.Playlet.UpdatedAt: 1, + playletmod.FN.Playlet.OnlineAt: 1, + playletmod.FN.Playlet.CategoryIds: 1, + playletmod.FN.Playlet.TagIds: 1, + })) + for _, info := range list { + if info == nil || info.Id <= 0 { + continue + } + lastMod := info.UpdatedAt + if lastMod.IsZero() { + lastMod = info.OnlineAt + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: lastMod}) + for _, id := range info.CategoryIds { + if id > 0 { + categoryIDs[id] = struct{}{} + } + } + for _, id := range info.TagIds { + if id > 0 { + tagIDs[id] = struct{}{} + } + } + } + if len(list) < int(pageSize) { + break + } + } + return items, categoryIDs, tagIDs +} + +// getPlayletCategoryList 只收录有上架短剧的分类,避免空列表页被抓取。 +func getPlayletCategoryList(ctx context.Context, usedCategoryIDs map[int64]struct{}) []idWithTime { + if len(usedCategoryIDs) == 0 { + return nil + } + filter := bson.M{ + playletcategorymod.FN.PlayletCategory.Status: true, + } + pageSize := int64(1000) + items := make([]idWithTime, 0) + for i := int64(0); i < 100; i++ { + skip := i * pageSize + list, _ := playletcategorymod.FindManyPlayletCategory(ctx, filter, options.Find(). + SetSort(bson.M{playletcategorymod.FN.PlayletCategory.Rank: -1}). + SetSkip(skip). + SetLimit(pageSize). + SetProjection(bson.M{ + playletcategorymod.FN.PlayletCategory.Id: 1, + playletcategorymod.FN.PlayletCategory.Slug: 1, + playletcategorymod.FN.PlayletCategory.UpdatedAt: 1, + })) + for _, info := range list { + if info == nil || info.Id <= 0 { + continue + } + if _, ok := usedCategoryIDs[info.Id]; !ok { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + if len(list) < int(pageSize) { + break + } + } + return items +} + +// getContentTagList 只收录当前频道已绑定内容的统一标签。 +func getContentTagList(ctx context.Context, usedTagIDs map[int64]struct{}, tagType contenttagmod.ContentTagType) []idWithTime { + if len(usedTagIDs) == 0 { + return nil + } + filter := bson.M{ + contenttagmod.FN.ContentTag.Status: true, + contenttagmod.FN.ContentTag.Types: tagType, + } + pageSize := int64(1000) + items := make([]idWithTime, 0) + for i := int64(0); i < 100; i++ { + skip := i * pageSize + list, _ := contenttagmod.FindManyContentTag(ctx, filter, options.Find(). + SetSort(bson.M{contenttagmod.FN.ContentTag.Rank: -1}). + SetSkip(skip). + SetLimit(pageSize). + SetProjection(bson.M{ + contenttagmod.FN.ContentTag.Id: 1, + contenttagmod.FN.ContentTag.Slug: 1, + contenttagmod.FN.ContentTag.UpdatedAt: 1, + })) + for _, info := range list { + if info == nil || info.Id <= 0 { + continue + } + if _, ok := usedTagIDs[info.Id]; !ok { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + if len(list) < int(pageSize) { + break + } + } + return items +} diff --git a/app/skd/job/sitemap/sitemap.go b/app/skd/job/sitemap/sitemap.go new file mode 100644 index 0000000..eed5432 --- /dev/null +++ b/app/skd/job/sitemap/sitemap.go @@ -0,0 +1,245 @@ +package sitemap + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + // 浏览器用 XSLT 把 loc 渲成可点击链接;搜索引擎仍按 XML 解析 + stylesheetIndex = `` + "\n" + stylesheetURLSet = `` + "\n" + + // defaultMaxURLsPerFile 单文件 URL 上限;Google 上限 50000,留余量避免体积过大。 + defaultMaxURLsPerFile = 40000 +) + +// URL 单个URL条目 +type URL struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod"` + ChangeFreq string `xml:"changefreq"` // always/hourly/daily/weekly/monthly/yearly/never + Priority float32 `xml:"priority"` // 0.0 - 1.0 +} + +// Sitemap 站点地图文件 +type Sitemap struct { + XMLName xml.Name `xml:"urlset"` + Xmlns string `xml:"xmlns,attr"` + URLs []URL `xml:"url"` +} + +// SitemapIndex 站点地图索引 +type SitemapIndex struct { + XMLName xml.Name `xml:"sitemapindex"` + Xmlns string `xml:"xmlns,attr"` + Sitemaps []SitemapIndexEntry `xml:"sitemap"` +} + +// SitemapIndexEntry 站点地图索引条目 +type SitemapIndexEntry struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod"` +} + +// SitemapGenerator 站点地图生成器 +type SitemapGenerator struct { + MaxURLsPerFile int // 每个sitemap文件最大URL数量 + OutputDir string // 分片输出目录(/sitemaps) + IndexFile string // 索引文件路径;空则写到 OutputDir/sitemap.xml + WebAddr string // 网站地址 + SitemapFiles []string // 生成的sitemap文件列表 +} + +// NewSitemapGenerator 创建新的站点地图生成器 +func NewSitemapGenerator(webAddr, outputDir string) *SitemapGenerator { + return &SitemapGenerator{ + MaxURLsPerFile: defaultMaxURLsPerFile, + OutputDir: outputDir, + WebAddr: webAddr, + SitemapFiles: make([]string, 0), + } +} + +func (sg *SitemapGenerator) maxURLsPerFile() int { + if sg.MaxURLsPerFile > 0 { + return sg.MaxURLsPerFile + } + return defaultMaxURLsPerFile +} + +// Generate 生成站点地图 +func (sg *SitemapGenerator) Generate() error { + // 清空之前的文件列表 + sg.SitemapFiles = make([]string, 0) + + // 创建输出目录 + if err := os.MkdirAll(sg.OutputDir, 0755); err != nil { + return err + } + deprecatedFiles := []string{ + "sitemap-createpostlist.xml", + "sitemap-postlist.xml", + "sitemap-posttag.xml", + "sitemap-createvideolist.xml", + "sitemap-tag.xml", + "sitemap-translation.xml", + "sitemap-actress.xml", + "sitemap-videocategory.xml", + "sitemap-playletcategory.xml", + "sitemap-videotag.xml", + "sitemap-playlettag.xml", + "sitemap-archives.xml", + "sitemap-videos.xml", + } + for _, filename := range deprecatedFiles { + if err := os.Remove(filepath.Join(sg.OutputDir, filename)); err != nil && !os.IsNotExist(err) { + return err + } + } + sg.cleanupSitemapShards("sitemap-archives", 0) + sg.cleanupSitemapShards("sitemap-videos", 0) + if sg.IndexFile != "" && filepath.Clean(sg.IndexFile) != filepath.Clean(filepath.Join(sg.OutputDir, "sitemap.xml")) { + if err := os.Remove(filepath.Join(sg.OutputDir, "sitemap.xml")); err != nil && !os.IsNotExist(err) { + return err + } + } + getUrl(sg) + + // 生成sitemap索引文件 + return sg.generateSitemapIndex() +} + +// writeSitemapFile 写入sitemap文件 +func (sg *SitemapGenerator) writeSitemapFile(filename string, urls []URL) error { + sitemap := Sitemap{ + Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + URLs: urls, + } + + filePath := filepath.Join(sg.OutputDir, filename) + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + // 添加XML头 + file.WriteString(xml.Header) + file.WriteString(stylesheetURLSet) + encoder := xml.NewEncoder(file) + encoder.Indent("", " ") + + if err := encoder.Encode(sitemap); err != nil { + return err + } + + // 记录生成的文件 + sg.SitemapFiles = append(sg.SitemapFiles, filename) + return nil +} + +// writeSitemapFiles 写入 sitemap 分片;超过上限时拆为 baseName-0.xml、baseName-1.xml ... +func (sg *SitemapGenerator) writeSitemapFiles(baseName string, urls []URL) error { + baseName = strings.TrimSuffix(strings.TrimSpace(baseName), ".xml") + if baseName == "" { + return nil + } + if len(urls) == 0 { + sg.cleanupSitemapShards(baseName, 0) + return nil + } + + limit := sg.maxURLsPerFile() + shardCount := (len(urls) + limit - 1) / limit + if shardCount == 1 { + sg.cleanupSitemapShards(baseName, 1) + return sg.writeSitemapFile(baseName+".xml", urls) + } + + sg.cleanupSitemapShards(baseName, shardCount) + for i := 0; i < shardCount; i++ { + start := i * limit + end := start + limit + if end > len(urls) { + end = len(urls) + } + filename := fmt.Sprintf("%s-%d.xml", baseName, i) + if err := sg.writeSitemapFile(filename, urls[start:end]); err != nil { + return err + } + } + return nil +} + +// cleanupSitemapShards 清理旧分片:shardCount=1 保留 baseName.xml;>1 保留 baseName-0..N-1.xml。 +func (sg *SitemapGenerator) cleanupSitemapShards(baseName string, shardCount int) { + entries, err := os.ReadDir(sg.OutputDir) + if err != nil { + return + } + prefix := baseName + "-" + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".xml") { + idxText := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".xml") + idx, err := strconv.Atoi(idxText) + if err != nil { + continue + } + if shardCount <= 1 || idx >= shardCount { + _ = os.Remove(filepath.Join(sg.OutputDir, name)) + } + continue + } + if shardCount > 1 && name == baseName+".xml" { + _ = os.Remove(filepath.Join(sg.OutputDir, name)) + } + } +} + +// generateSitemapIndex 生成sitemap索引文件 +func (sg *SitemapGenerator) generateSitemapIndex() error { + index := SitemapIndex{ + Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + Sitemaps: make([]SitemapIndexEntry, 0, len(sg.SitemapFiles)), + } + + now := time.Now().Format(time.DateOnly) + webAddr := strings.TrimRight(strings.TrimSpace(sg.WebAddr), "/") + for _, filename := range sg.SitemapFiles { + index.Sitemaps = append(index.Sitemaps, SitemapIndexEntry{ + Loc: fmt.Sprintf("%s/sitemaps/%s", webAddr, filename), + LastMod: now, + }) + } + + filePath := sg.IndexFile + if strings.TrimSpace(filePath) == "" { + filePath = filepath.Join(sg.OutputDir, "sitemap.xml") + } + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return err + } + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + // 添加XML头 + file.WriteString(xml.Header) + file.WriteString(stylesheetIndex) + encoder := xml.NewEncoder(file) + encoder.Indent("", " ") + + return encoder.Encode(index) +} diff --git a/app/skd/job/sitemap/video_sitemap.go b/app/skd/job/sitemap/video_sitemap.go new file mode 100644 index 0000000..c03a56a --- /dev/null +++ b/app/skd/job/sitemap/video_sitemap.go @@ -0,0 +1,180 @@ +package sitemap + +import ( + "bytes" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" + + "videoweb/pub/common/netutil" + "videoweb/pub/common/utils" +) + +const ( + videoSitemapShardPrefix = "sitemap-videos-" + videoSitemapDefaultThumb = "/static/images/og-default.jpg" + videoSitemapDefaultDesc = "高清成人视频在线观看。" + videoSitemapEmbedPathFmt = "/video/embed?id=%d" +) + +type videoEntry struct { + Id int64 + Slug string + Title string + LastMod time.Time + UnencryptedPreMp4 string + ContentLoc string +} + +// prepareVideoSitemapEntries 为全部视频补齐 sitemap 字段;有 UnencryptedPreMp4 时额外设置 content_loc。 +func prepareVideoSitemapEntries(entries []videoEntry, cdn string) []videoEntry { + cdn = strings.TrimSpace(cdn) + out := make([]videoEntry, 0, len(entries)) + for _, e := range entries { + if e.Id <= 0 { + continue + } + if cdn != "" { + if preMp4 := strings.TrimSpace(e.UnencryptedPreMp4); preMp4 != "" { + if contentLoc := netutil.MergeURL(cdn, preMp4); contentLoc != "" { + e.ContentLoc = contentLoc + } + } + } + out = append(out, e) + } + return out +} + +func (sg *SitemapGenerator) writeVideoSitemapFiles(entries []videoEntry) error { + if len(entries) == 0 { + sg.cleanupVideoSitemapShards(0) + return nil + } + + limit := sg.maxURLsPerFile() + shardCount := (len(entries) + limit - 1) / limit + sg.cleanupVideoSitemapShards(shardCount) + + thumb := strings.TrimRight(sg.WebAddr, "/") + videoSitemapDefaultThumb + for i := 0; i < len(entries); i += limit { + end := i + limit + if end > len(entries) { + end = len(entries) + } + name := fmt.Sprintf("%s%d.xml", videoSitemapShardPrefix, i/limit) + if err := sg.writeOneVideoSitemap(name, entries[i:end], thumb); err != nil { + return err + } + sg.SitemapFiles = append(sg.SitemapFiles, name) + } + return nil +} + +func (sg *SitemapGenerator) cleanupVideoSitemapShards(shardCount int) { + entries, err := os.ReadDir(sg.OutputDir) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, videoSitemapShardPrefix) || !strings.HasSuffix(name, ".xml") { + continue + } + idxText := strings.TrimSuffix(strings.TrimPrefix(name, videoSitemapShardPrefix), ".xml") + idx, err := strconv.Atoi(idxText) + if err != nil || shardCount <= 0 || idx >= shardCount { + _ = os.Remove(filepath.Join(sg.OutputDir, name)) + } + } +} + +func (sg *SitemapGenerator) writeOneVideoSitemap(filename string, entries []videoEntry, thumb string) error { + var b bytes.Buffer + b.WriteString(xml.Header) + b.WriteString(stylesheetURLSet) + b.WriteString(`` + "\n") + for _, e := range entries { + if e.Id <= 0 { + continue + } + loc := videoEntryLoc(sg.WebAddr, e) + if loc == "" { + continue + } + playerLoc := videoEntryPlayerLoc(sg.WebAddr, e.Id) + title := xmlEscapeVideoSitemap(videoEntryTitle(e)) + desc := xmlEscapeVideoSitemap(videoSitemapDefaultDesc) + lastMod := videoEntryLastMod(e) + + b.WriteString(" \n") + fmt.Fprintf(&b, " %s\n", xmlEscapeVideoSitemap(loc)) + fmt.Fprintf(&b, " %s\n", xmlEscapeVideoSitemap(lastMod)) + b.WriteString(" weekly\n") + b.WriteString(" 0.8\n") + b.WriteString(" \n") + fmt.Fprintf(&b, " %s\n", xmlEscapeVideoSitemap(thumb)) + fmt.Fprintf(&b, " %s\n", title) + fmt.Fprintf(&b, " %s\n", desc) + if contentLoc := strings.TrimSpace(e.ContentLoc); contentLoc != "" { + fmt.Fprintf(&b, " %s\n", xmlEscapeVideoSitemap(contentLoc)) + } + fmt.Fprintf(&b, " %s\n", xmlEscapeVideoSitemap(playerLoc)) + b.WriteString(" no\n") + b.WriteString(" \n") + b.WriteString(" \n") + } + b.WriteString("\n") + + path := filepath.Join(sg.OutputDir, filename) + return os.WriteFile(path, b.Bytes(), 0644) +} + +func videoEntryLoc(webAddr string, e videoEntry) string { + seg := detailSegment(e.Slug, e.Id) + if seg == "" { + return "" + } + return utils.JoinUrlPathKeepSlash(webAddr, "/video-detail/"+seg) +} + +func videoEntryPlayerLoc(webAddr string, id int64) string { + return utils.JoinUrlPathKeepSlash(webAddr, fmt.Sprintf(videoSitemapEmbedPathFmt, id)) +} + +func videoEntryTitle(e videoEntry) string { + if title := strings.TrimSpace(e.Title); title != "" { + return truncateRunesVideoSitemap(title, 100) + } + return fmt.Sprintf("视频 %d", e.Id) +} + +func videoEntryLastMod(e videoEntry) string { + if e.LastMod.IsZero() { + return time.Now().Format(time.DateOnly) + } + return e.LastMod.Format(time.DateOnly) +} + +func xmlEscapeVideoSitemap(s string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} + +func truncateRunesVideoSitemap(s string, max int) string { + if max <= 0 || utf8.RuneCountInString(s) <= max { + return s + } + runes := []rune(s) + return string(runes[:max]) +} diff --git a/app/skd/seo/seo.go b/app/skd/seo/seo.go new file mode 100644 index 0000000..23e81c9 --- /dev/null +++ b/app/skd/seo/seo.go @@ -0,0 +1,101 @@ +package seo + +import ( + "context" + "path/filepath" + "strings" + "sync" + "time" + "videoweb/app/service/configser" + "videoweb/app/skd/job/dhsitemap" + "videoweb/app/skd/job/llmstxt" + "videoweb/app/skd/job/rss" + "videoweb/app/skd/job/sitemap" + "videoweb/pub/common/log" + "videoweb/pub/seo/sitemapdirty" + + "github.com/robfig/cron/v3" +) + +const ( + // h5OutputDir 站点地图写在 h5 根目录,不进 public。 + // 样式表仍由前端 public/static 提供,XML 里引用 /static/sitemap*.xsl。 + h5OutputDir = "../h5" + pageTemplateOutputDir = "../h5/templates/page" +) + +var seoCron *cron.Cron + +func init() { + seoCron = cron.New(cron.WithSeconds()) +} + +var ( + dirtyMu sync.Mutex + // generatedDirtyAt 最近一次 sitemap 生成所对应的内容变更时间 + generatedDirtyAt time.Time +) + +// Start 启动 SEO 静态资源生成(sitemap / RSS / llms.txt / dhsitemap)定时任务。 +// 页面级搜索引擎推送由 web 进程的 seopush 任务负责,不在这里处理。 +func Start() { + generatedDirtyAt = sitemapdirty.LastDirtyAt(context.Background()) + go generateSitemapFiles(context.Background()) + go dhsitemap.GenerateDHSitemap() + go llmstxt.GenerateLLMSTxt(pageTemplateOutputDir) + + _, _ = seoCron.AddFunc("0 0 */6 * * ?", func() { + generateSitemapFiles(context.Background()) + dhsitemap.GenerateDHSitemap() + llmstxt.GenerateLLMSTxt(pageTemplateOutputDir) + }) + // 短剧/视频等发布发生在 web 进程,这里检查它留下的变更标记,有变更才重新生成 + _, _ = seoCron.AddFunc("0 */2 * * * ?", func() { + regenerateSitemapIfDirty() + }) + seoCron.Start() + log.Info("seo skd started") +} + +func generateSitemapFiles(ctx context.Context) { + config, serr := configser.GetSystemConfig(ctx) + if serr != nil || config == nil { + log.Error("gen sitemap GetSystemConfig failed", log.Any("err", serr)) + return + } + webAddr := strings.TrimSpace(config.WebAddr) + if webAddr == "" { + log.Error("gen sitemap WebAddr empty") + return + } + generator := sitemap.NewSitemapGenerator(webAddr, filepath.Join(h5OutputDir, "sitemaps")) + generator.IndexFile = filepath.Join(h5OutputDir, "sitemap.xml") + if err := generator.Generate(); err != nil { + log.Error("gen sitemap.xml err", log.E(err)) + return + } + rss.GenerateRSS(ctx, pageTemplateOutputDir) +} + +func regenerateSitemapIfDirty() { + if !dirtyMu.TryLock() { + return + } + defer dirtyMu.Unlock() + + dirtyAt := sitemapdirty.LastDirtyAt(context.Background()) + if dirtyAt.IsZero() || !dirtyAt.After(generatedDirtyAt) { + return + } + log.Info("seo: content changed, regenerate sitemap", log.Any("dirtyAt", dirtyAt)) + generateSitemapFiles(context.Background()) + dhsitemap.GenerateDHSitemap() + llmstxt.GenerateLLMSTxt(pageTemplateOutputDir) + generatedDirtyAt = dirtyAt +} + +func Stop() { + if seoCron != nil { + seoCron.Stop() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..19ba33e --- /dev/null +++ b/go.mod @@ -0,0 +1,130 @@ +module videoweb + +go 1.25.0 + +require ( + github.com/DeanThompson/ginpprof v0.0.0-20201112072838-007b1e56b2e1 + github.com/PuerkitoBio/purell v1.2.1 + github.com/aws/aws-sdk-go v1.34.28 + github.com/ddliu/go-httpclient v0.6.9 + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 + github.com/elastic/go-elasticsearch/v7 v7.17.10 + github.com/gin-gonic/gin v1.10.1 + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.27.0 + github.com/go-redis/redis v6.15.9+incompatible + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible + github.com/google/uuid v1.6.0 + github.com/grafov/m3u8 v0.11.1 + github.com/karrick/godirwalk v1.10.3 + github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20251124080701-096d68ea7706 + github.com/mitchellh/mapstructure v1.4.1 + github.com/moul/http2curl v1.0.0 + github.com/nyaruka/phonenumbers v1.2.2 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/robfig/cron/v3 v3.0.1 + github.com/satori/go.uuid v1.2.0 + github.com/shirou/gopsutil/v3 v3.24.5 + github.com/shopspring/decimal v1.2.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.6 + github.com/xuri/excelize/v2 v2.11.0 + github.com/xxjwxc/gowp v0.0.0-20200603141413-57c3ba7108be + go.mongodb.org/mongo-driver v1.17.3 + go.uber.org/zap v1.16.0 + golang.org/x/crypto v0.53.0 + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f + google.golang.org/api v0.248.0 + gopkg.in/go-playground/validator.v9 v9.31.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + cloud.google.com/go/auth v0.16.5 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/spec v0.22.3 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gosimple/slug v1.15.0 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/smartystreets/goconvey v1.8.1 // indirect + github.com/technoweenie/multipartstreamer v1.0.1 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/xxjwxc/public v0.0.0-20200603141144-4001846f9957 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.uber.org/atomic v1.6.0 // indirect + go.uber.org/multierr v1.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/eapache/queue.v1 v1.1.0 // indirect + gopkg.in/go-playground/assert.v1 v1.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff5cf4e --- /dev/null +++ b/go.sum @@ -0,0 +1,546 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= +cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DeanThompson/ginpprof v0.0.0-20201112072838-007b1e56b2e1 h1:IIOiH2YkFyyHCImuX7YWlHpc7wHZTQVxZwADs5jfggQ= +github.com/DeanThompson/ginpprof v0.0.0-20201112072838-007b1e56b2e1/go.mod h1:kMi/fSDAgvjo9TYfYwYeQ2vkyj+VTR/tB6u/Tjh39t0= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= +github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/ant0ine/go-json-rest v3.3.2+incompatible/go.mod h1:q6aCt0GfU6LhpBsnZ/2U+mwe+0XB5WStbmwyoPfc+sk= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk= +github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ddliu/go-httpclient v0.6.9 h1:/3hsBVpcgCJwqm1dkVlnAJ9NWuYInbRc+i9FyUXX/LE= +github.com/ddliu/go-httpclient v0.6.9/go.mod h1:zM9P0OxV4OGGz1pt/ibuj0ooX2SWH9a6MvXZLbT0JMc= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 h1:AqeKSZIG/NIC75MNQlPy/LM3LxfpLwahICJBHwSMFNc= +github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3/go.mod h1:hEfFauPHz7+NnjR/yHJGhrKo1Za+zStgwUETx3yzqgY= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo= +github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= +github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= +github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= +github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jander/golog v0.0.0-20150917071935-954a5be801fc/go.mod h1:uWhWXOR4dpfk9J8fegnMY7sP2GFXxe3PFI9Ps+TRXJs= +github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/service v1.0.0/go.mod h1:8CzDhVuCuugtsHyZoTvsOBuvonN/UDBvl0kH+BUxvbo= +github.com/karrick/godirwalk v1.10.3 h1:lOpSw2vJP0y5eLBW906QwKsUK/fe/QDyoqM5rnnuPDY= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20251124080701-096d68ea7706 h1:yi4Usm9TUC5itUsw6DPqWXZnfSui0Y3qWEoeFlq778s= +github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20251124080701-096d68ea7706/go.mod h1:+mNMTBuDMdEGhWzoQgc6kBdqeaQpWh5ba8zqmp2MxCU= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/muesli/cache2go v0.0.0-20200423001931-a100c5aac93f/go.mod h1:414R+qZrt4f9S2TO/s6YVQMNAXR2KdwqQ7pW+O4oYzU= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo= +github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/nsqio/go-nsq v1.0.8/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= +github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY= +github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olivere/elastic v6.2.31+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xxjwxc/gowp v0.0.0-20200603130651-4d7368b0e285/go.mod h1:yJ/fY5BorWARfDDsxBU/MyQTHc5MVyNcqBQQYD6MN0k= +github.com/xxjwxc/gowp v0.0.0-20200603141413-57c3ba7108be h1:v4Ws2Pd0HNxegMWiZTgaSBfeLtfyFP/eWc50o2CFFX8= +github.com/xxjwxc/gowp v0.0.0-20200603141413-57c3ba7108be/go.mod h1:mstQ3GY2ZYdP9fCUJJmsq5hwhP0LKoR4ZVTq0XaxK0U= +github.com/xxjwxc/public v0.0.0-20200603115833-341beff27850/go.mod h1:fp3M+FEQrCgWD1fZ/PLwZkCTglf086OEhC9LcydAUnc= +github.com/xxjwxc/public v0.0.0-20200603141144-4001846f9957 h1:1JDmDFVQFpty205Kh9lxBAjjn9rG1eTYtdF/Q0dTh5w= +github.com/xxjwxc/public v0.0.0-20200603141144-4001846f9957/go.mod h1:0BFWVHqt7nKW8MtIx7R7bOkoGQFFnKsaJeeVbkzY88E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= +golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= +google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/eapache/queue.v1 v1.1.0 h1:EldqoJEGtXYiVCMRo2C9mePO2UUGnYn2+qLmlQSqPdc= +gopkg.in/eapache/queue.v1 v1.1.0/go.mod h1:wNtmx1/O7kZSR9zNT1TTOJ7GLpm3Vn7srzlfylFbQwU= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= +gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/go-with/wxpay.v1 v1.3.0/go.mod h1:12lWy92n19pAUSSE3BrOiEZbWRkl+9tneOd/aU/LU6g= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/make.sh b/make.sh new file mode 100755 index 0000000..9d2ef5a --- /dev/null +++ b/make.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +export GOPATH=$(cd $(dirname $0)/../;pwd) +export PATH=${PATH}:${GOPATH}/bin + +curDir=$(cd $(dirname $0);pwd) +cd ${curDir} + +which swag +if [ $? -ne 0 ]; then + echo "未发现swag,拉取最新swag" + go get github.com/swaggo/swag/cmd/swag +fi + +prepare=( + "github.com/swaggo/gin-swagger" + "github.com/swaggo/gin-swagger/swaggerFiles" +) +for goget in ${prepare[@]};do + go get ${goget} + if [ $? -ne 0 ]; then + echo "拉取${goget}失败,终止构建" + exit 1 + fi +done + +echo "package main;func main() {}" > main.go + +swag init --exclude web --output app/docs +if [ $? -ne 0 ]; then + echo "执行swag init失败,终止构建" + rm main.go + exit 1 +fi + +swag init --exclude app --output web/docs +if [ $? -ne 0 ]; then + echo "执行swag init失败,终止构建" + rm main.go + exit 1 +fi + +rm main.go + diff --git a/project.md b/project.md new file mode 100644 index 0000000..721d656 --- /dev/null +++ b/project.md @@ -0,0 +1,86 @@ +--- +项目名称: 海角社区 +一级部门: 北斗 +二级部门: 瑞升 + +DMP编码: + +项目类型: 服务 + # 可选值: + # H5 / APP / PC / 服务 / 组件 / 工具 / AI / 中间件 + +开发语言: GO + # 可选值: + # GO / Java / PHP / Node + +项目负责人: DN1425 # 员工编号, 多个用逗号隔开,这里的负责是这套代码的负责人 + +运维负责人: DN9460, DN9651, DN4792 # 负责部署、监控、K8S、发布、证书等 + +产品负责人: YY1166 # 项目产品经理 + + +项目成员: DN2185, DN8431 + +项目状态: 维护中 + # 可选值: + # 开发中 / 维护中 / 已停止 / 已归档 + +项目级别: 中 + # 可选值: + # 高 / 中 / 低 , 北斗体系 s和a为 高 b为中 c d为低 + +核心分支: main + # 即部署时候使用的分支,可选值: + # master / main / dev / release + +代码仓库: + ssh://git@bd-git-sh.jsyyds.com:8082/ruisheng/jha-1021-91jhs/backend/jha-1046-hjsq-server.git + +最后审计人: + +最后审计时间: + +告警群: + +备注: + +--- + +# 1. 项目说明 + +- 海角社区 +- app 包含前端API +- web 包含管理后端API + + +# 2. 基础设施与中间件 + +- MongoDB 主从 +- Redis 单节点 +- Elasticsearch 搜索(v7) + + +# 3. 运维部署 + +- Shell脚本部署(scp + supervisorctl) +- Telegram Bot 部署通知 + + +# 4. 风险说明 + +- Redis 热Key风险 + + +# 5. 历史事故(可选) + +- + + +# 6. 交接说明(可选) + +- appid 代码固定:1046 +- ES的索引KEY:webhjsq_video_index_v1 等 +- /wbapi/hjsq api路径及名称 +- ReportAppId 埋点APPID需要注意 +- seoPushEnabled SEO推送开关(刚开始建项目关闭,等上线后看情况开启) diff --git a/pub/adcenter/adcenter.go b/pub/adcenter/adcenter.go new file mode 100644 index 0000000..155586f --- /dev/null +++ b/pub/adcenter/adcenter.go @@ -0,0 +1,298 @@ +package adcenter + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path" + "sort" + "strings" + "time" + "videoweb/pub/common/crypt" + "videoweb/pub/common/godef" + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" + "videoweb/pub/redis" +) + +/* +测试: +部门CODE:1991129769626804224 +商户CODE:1991129808640122880 +应用CODE:jh_appid01 +客户CODE:1991129948159451136 +*/ + +type JtAdvertiseReq struct { + MerchantCode string `json:"merchantCode"` // 商户 code,必填 + AppCode string `json:"appCode"` // 应用 code,必填 + AdStatus int `json:"adStatus"` // 广告状态 1-上架,0-下架,2-未开始,3-已过期,4-即将到期 默认查询全部 + //AdvertiseLocationCode string `json:"advertiseLocationCode,omitempty"` // 广告位唯一标识,选填 + //AdvertiseCode string `json:"advertiseCode,omitempty"` // 广告唯一标识,选填 + //PageNo int `json:"pageNo,omitempty"` // 页码,全量查询不需要传,选填 + //PageSize int `json:"pageSize,omitempty"` // 每页条数,全量查询不需要传,选填 +} + +type JtAdvertiseRes struct { + Code int `json:"code"` // 返回状态码 + Msg string `json:"msg"` // 返回消息 + Data string `json:"data"` // 广告信息 - 加密 +} + +// AdSlot 广告位信息 +type AdSlot struct { + DeptCode string `json:"deptCode"` // 部门 code + MerchantCode string `json:"merchantCode"` // 商户 code + AppCode string `json:"appCode"` // 应用 code + LocationType int `json:"locationType"` // 广告位类型:0 → 开屏广告 1 → 弹窗广告 2 → 图标广告 3 → 九宫格广告 4 → 信息流广告 5 → 瀑布流广告 6 → 详情广告 7 → 片头广告 + AdvertiseLocationCode string `json:"advertiseLocationCode"` // 广告位标识 + AdvertiseLocationName string `json:"advertiseLocationName"` // 广告位名称 + DisplayMode int `json:"displayMode"` // 展示模式:0 → 多个轮播, 1 → 单体, 2 → 三分屏, 3 → 四分屏, 4 → 小图标, 5 → 大图标 + AdvertiseHeight int `json:"advertiseHeight"` // 高 + AdvertiseWidth int `json:"advertiseWidth"` // 宽 + MaterialType int `json:"materialType"` // 素材类型(视频/图片):1 → 视频, 2 → 图片, 3 → 视频、图片 + AdLimit int `json:"adLimit"` // 广告限制数量 + AdDetailInfoList []AdDetailInfo `json:"adDetailInfoList"` // 广告详情信息列表 +} + +// AdDetailInfo 广告详情信息 +type AdDetailInfo struct { + AdvertiseCode string `json:"advertiseCode"` // 广告业务code + CustomerCode string `json:"customerCode"` // 客户code + AdvertiseName string `json:"advertiseName"` // 广告名称 + AdvertiseUrl string `json:"advertiseUrl"` // 广告链接 + AdvertiseIcon string `json:"advertiseIcon"` // 广告图片地址 + AdvertiseType int `json:"advertiseType"` // 广告类型:1 → 播放器, 2 → 药台, 3 → 炮台, 4 → 黄游, 5 → 直播, 6 → BC + AdMode string `json:"adMode"` // 广告模式 + AppCode string `json:"appCode"` // 应用 code + LocationType int `json:"locationType"` // 广告位类型:0 → 开屏广告 1 → 弹窗广告 2 → 图标广告 3 → 九宫格广告 4 → 信息流广告 5 → 瀑布流广告 6 → 详情广告 7 → 片头广告 + AdvertiseDesc string `json:"advertiseDesc"` // 广告描述 + PageType string `json:"pageType"` // 内部路由页面类型:如 rechange_page → 会员充值页, gold_rechange_page → 金币充值页, invite_page → 邀请分享页 + StartTimeStamp int64 `json:"startTimeStamp"` // 广告开始时间 时间戳 + EndTimeStamp int64 `json:"endTimeStamp"` // 广告结束时间 时间戳 + Sort int `json:"sort"` // 排序 + AdExtData string `json:"adExtData"` // 扩展字段1 = CoverImgSize +} +type AdCacheInfo struct { + AdvertiseCode string `json:"advertiseCode"` // 广告业务code + CustomerCode string `json:"customerCode"` // 客户code + AdvertiseName string `json:"advertiseName"` // 广告名称 + AdvertiseUrl string `json:"advertiseUrl"` // 广告链接 + AdvertiseIcon string `json:"advertiseIcon"` // 广告图片地址 + AdvertiseType int `json:"advertiseType"` // 广告类型:1 → 播放器, 2 → 药台, 3 → 炮台, 4 → 黄游, 5 → 直播, 6 → BC + AdMode string `json:"adMode"` // 广告模式 + AppCode string `json:"appCode"` // 应用 code + LocationType int `json:"locationType"` // 广告位类型:0 → 开屏广告 1 → 弹窗广告 2 → 图标广告 3 → 九宫格广告 4 → 信息流广告 5 → 瀑布流广告 6 → 详情广告 7 → 片头广告 + AdvertiseDesc string `json:"advertiseDesc"` // 广告描述 + PageType string `json:"pageType"` // 内部路由页面类型:如 rechange_page → 会员充值页, gold_rechange_page → 金币充值页, invite_page → 邀请分享页 + StartTimeStamp int64 `json:"startTimeStamp"` // 广告开始时间 时间戳 + EndTimeStamp int64 `json:"endTimeStamp"` // 广告结束时间 时间戳 + Sort int `json:"sort"` // 排序 + AdExtData string `json:"adExtData"` // 扩展字段1 = CoverImgSize + DeptCode string `json:"deptCode"` // 部门 code + MerchantCode string `json:"merchantCode"` // 商户 code + AdvertiseLocationCode string `json:"advertiseLocationCode"` // 广告位标识 + AdvertiseLocationName string `json:"advertiseLocationName"` // 广告位名称 + DisplayMode int `json:"displayMode"` // 展示模式:0 → 多个轮播, 1 → 单体, 2 → 三分屏, 3 → 四分屏, 4 → 小图标, 5 → 大图标 + AdvertiseHeight int `json:"advertiseHeight"` // 高 + AdvertiseWidth int `json:"advertiseWidth"` // 宽 + MaterialType int `json:"materialType"` // 素材类型(视频/图片):1 → 视频, 2 → 图片, 3 → 视频、图片 + AdLimit int `json:"adLimit"` // 广告限制数量 +} + +type Extra struct { + ImgType int64 `json:"imgType"` // 图片展示类型 0:应用图标 1.小图标 2.大图标 + DownloadCount int64 `json:"downloadCount"` +} + +func (ad AdDetailInfo) GetExtraData() (res Extra) { + _ = json.Unmarshal([]byte(ad.AdExtData), &res) + return +} + +func (ad AdDetailInfo) GetRealLink() string { + return strings.ReplaceAll(ad.AdvertiseUrl, "inner://yinseinner/", "yinseinner://") +} + +func (ad AdDetailInfo) GetCoverLsj() string { + if ad.AdvertiseIcon == "" { + return "" + } + + return path.Join("laosiji", ad.AdvertiseIcon) +} + +func (ad AdDetailInfo) GetAdvertiseType() int { + // 内部顺序:1 应用 2 游戏 3 炮台 4 棋牌 5 直播 + // 外部顺序:1:应用 2:药台、3:炮台、4:游戏、5:直播、6:棋牌 + if ad.AdvertiseType == 4 { + return 2 + } + if ad.AdvertiseType == 6 { + return 4 + } + + return ad.AdvertiseType +} + +// JtAdvertiseThreeServer 集团广告中心 +func JtAdvertiseThreeServer(apiDomain, merchantCode, appCode, aesKey string) ([]AdSlot, error) { + var foreverCacheKey = fmt.Sprintf("jtAdForever-%s-%s", merchantCode, appCode) + redisKey := fmt.Sprintf("jtAd-%s-%s", merchantCode, appCode) + str, err := redis.Get(redisKey) + if err != nil { + log.Error(fmt.Sprintf("JtAdvertiseThreeServer 缓存获取广告列表信息异常:%v", err)) + } + var resp []AdSlot + if str != nil { + if err = json.Unmarshal([]byte(*str), &resp); err == nil { + return resp, nil + } + log.Error(fmt.Sprintf("JtAdvertiseThreeServer 解析缓存数据异常:%v", err)) + } + req := JtAdvertiseReq{ + MerchantCode: merchantCode, + AppCode: appCode, + AdStatus: 1, + } + + bodyStr, _ := json.Marshal(req) + headers := map[string]string{"Content-Type": "application/json"} + serverResp, err := fetchJtAdvertiseWithFallback(apiDomain, headers, bodyStr) + if err != nil { + return getFromLastTime(foreverCacheKey, err) + } + + adsData, err := crypt.AdDecrypt(serverResp.Data, aesKey) + if err != nil { + log.Error(fmt.Sprintf("JtAdvertiseThreeServer 解密广告数据异常:%v", err)) + return nil, errors.New("response data err") + } + err = json.Unmarshal([]byte(adsData), &resp) + if err != nil { + log.Error(fmt.Sprintf("JtAdvertiseThreeServer 解析 JSON 失败:%v", err)) + return nil, errors.New("data format error") + } + + // 对广告进行升序排序 + for i := range resp { + sort.Slice(resp[i].AdDetailInfoList, func(j, k int) bool { + return resp[i].AdDetailInfoList[j].Sort < resp[i].AdDetailInfoList[k].Sort + }) + } + + // 将最后一次请求成功的数据更新到二级缓存 + godef.Go(func() { + setAdsCache(foreverCacheKey, resp) + }) + // 写入缓存 + godef.Go(func() { + data, err := json.Marshal(&resp) + if err != nil { + log.Error(fmt.Sprintf("JtAdvertiseThreeServer json序列化缓存数据异常:%v", err)) + return + } + if err = redis.Set(redisKey, data, time.Minute); err != nil { + log.Error(fmt.Sprintf("JtAdvertiseThreeServer 保存缓存数据异常:%v", err)) + } + }) + + return resp, nil +} + +var jtAdvertiseFallbackDomains = []string{ + "https://apiad01.cc", + "https://apiad02.cc", + "https://apiad03.cc", + "https://awsapiad01.cc", + "https://d3o2peztwy6jno.cloudfront.net", +} + +func jtAdvertiseRequestDomains(apiDomain string) []string { + seen := make(map[string]struct{}, 1+len(jtAdvertiseFallbackDomains)) + domains := make([]string, 0, 1+len(jtAdvertiseFallbackDomains)) + add := func(domain string) { + domain = strings.TrimRight(strings.TrimSpace(domain), "/") + if domain == "" { + return + } + if _, ok := seen[domain]; ok { + return + } + seen[domain] = struct{}{} + domains = append(domains, domain) + } + add(apiDomain) + for _, domain := range jtAdvertiseFallbackDomains { + add(domain) + } + return domains +} + +func fetchJtAdvertiseWithFallback(apiDomain string, headers map[string]string, body []byte) (*JtAdvertiseRes, error) { + var lastErr error + for _, domain := range jtAdvertiseRequestDomains(apiDomain) { + resp, err := fetchJtAdvertise(domain, headers, body) + if err == nil { + return resp, nil + } + lastErr = err + log.Error("JtAdvertiseThreeServer 请求异常,尝试下一个域名", log.Any("domain", domain), log.E(err)) + } + if lastErr == nil { + lastErr = errors.New("adcenter api domain empty") + } + return nil, lastErr +} + +func fetchJtAdvertise(apiDomain string, headers map[string]string, body []byte) (*JtAdvertiseRes, error) { + url := apiDomain + "/openapi/getAdvertiseList" + var serverResp *JtAdvertiseRes + code, err := httputil.Client().POSTJsonWithJResp(&serverResp, url, headers, body) + if err != nil { + return nil, err + } + if code != http.StatusOK { + return nil, fmt.Errorf("response status %d", code) + } + if serverResp == nil { + return nil, errors.New("empty response") + } + if serverResp.Code != 0 { + return nil, fmt.Errorf("response code %d: %s", serverResp.Code, serverResp.Msg) + } + return serverResp, nil +} + +// getFromLastTime 返回最后一次请求的广告数据 +func getFromLastTime(foreverCacheKey string, e error) ([]AdSlot, error) { + str, err := redis.Get(foreverCacheKey) + if err != nil { + log.Error(fmt.Sprintf("getFromLastTime 缓存获取广告列表信息异常:%v", err)) + return nil, e + } + + var resp []AdSlot + if str != nil { + if err = json.Unmarshal([]byte(*str), &resp); err == nil { + return resp, nil + } + log.Error(fmt.Sprintf("getFromLastTime 解析缓存数据异常:%v", err)) + } + + return nil, e +} + +// setAdsCache 更新最后一次请求的广告数据 +func setAdsCache(foreverCacheKey string, resp []AdSlot) { + data, err := json.Marshal(&resp) + if err != nil { + log.Error(fmt.Sprintf("setAdsCache json序列化缓存数据异常:%v", err)) + return + } + + if err := redis.Set(foreverCacheKey, data, time.Hour*72); err != nil { + log.Error(fmt.Sprintf("setAdsCache 保存缓存数据异常:%v", err)) + } +} diff --git a/pub/common/comm/comm.go b/pub/common/comm/comm.go new file mode 100644 index 0000000..f5e3c65 --- /dev/null +++ b/pub/common/comm/comm.go @@ -0,0 +1,138 @@ +package comm + +import ( + "encoding/json" + "sync" + "time" + "videoweb/pub/common/godef" + "videoweb/pub/common/lock" + "videoweb/pub/common/log" + + "github.com/go-redis/redis" + "go.uber.org/zap" +) + +type MessageType int64 + +const ( + MessageTypeError MessageType = iota // 心跳消息 + MessageTypeAlive + MessageTypeMediaAdd // 添加一个媒体 + MessageTypeMediaDel // 删除一个媒体 + _ // 已移除帖子新增消息,保留原数值 + _ // 已移除帖子删除消息,保留原数值 +) + +type message struct { + Type MessageType + Payload []byte +} + +type OnReceive func(data []byte) + +type Communicate struct { + sendChannel string // 发送消息的频道 + recvChannel string // 接收消息的频道 + client *redis.Client // Redis的客户端 + lock sync.RWMutex + callbacks map[MessageType][]OnReceive + publish *lock.Queue[*message] +} + +func NewCommunicate(client *redis.Client, sendChannel string, recvChannel string) *Communicate { + return &Communicate{ + sendChannel: sendChannel, + recvChannel: recvChannel, + client: client, + callbacks: make(map[MessageType][]OnReceive), + publish: lock.NewQueue[*message](), + } +} + +func (c *Communicate) RegisterReceiver(mType MessageType, receive OnReceive) { + c.lock.Lock() + defer c.lock.Unlock() + c.callbacks[mType] = append(c.callbacks[mType], receive) +} + +func (c *Communicate) findEvent(mType MessageType) []OnReceive { + c.lock.RLock() + defer c.lock.RUnlock() + return c.callbacks[mType] +} + +func (c *Communicate) dispatchMessage(mType MessageType, payload []byte) { + events := c.findEvent(mType) + for _, event := range events { + event(payload) + } +} + +func (c *Communicate) handleRecv(data *redis.Message) { + if data.Channel == "error" { + log.Error("recv from channel %s failed", zap.Any("error", data.Payload)) + return + } + if data.Channel == c.recvChannel { + msg := &message{} + err := json.Unmarshal([]byte(data.Payload), msg) + if err != nil { + log.Error("recv from channel %s failed", zap.Any("error", err)) + } + c.dispatchMessage(msg.Type, msg.Payload) + } +} + +func (c *Communicate) Send(mType MessageType, payload []byte) error { + msg := &message{ + Type: mType, + Payload: payload, + } + c.publish.Enqueue(msg) + return nil +} + +func (c *Communicate) Start() { + // 启动接收线程 + godef.Go(func() { + sub := c.client.Subscribe(c.recvChannel) + defer sub.Close() + // 循环接收消息 + for { + msg, err := sub.ReceiveMessage() + if err != nil { + msg = &redis.Message{ + Channel: "error", + Payload: err.Error(), + } + c.handleRecv(msg) + time.Sleep(time.Second) + } else { + c.handleRecv(msg) + } + } + }) + // 启动发送线程 + godef.Go(func() { + for { + time.Sleep(time.Second) + for !c.publish.IsEmpty() { + msg, ok := c.publish.Dequeue() + if !ok { + break + } + data, err := json.Marshal(msg) + if err != nil { + log.Error("Communicate msg marshal failed", zap.Any("error", err)) + break + } + err = c.client.Publish(c.sendChannel, data).Err() + if err != nil { + c.publish.Enqueue(msg) // 再写回队列 + log.Error("Communicate send msg failed", zap.Any("error", err)) + break + } + } + } + }) +} diff --git a/pub/common/common.go b/pub/common/common.go new file mode 100644 index 0000000..f799130 --- /dev/null +++ b/pub/common/common.go @@ -0,0 +1,184 @@ +package common + +import ( + "errors" + "fmt" + "net/http" + "os" + "time" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + + "strconv" + + "github.com/gin-gonic/gin" +) + +var ( + cryptSecret string + env consts.RunMod + responseDataTransform func(*gin.Context, interface{}) interface{} + //routineCount int64 +) + +// SetResponseDataTransform 设置当前服务的成功响应数据转换函数 +func SetResponseDataTransform(transform func(*gin.Context, interface{}) interface{}) { + responseDataTransform = transform +} + +// InitGinSecret InitResponseParam +func InitGinSecret(secret string, env_ consts.RunMod) { + cryptSecret = secret + env = env_ +} + +// JSONWriterCache 返回数据并标记需要写入缓存 +func JSONWriterCache(c *gin.Context, codeOrErr srvdef.ErrInfoer, data interface{}) { + code := codeOrErr.GetCode() + if code == srvdef.Success && responseDataTransform != nil { + data = responseDataTransform(c, data) + } + tip := codeOrErr.GetTip() + msg := codeOrErr.GetMsg() + var hash bool + if code != srvdef.Success { //发生错误,记录日志 + var version, sysType, devType string + id := GetUserId(c) + ua, _ := GetUA(c) + if ua.Ver != "" { + version = ua.Ver + } + if ua.SysType != "" { + sysType = string(ua.SysType) + } + if devType != "" { + devType = ua.DevType + } + log.Warn("Error:", + log.Any("UID", strconv.FormatInt(id, 10)), + log.Any("IP", c.ClientIP()), + log.Any("Version", version), + log.Any(consts.CtxSysType, sysType), + log.Any("DevType", devType), + log.Any("Router", c.Request.RequestURI), + log.Any("PID", os.Getpid()), + log.Any("Error", data), + log.Any("Code", code)) + } + c.Header("writer-cache", "HIT") + c.JSON(http.StatusOK, gin.H{ + "code": code, + "hash": hash, + "msg": msg, + "tip": tip, + "data": data, + "time": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + }) +} + +// ServerJSON 返回数据并处理多语言 +func ServerJSON(c *gin.Context, codeOrErr srvdef.ErrInfoer, data interface{}) { + code := codeOrErr.GetCode() + if code == srvdef.Success && responseDataTransform != nil { + data = responseDataTransform(c, data) + } + tip := codeOrErr.GetTip() + msg := codeOrErr.GetMsg() + var hash bool + if code != srvdef.Success { //发生错误,记录日志 + var version, sysType, devType string + id := GetUserId(c) + ua, _ := GetUA(c) + if ua.Ver != "" { + version = ua.Ver + } + if ua.SysType != "" { + sysType = string(ua.SysType) + } + if devType != "" { + devType = ua.DevType + } + log.Warn("Error:", + log.Any("UID", strconv.FormatInt(id, 10)), + log.Any("IP", c.ClientIP()), + log.Any("Version", version), + log.Any(consts.CtxSysType, sysType), + log.Any("DevType", devType), + log.Any("Router", c.Request.RequestURI), + log.Any("PID", os.Getpid()), + log.Any("Error", data), + log.Any("Code", code)) + } + c.JSON(http.StatusOK, gin.H{ + "code": code, + "hash": hash, + "msg": msg, + "tip": tip, + "data": data, + "time": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + }) +} + +// GetUID 获取用户uid +// oauth.Auth执行后uid有效 +func GetUID(ctx *gin.Context) (uid uint64, err error) { + val, exists := ctx.Get(consts.CtxUserID) + if !exists { + err = errors.New("userId not exist") + return + } + uid, ok := val.(uint64) + if !ok { + return 0, errors.New("userId type error") + } + return +} + +func GetUserId(ctx *gin.Context) int64 { + return ctx.GetInt64(consts.CtxUserID) +} + +// GetUA +func GetUA(ctx *gin.Context) (u useragent.UA, err error) { + val, exists := ctx.Get(consts.CtxUA) + if !exists { + err = errors.New("user-agent not exists") + return + } + u, ok := val.(useragent.UA) + if !ok { + err = errors.New("user-agent type error") + return + } + return u, nil +} + +// GetIP 获取真实IP +func GetIP(ctx *gin.Context) string { + relIP, exists := ctx.Get(consts.CtxIP) + if !exists { + if relIP == "" { + relIP = ctx.ClientIP() + } + } + v, ok := relIP.(string) + if !ok { + return "" + } + log.Info(fmt.Sprintf("[IP-ROUTER] %s,PID %d", ctx.GetHeader("X-Forwarded-For"), os.Getpid())) + return v +} + +func IsNilOrEmpty(in interface{}) bool { + if in == nil || in == "" { + return true + } + return false +} + +func AlphabeticColl() []string { + return []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", + "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"} +} diff --git a/pub/common/convUtil.go b/pub/common/convUtil.go new file mode 100644 index 0000000..0a99f82 --- /dev/null +++ b/pub/common/convUtil.go @@ -0,0 +1,135 @@ +package common + +import ( + "encoding/json" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ID = primitive.ObjectID + +// ToJsonM 将struct转换为 Json Map +func ToJsonM(obj interface{}) (map[string]interface{}, error) { + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + m := make(map[string]interface{}) + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +func ToBsonM(s interface{}) (bson.M, error) { + data, err := bson.Marshal(s) + if err != nil { + return nil, err + } + m := make(bson.M) + if err := bson.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func MapToJsonString(m map[string]string) (string, error) { + if data, err := json.Marshal(m); err != nil { + return "", err + } else { + return string(data), nil + } +} + +// MapToStruct s is struct ptr +func MapToStruct(s interface{}, m map[string]interface{}) error { + bsonBytes, err := bson.Marshal(m) + if err != nil { + return err + } + return bson.Unmarshal(bsonBytes, s) +} + +// JSONStruct2Map 将struct转换为Map +func JSONStruct2Map(obj interface{}) (map[string]interface{}, error) { + m := make(map[string]interface{}) + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +// Map2JSONStruct 将map转换为Json struct +func Map2JSONStruct(v interface{}, m map[string]interface{}) error { + j, err := json.Marshal(m) + if err != nil { + return err + } + if err := json.Unmarshal(j, v); err != nil { + return err + } + return err +} + +func IDArray(ids []string) ([]ID, error) { + var idArray []ID + for _, id := range ids { + _id, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + idArray = append(idArray, _id) + } + + return idArray, nil +} + +func ObjectIDs2String(obj []primitive.ObjectID) []string { + arr := []string{} + if len(obj) == 0 { + return arr + } + for _, o := range obj { + arr = append(arr, o.Hex()) + } + return arr +} + +func String2ObjectID(obj []string) []primitive.ObjectID { + arr := []primitive.ObjectID{} + for _, o := range obj { + oid, err := primitive.ObjectIDFromHex(o) + if err != nil { + continue + } + arr = append(arr, oid) + } + return arr +} + +// PercentOf - calculate what percent [number1] is of [number2]. +// ex. 300 is 12.5% of 2400 +func PercentOf(part int, total int) float64 { + return (float64(part) * float64(100)) / float64(total) +} + +func MergeMap(src, dest map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return dest + } + if len(dest) == 0 { + return src + } + if len(src) == 0 && len(dest) == 0 { + return make(map[string]interface{}) + } + for k, v := range dest { + src[k] = v + } + return src +} diff --git a/pub/common/cronmgr/crommgr.go b/pub/common/cronmgr/crommgr.go new file mode 100644 index 0000000..2494c63 --- /dev/null +++ b/pub/common/cronmgr/crommgr.go @@ -0,0 +1,119 @@ +package cronmgr + +import ( + "context" + "errors" + "sync" + "videoweb/pub/common/log" + + "github.com/robfig/cron/v3" +) + +var c *cron.Cron + +// runIngs 正在运行中的任务 +var runIngs map[*context.Context]struct{} +var r_lock sync.Mutex + +// RegisterCron 注册一个定时任务 +func RegisterCron(spec string, cmd func(), errfunc func(err interface{})) (cron.EntryID, error) { + if len(spec) == 0 || cmd == nil { + return -1, errors.New("spec or cmd is nil") + } + return registerCron(spec, func() { + defer func() { + if err := recover(); err != nil { + if errfunc != nil { + errfunc(err) + } + log.Errorf("cron task error.%v", err) + } + }() + cmd() + }) +} + +// RegisterCronAsyncOne 注册一个协程执行的定时任务 限制了cmd同时只有一个协程在执行 +func RegisterCronAsyncOne(spec string, cmd func(), errfunc func(err interface{})) (cron.EntryID, error) { + if len(spec) == 0 || cmd == nil { + return -1, errors.New("spec or cmd is nil") + } + ctx := context.Background() + return registerCron(spec, func() { + go func(c *context.Context) { + defer func() { + if err := recover(); err != nil { + if errfunc != nil { + errfunc(err) + } + log.Errorf("cron task error.%v", err) + } + }() + if taskRunIng(c) { + cmd() + taskRunEnd(c) + } else { + log.Warnf("Limits cmd to only one coroutine executing at the same time") + } + }(&ctx) + }) +} + +// RegisterCronAsync 注册一个协程执行的定时任务 +func RegisterCronAsync(spec string, cmd func(), errfunc func(err interface{})) (cron.EntryID, error) { + if len(spec) == 0 || cmd == nil { + return -1, errors.New("spec or cmd is nil") + } + return registerCron(spec, func() { + go func() { + defer func() { + if err := recover(); err != nil { + if errfunc != nil { + errfunc(err) + } + log.Errorf("cron task error.%v", err) + } + }() + cmd() + }() + }) +} +func registerCron(spec string, cmd func()) (cron.EntryID, error) { + if c == nil { + c = cron.New(cron.WithSeconds()) + runIngs = make(map[*context.Context]struct{}) + } + id, err := c.AddFunc(spec, cmd) + if err != nil { + log.Warnf("add cron [%s] fail", spec) + return -1, err + } + c.Start() + log.Infof("add cron [%d] spec:[%s].", id, spec) + return id, nil +} + +// UnregisterCron 注销一个定时任务 +func UnregisterCron(id cron.EntryID) bool { + if id > 0 { + c.Remove(id) + log.Infof("remove cron id:[%d]", id) + } + log.Infof("remove cron [%d] not found", id) + return false +} + +func taskRunIng(ctx *context.Context) bool { + r_lock.Lock() + defer r_lock.Unlock() + if _, ok := runIngs[ctx]; ok { + return false + } + runIngs[ctx] = struct{}{} + return true +} +func taskRunEnd(ctx *context.Context) { + r_lock.Lock() + defer r_lock.Unlock() + delete(runIngs, ctx) +} diff --git a/pub/common/cronx/cron.go b/pub/common/cronx/cron.go new file mode 100644 index 0000000..5228ccf --- /dev/null +++ b/pub/common/cronx/cron.go @@ -0,0 +1,148 @@ +package cronx + +import ( + "context" + "sync" + "time" + "videoweb/pub/common/log" + + "github.com/robfig/cron/v3" + "go.uber.org/zap" +) + +type CronJob interface { + Run() error + Desc() string + Name() string +} + +type CronJobAndSpec struct { + CronJob CronJob + Spec string +} + +type BaseJob struct { + ctx context.Context + spec string + name string + desc string + l *zap.SugaredLogger +} + +type cronFunc struct { + *BaseJob + fn func(ctx context.Context, l *zap.SugaredLogger) error +} + +func addError(fn func(ctx context.Context, l *zap.SugaredLogger)) func(context.Context, *zap.SugaredLogger) error { + return func(ctx context.Context, l *zap.SugaredLogger) error { + l.Info("start") + defer l.Info("end") + fn(ctx, l) + return nil + } +} + +func (f cronFunc) Run() error { return f.fn(f.ctx, f.l) } +func NewCronFunc(ctx context.Context, name, desc string, fn func(ctx context.Context, l *zap.SugaredLogger)) cronFunc { + return cronFunc{ + BaseJob: NewBaseJob(ctx, name, desc), + fn: addError(fn), + } +} + +func NewCronFuncWithError(ctx context.Context, name, desc string, fn func(ctx context.Context, l *zap.SugaredLogger) error) cronFunc { + return cronFunc{ + BaseJob: NewBaseJob(ctx, name, desc), + fn: fn, + } +} + +func NewBaseJob(ctx context.Context, name, desc string) *BaseJob { + return &BaseJob{ + ctx: ctx, + name: name, + desc: desc, + l: log.ZapLogS.Named(name), + } +} + +func (j *BaseJob) Name() string { return j.name } +func (j *BaseJob) Desc() string { return j.desc } +func (j *BaseJob) L() *zap.SugaredLogger { return j.l } +func (j *BaseJob) Context() context.Context { return j.ctx } + +type CronLogger struct { + l *zap.SugaredLogger +} + +func NewCronLogger() *CronLogger { + return &CronLogger{ + l: log.ZapLogS.Named("cron"), + } +} + +func (l *CronLogger) Info(msg string, kvs ...interface{}) { + l.l.Infow(msg, kvs) +} +func (l *CronLogger) Error(err error, msg string, kvs ...interface{}) { + l.l.Errorw(err.Error()+msg, kvs) +} + +func StartJobs(jobs ...CronJobAndSpec) (closer func()) { + var once sync.Once + l := NewCronLogger() + c := cron.New(cron.WithSeconds(), cron.WithLocation(time.Local), cron.WithChain(cron.Recover(l))) + for i := range jobs { + j := jobs[i].CronJob + spec := jobs[i].Spec + name := j.Name() + _, err := c.AddFunc(spec, func() { + defer log.Infof("%s end", name) + log.Infof("%s start", name) + if err := j.Run(); err != nil { + log.Errorf("%s run failed: %s", name, err.Error()) + } + }) + if err != nil { + log.Fatalf("%s add failed: %s", err) + } + } + c.Start() + return func() { + once.Do(func() { + c.Stop() + }) + } +} + +type CronFuncType func(ctx context.Context, l *zap.SugaredLogger) + +func TraceTime(fn CronFuncType) CronFuncType { + return func(ctx context.Context, l *zap.SugaredLogger) { + now := time.Now() + defer func() { + elapse := time.Since(now) + if elapse > time.Second { + l.Infof("takes %s", elapse.Truncate(time.Millisecond)) + } else { + l.Infof("takes %d ms", elapse/time.Millisecond) + } + }() + l.Info("start run") + fn(ctx, l) + l.Info("end run") + } +} + +// MustSecondSpec is a helper method to validate spec at compile time +func MustSecondSpec(spec string) string { + parser := cron.NewParser( + cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, + ) + _, err := parser.Parse(spec) + if err != nil { + panic(err) + } + return spec +} diff --git a/pub/common/crypt/aesecb.go b/pub/common/crypt/aesecb.go new file mode 100644 index 0000000..a75f817 --- /dev/null +++ b/pub/common/crypt/aesecb.go @@ -0,0 +1,43 @@ +package crypt + +import ( + "bytes" + "crypto/aes" +) + +func PKCS7PaddingEx(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padtext...) +} + +func PKCS7UnPaddingEx(origData []byte) []byte { + length := len(origData) + unpadding := int(origData[length-1]) + return origData[:(length - unpadding)] +} + +func EcbDecrypt(data, key []byte) []byte { + block, _ := aes.NewCipher(key) + decrypted := make([]byte, len(data)) + size := block.BlockSize() + + for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size { + block.Decrypt(decrypted[bs:be], data[bs:be]) + } + + return PKCS7UnPaddingEx(decrypted) +} + +func EcbEncrypt(data, key []byte) []byte { + block, _ := aes.NewCipher(key) + data = PKCS7PaddingEx(data, block.BlockSize()) + decrypted := make([]byte, len(data)) + size := block.BlockSize() + + for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size { + block.Encrypt(decrypted[bs:be], data[bs:be]) + } + + return decrypted +} diff --git a/pub/common/crypt/crypt.go b/pub/common/crypt/crypt.go new file mode 100644 index 0000000..c7f4f71 --- /dev/null +++ b/pub/common/crypt/crypt.go @@ -0,0 +1,615 @@ +package crypt + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "io" + "net/url" + "os" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/dgrijalva/jwt-go" +) + +// CreateToken 生成Token算法 +func CreateToken(secret string, tokenClaims map[string]interface{}) (tokeness string, err error) { + if secret == "" { + return "", errors.New("secret is empty") + } + claims := jwt.MapClaims(tokenClaims) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokeness, err = token.SignedString([]byte(secret)) + if err != nil { + return "", err + } + return tokeness, nil +} + +func keyFunc(secret string) jwt.Keyfunc { + return func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + } +} + +// ParseToken 解析Token +func ParseToken(secret string, tokeness string) (map[string]interface{}, error) { + if secret == "" || tokeness == "" { + return nil, errors.New("secret or tokeness is empty") + } + token, err := jwt.Parse(tokeness, keyFunc(secret)) + if err != nil { + return nil, err + } + //验证token,如果token被修改过则为false + if !token.Valid { + return nil, errors.New("token is invalid") + } + claimsToken, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("cannot convert claim to MapClaim") + } + return claimsToken, nil +} + +// StructToStr 结构体转json 先对结构体按照字典顺序排序 并返回字符串 +func StructToStr(obj interface{}) (string, error) { + if obj == nil { + return "", errors.New("obj is not nil") + } + data := make(map[string]interface{}) + bytes, err := json.Marshal(obj) + if err != nil { + return "", err + } + err = json.Unmarshal(bytes, &data) + if err != nil { + return "", err + } + str, err := MapToStr(data) + if err != nil { + return "", err + } + return str, nil +} + +// MapToStr 对map按照字典顺序排序 并返回字符串 +func MapToStr(data map[string]interface{}) (string, error) { + if data == nil || len(data) == 0 { + return "", errors.New("data is nil or empty") + } + newData := make(map[string]interface{}) + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + newData[k] = data[k] + } + b, err := json.Marshal(newData) + if err != nil { + return "", err + } + str := string(b) + return str, nil +} + +// JSON2Struct json转struct +func JSON2Struct(str string, obj interface{}) { + if err := json.Unmarshal([]byte(str), &obj); err != nil { + return + } +} + +// JSONArray2Struct json转struct +func JSONArray2Struct(str []string, obj interface{}) []interface{} { + var data []interface{} + for _, v := range str { + JSON2Struct(v, &obj) + data = append(data, obj) + } + return data +} + +// JSON2Map json转map +func JSON2Map(str string) (map[string]interface{}, error) { + var mapResult map[string]interface{} + if err := json.Unmarshal([]byte(str), &mapResult); err != nil { + return nil, err + } + return mapResult, nil +} + +// StructToStrNormal 结构体转str +func StructToStrNormal(obj interface{}) (string, error) { + str, err := json.Marshal(obj) + if err != nil { + return "", err + } + return string(str), nil +} + +func UrlValueToStr(values url.Values, exclude string) (string, string) { + if values == nil { + return "", "" + } + var buf bytes.Buffer + var sign string + keys := make([]string, 0, len(values)) + for k, _ := range values { + if k == exclude { + sign = strings.Join(values[k], "") + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i != 0 { + buf.WriteString("&") + } + buf.WriteString(k) + buf.WriteString("=") + buf.WriteString(strings.Join(values[k], "")) + } + return buf.String(), sign +} + +// 对参数进行排序后 拼接成url字符串 只对一级字符串类型的参数做处理 +func MapToUrlOnlyStr(data map[string]interface{}) string { + if data == nil || len(data) == 0 { + return "" + } + var buf bytes.Buffer + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i != 0 { + buf.WriteString("&") + } + buf.WriteString(k) + buf.WriteString("=") + if v, ok := data[k].(string); ok { + buf.WriteString(v) + } + } + return buf.String() +} + +// MapToStr 对map按照字典顺序排序 并返回字符串url 拼接的字段 +func MapToUrlStr(data map[string]interface{}) string { + if data == nil || len(data) == 0 { + return "" + } + var buf bytes.Buffer + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i != 0 { + buf.WriteString("&") + } + buf.WriteString(k) + buf.WriteString("=") + if v, ok := data[k].(string); ok { + buf.WriteString(v) + } else { + t := reflect.TypeOf(data[k]) + if t != nil && t.Kind() == reflect.Map { + if v, ok := data[k].(map[string]interface{}); ok { + buf.WriteString("{") + str := MapToUrlStr(v) + buf.WriteString(str) + buf.WriteString("}") + } + } + if t != nil && (t.Kind() == reflect.Slice || t.Kind() == reflect.Array) { + arraybyte, _ := json.Marshal(data[k]) + buf.WriteString(string(arraybyte)) + } + } + } + return buf.String() +} + +// MapToURL 集合转URL +func MapToURL(m map[string]string) string { + if m == nil || len(m) == 0 { + return "" + } + v := url.Values{} + for k := range m { + v.Set(k, string(m[k])) + } + return v.Encode() +} + +func Strcut2UrlValue(obj interface{}) (string, error) { + t := reflect.TypeOf(obj) + v := reflect.ValueOf(obj) + if t.Kind() != reflect.Struct { + return "", errors.New("obj must be struct type") + } + u := url.Values{} + for i := 0; i < t.NumField(); i++ { + var value string + tag := t.Field(i).Tag.Get("json") + switch t.Field(i).Type.Kind() { + case reflect.String: + value = v.Field(i).String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + value = strconv.Itoa(int(v.Field(i).Int())) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + value = strconv.FormatUint(v.Field(i).Uint(), 10) + case reflect.Slice, reflect.Array: + buf := bytes.Buffer{} + b, _ := json.Marshal(v.Field(i).Interface()) + t := strings.TrimRight(strings.TrimLeft(string(b), "["), "]") + array := strings.Split(t, ",") + if len(array) == 0 { + value = "" + } + if len(array) == 1 { + value = array[0] + } else { + for i, v := range array { + if i == 0 { + buf.WriteString(v) + continue + } + buf.WriteString("&") + buf.WriteString(tag) + buf.WriteString("=") + buf.WriteString(v) + value = buf.String() + } + } + } + u.Add(tag, value) + } + dcodeurl, err := url.QueryUnescape(u.Encode()) + return dcodeurl, err +} + +// StrToMd5 字符串转md5 +func StrToMd5(str string) string { + data := []byte(str) + return ByteToMd5(data) +} + +// ByteToMd5 数组转md5 +func ByteToMd5(data []byte) string { + md5Ctx := md5.New() + md5Ctx.Write(data) + cipherStr := md5Ctx.Sum(nil) + return hex.EncodeToString(cipherStr) +} + +// FileToMd5 文件转MD5 +func FileToMd5(fileName string) string { + file, err := os.Open(fileName) + if err != nil { + return "" + } + defer file.Close() + md5Ctx := md5.New() + if _, err := io.Copy(md5Ctx, file); err != nil { + return "" + } + cipherStr := md5Ctx.Sum(nil) + return hex.EncodeToString(cipherStr) +} + +// HashCode 对一个字符串生成唯一的hasHcode 码 +func HashCode(src string) int { + v := int(crc32.ChecksumIEEE([]byte(src))) + if v > 0 { + return v + } + if -v > 0 { + return -v + } + return 0 +} + +// HexToString converts +func HexToString(data []byte) string { + return hex.EncodeToString(data) +} + +// StrToSha256 字符串转sha256 +func StrToSha256(str string) string { + shaCtx := sha256.New() + shaCtx.Write([]byte(str)) + shaBytes := shaCtx.Sum(nil) + cipherStr := hex.EncodeToString(shaBytes[:]) + return cipherStr +} + +// StrToSha1 字符串转sha1 +func StrToSha1(str string, secret string) []byte { + h := hmac.New(sha1.New, []byte(secret)) + h.Write([]byte(str)) + return h.Sum(nil) +} + +// StrToSha1 字符串转sha1 转16进制后 +func StrToHmacSha1(str, secret string) string { + return hex.EncodeToString(StrToSha1(str, secret)) +} + +// StrToHmacSha256 加密 字符串转sha256加密字符串 +func StrToHmacSha256(str string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(str)) + cipherStr := hex.EncodeToString(h.Sum(nil)) + return cipherStr +} + +func PKCS7Padding(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padtext...) +} + +func PKCS7UnPadding(origData []byte) []byte { + length := len(origData) + unpadding := int(origData[length-1]) + return origData[:(length - unpadding)] +} + +// AesEncrypt AES加密 +func AesEncrypt(origData, key string) ([]byte, error) { + origDataByte := []byte(origData) + return CoreAesEncrypt(origDataByte, key) +} + +// AesDecrypt AES解密 +func AesDecrypt(crypted, key string) (string, error) { + cryptedByte := []byte(crypted) + return CoreAesDecrypt(cryptedByte, key) +} + +// AesDecrypt AES解密 +func AesDecryptByte(cryptedByte []byte, key string) (string, error) { + return CoreAesDecrypt(cryptedByte, key) +} + +// CoreAesEncrypt AES加密 +func CoreAesEncrypt(origData []byte, key string) ([]byte, error) { + keybyte := []byte(key) + block, err := aes.NewCipher(keybyte) + if err != nil { + return nil, err + } + blockSize := block.BlockSize() + origData = PKCS7Padding(origData, blockSize) + blockMode := cipher.NewCBCEncrypter(block, keybyte[:blockSize]) + crypted := make([]byte, len(origData)) + blockMode.CryptBlocks(crypted, origData) + return crypted, nil +} + +// CoreAesDecrypt AES解密 +func CoreAesDecrypt(crypted []byte, key string) (string, error) { + keybyte := []byte(key) + block, err := aes.NewCipher(keybyte) + if err != nil { + return "", err + } + blockSize := block.BlockSize() + blockMode := cipher.NewCBCDecrypter(block, keybyte[:blockSize]) + origData := make([]byte, len(crypted)) + blockMode.CryptBlocks(origData, crypted) + origData = PKCS7UnPadding(origData) + return string(origData), nil +} + +// XorEnc +func XorEnc(src string, xorKey string) string { + var result string + j := 0 + bt := []rune(src) + xor := []rune(xorKey) + for i := 0; i < len(bt); i++ { + s := strconv.FormatInt(int64(bt[i]^xor[j]), 10) + result = result + s + j = 1 % len(xor) + } + return result +} + +// XorDec +func XorDec(src string, xorKey string) string { + var result string + j := 0 + bt := []rune(src) + xor := []rune(xorKey) + for i := 0; i < len(bt); i++ { + s := strconv.FormatInt(int64(bt[i]^xor[j]), 10) + result = result + s + j = i % len(xor) + } + return result +} + +func XorDecode(src, key string) string { + srcByte, _ := base64.StdEncoding.DecodeString(src) + keyByte, _ := base64.StdEncoding.DecodeString(key) + srcByte = xor(srcByte, keyByte) + return base64.StdEncoding.EncodeToString(srcByte) +} + +func XorEncode(src, key string) string { + return XorDecode(src, key) +} + +func xor(src []byte, key []byte) []byte { + for i := 0; i < len(src); i++ { + src[i] ^= key[i%len(key)] + } + return src +} + +func XORLength(src []byte, key []byte, length int) []byte { + for i := 0; i < length; i++ { + src[i] ^= key[i%len(key)] + } + return src[:length] +} + +// https://core.telegram.org/api/end-to-end#sending-and-receiving-messages-in-a-secret-chat +func CoreAesEncryptEx(plain []byte, nonceLen int, key string) ([]byte, error) { + nonce := make([]byte, nonceLen) + _, err := io.ReadFull(rand.Reader, nonce) + if err != nil { + return nil, err + } + var largeShaRaw []byte + largeShaRaw = append(largeShaRaw, []byte(key)...) + largeShaRaw = append(largeShaRaw, nonce...) + largeShaRawMid := len(largeShaRaw) / 2 + msgKeyLarge := sha256.Sum256(largeShaRaw) + msgKey := msgKeyLarge[8:24] //16 bytes + + var shaRawA []byte + shaRawA = append(shaRawA, msgKey...) + shaRawA = append(shaRawA, largeShaRaw[:largeShaRawMid]...) + sha256a := sha256.Sum256(shaRawA) //32 bytes + + var shaRawB []byte + shaRawB = append(shaRawB, largeShaRaw[largeShaRawMid:]...) + shaRawB = append(shaRawB, msgKey...) + sha256b := sha256.Sum256(shaRawB) //32 bytes + + var aesKey []byte //32 bytes AES-256 + aesKey = append(aesKey, sha256a[:8]...) //a: 8 bytes + aesKey = append(aesKey, sha256b[8:24]...) //b: 16 bytes + aesKey = append(aesKey, sha256a[24:32]...) //a: 8 bytes + + var aesIV []byte // 16 bytes + aesIV = append(aesIV, sha256b[:4]...) //b: 4 bytes + aesIV = append(aesIV, sha256a[12:20]...) //a: 8 bytes + aesIV = append(aesIV, sha256b[28:]...) //b: 4 bytes + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + blockSize := block.BlockSize() + ciphertext := PKCS7Padding(plain, blockSize) + mode := cipher.NewCBCEncrypter(block, aesIV) + cipher := make([]byte, nonceLen+len(ciphertext)) + copy(cipher, nonce) + mode.CryptBlocks(cipher[nonceLen:], ciphertext) + return cipher, nil +} + +func CoreAesDecryptEx(crypt []byte, nonceLen int, key string) ([]byte, error) { + nonce := make([]byte, nonceLen) + copy(nonce, crypt) + var largeShaRaw []byte + largeShaRaw = append(largeShaRaw, []byte(key)...) + largeShaRaw = append(largeShaRaw, nonce...) + largeShaRawMid := len(largeShaRaw) / 2 + msgKeyLarge := sha256.Sum256(largeShaRaw) + msgKey := msgKeyLarge[8:24] //16 bytes + + var shaRawA []byte + shaRawA = append(shaRawA, msgKey...) + shaRawA = append(shaRawA, largeShaRaw[:largeShaRawMid]...) + sha256a := sha256.Sum256(shaRawA) //32 bytes + + var shaRawB []byte + shaRawB = append(shaRawB, largeShaRaw[largeShaRawMid:]...) + shaRawB = append(shaRawB, msgKey...) + sha256b := sha256.Sum256(shaRawB) //32 bytes + + var aesKey []byte //32 bytes AES-256 + aesKey = append(aesKey, sha256a[:8]...) //a: 8 bytes + aesKey = append(aesKey, sha256b[8:24]...) //b: 16 bytes + aesKey = append(aesKey, sha256a[24:32]...) //a: 8 bytes + + var aesIV []byte // 16 bytes + aesIV = append(aesIV, sha256b[:4]...) //b: 4 bytes + aesIV = append(aesIV, sha256a[12:20]...) //a: 8 bytes + aesIV = append(aesIV, sha256b[28:]...) //b: 4 bytes + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + realData := crypt[nonceLen:] + blockMode := cipher.NewCBCDecrypter(block, aesIV) + origData := make([]byte, len(realData)) + blockMode.CryptBlocks(origData, realData) + return PKCS7UnPadding(origData), nil +} + +// base64补全 +func padding(origin string) string { + missing := len(origin) % 4 + if missing != 0 { + origin += strings.Repeat("=", 4-missing) + } + + return origin +} + +// AdDecrypt 集团广告中心-广告数据解密专用 +func AdDecrypt(encryptedData, keyBase64 string) (res string, err error) { + // 1. Base64 解码获取密钥 + keyBytes, err := base64.StdEncoding.DecodeString(padding(keyBase64)) + if err != nil { + return "", fmt.Errorf("密钥 Base64 解码失败: %v", err) + } + // 2. Base64 解码获取加密数据 + data, err := base64.StdEncoding.DecodeString(padding(encryptedData)) + if err != nil { + return "", fmt.Errorf("数据 Base64 解码失败: %v", err) + } + + // 3. 拆分 IV 与密文(前 12 字节为 IV) + if len(data) < 12 { + return "", errors.New("数据长度不足,无法提取 IV") + } + iv := data[:12] + ciphertext := data[12:] + // 4. 初始化 AES 密码块 + block, err := aes.NewCipher(keyBytes) + if err != nil { + return "", fmt.Errorf("创建 AES 密码块失败: %v", err) + } + // 5. 采用 GCM 模式 + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("创建 GCM 失败: %v", err) + } + // 6. 解密数据 + plainText, err := aesGCM.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("解密失败: %v", err) + } + + return string(plainText), nil +} diff --git a/pub/common/crypt/crypt_test.go b/pub/common/crypt/crypt_test.go new file mode 100644 index 0000000..f3b5c6b --- /dev/null +++ b/pub/common/crypt/crypt_test.go @@ -0,0 +1,27 @@ +package crypt + +import ( + "testing" + "videoweb/pub/common/log" +) + +var ( + PlatPublicKey = ` +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCWtWnWmTgLemO+GBMWIX9N5yx2 +YMc+4MczBogNdTFjF4rrd/+Y1G9qJ8ZDHR7qjBQgJjzcZrGYRUf2wiY6T9ble9+h +s14pYPpXqkSoD2VOQI4+idb6nRgVQ6rXyMQps7ORcshmEtIS/Ra3aG3XaAFAUCfO +E3nHGyldtNIGvuBTAQIDAQAB +-----END PUBLIC KEY-----` +) + +func TestRSA(t *testing.T) { + //data := make([]byte, 0) + testData := "是对的发送打赏打赏打赏打赏的" + rawContext, err := RSAEncryptByPublicKey(PlatPublicKey, []byte(testData)) + if err != nil { + log.Error("wnth RSAEncryptByPublicKey err", log.Any("err", err)) + return + } + log.Info(string(rawContext)) +} diff --git a/pub/common/crypt/rsa.go b/pub/common/crypt/rsa.go new file mode 100644 index 0000000..0ae93f7 --- /dev/null +++ b/pub/common/crypt/rsa.go @@ -0,0 +1,145 @@ +package crypt + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "videoweb/pub/common/log" +) + +func splitBytesSlice(data []byte, n int) [][]byte { + var chunk []byte + chunks := make([][]byte, 0, len(data)/n+1) + for len(data) >= n { + chunk, data = data[:n], data[n:] + chunks = append(chunks, chunk) + } + if len(data) > 0 { + chunks = append(chunks, data[:]) + } + return chunks +} + +func rsaBytesToPublicKey(pub []byte) (*rsa.PublicKey, error) { + // fmt.Printf("pubKey is %s", string(pub)) + block, _ := pem.Decode(pub) + enc := x509.IsEncryptedPEMBlock(block) + b := block.Bytes + var err error + if enc { + b, err = x509.DecryptPEMBlock(block, nil) + if err != nil { + log.Error("RSABytesToPublicKey DecryptPEMBlock err", log.E(err)) + return nil, err + } + } + + pk, err := x509.ParsePKIXPublicKey(b) + if err != nil { + log.Error("RSABytesToPublicKey ParsePKIXPublicKey err", log.E(err)) + return nil, err + } + if pk_, ok := pk.(*rsa.PublicKey); ok { + return pk_, nil + } + log.Error("RSABytesToPublicKey error input not PublicKey") + return nil, errors.New("Not RSA PublicKey format") +} + +func rsaBytesToPrivateKey(priv []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(priv) + enc := x509.IsEncryptedPEMBlock(block) + b := block.Bytes + var err error + if enc { + b, err = x509.DecryptPEMBlock(block, nil) + if err != nil { + log.Error("RSABytesToPrivateKey DecryptPEMBlock err", log.E(err)) + return nil, err + } + } + key, err := x509.ParsePKCS8PrivateKey(b) + if err != nil { + log.Error("RSABytesToPrivateKey ParsePKCS8PrivateKey err", log.E(err)) + return nil, err + } + if pk_, ok := key.(*rsa.PrivateKey); ok { + return pk_, nil + } + log.Error("RSABytesToPrivateKey error input not PrivateKey") + return nil, errors.New("Not RSA PrivateKey format") +} + +// 公钥加密 +func RSAEncryptByPublicKey(pemPubKey string, data []byte) ([]byte, error) { + pubKey, err := rsaBytesToPublicKey([]byte(pemPubKey)) + if err != nil { + log.Error("RSAEncryptByPublicKey rsaBytesToPublicKey err", log.E(err)) + return nil, err + } + partLen := pubKey.N.BitLen()/8 - 11 + chunks := splitBytesSlice(data, partLen) + result := bytes.Buffer{} + for _, chunk := range chunks { + cipherSilce, err := rsa.EncryptPKCS1v15(rand.Reader, pubKey, chunk) + if err != nil { + log.Error("RSAEncryptByPublicKey EncryptPKCS1v15 err", log.E(err)) + return nil, err + } + result.Write(cipherSilce) + } + return result.Bytes(), nil +} + +// 私玥签名 +func RSASignByPrivateKey(pemPrivateKey string, data []byte, hash crypto.Hash) ([]byte, error) { + privateKey, err := rsaBytesToPrivateKey([]byte(pemPrivateKey)) + if err != nil { + log.Error("RSASignByPrivateKey rsaBytesToPrivateKey err", log.E(err)) + return nil, err + } + h := hash.New() + h.Write(data) + hashed := make([]byte, 0) + hashed = h.Sum(hashed) + + return rsa.SignPKCS1v15(rand.Reader, privateKey, hash, hashed) + +} +func RSAVerifyByPublicKey(publicKey string, data, sign []byte, hash crypto.Hash) (err error) { + pubKey, err := rsaBytesToPublicKey([]byte(publicKey)) + if err != nil { + log.Error("RSAEncryptByPublicKey rsaBytesToPublicKey err", log.E(err)) + return err + } + h := hash.New() + h.Write(data) + hashed := make([]byte, 0) + hashed = h.Sum(hashed) + return rsa.VerifyPKCS1v15(pubKey, hash, hashed, sign) +} + +// 公钥解密 +func RSADecryptByPrivateKey(pemPrivateKey string, cipherData []byte) ([]byte, error) { + privateKey, err := rsaBytesToPrivateKey([]byte(pemPrivateKey)) + if err != nil { + log.Error("RSADecryptByPrivateKey rsaBytesToPrivateKey err", log.E(err)) + return nil, err + } + blockLen := privateKey.N.BitLen() / 8 + chunks := splitBytesSlice(cipherData, blockLen) + result := bytes.Buffer{} + for _, chunk := range chunks { + plain, err := rsa.DecryptPKCS1v15(rand.Reader, privateKey, chunk) + if err != nil { + log.Error("RSADecryptByPrivateKey DecryptPKCS1v15 err", log.E(err)) + return nil, err + } + result.Write(plain) + } + return result.Bytes(), nil +} diff --git a/pub/common/enum/consts/consts.go b/pub/common/enum/consts/consts.go new file mode 100644 index 0000000..13cdd03 --- /dev/null +++ b/pub/common/enum/consts/consts.go @@ -0,0 +1,126 @@ +package consts + +import ( + "strings" + "time" +) + +type RunMod string + +const ( + RunModeDev RunMod = "dev" + RunModeTest RunMod = "test" + RunModeProd RunMod = "prod" + RunModRelease RunMod = "release" +) + +// gin中ctx使用的key +const ( + CtxUserID = "USER_ID" + CtxUpUserID = "UP_USER_ID" + CtxUpInfo = "UP_INFO" + CtxUA = "UA" + CtxUAH5 = "UAH5" + CtxIP = "IP" + CtxDevId = "DEVID" + CtxDevType = "DEVTYPE" + CtxSysType = "SYSTYPE" + CtxVersion = "VERSION" + CtxBuildId = "BUILDID" + CtxToken = "TOKEN" + CtxOnlineTime = "ONLINE_TIME" +) + +const ( + AppSysType_And = "" + AppSysType_Ios = "" +) + +// Etcd 的Key +const ( + EtcdWarmupDB = "/micro/config/com.sz.api.server/warmupdb" + EtcdSzDB = "/micro/config/com.sz.api.server/db" + EtcdRds = "/micro/config/com.sz.api.server/redis" +) + +const ( + AdminJwtTokenExpire = 30 * 24 * time.Hour + UserJwtTokenExpire = 30 * 24 * time.Hour + UserSignInExpire = 60 * 24 * time.Hour +) + +const ( + UAHeader = "User-Agent" +) + +const ( + IOS = "ios" + Android = "android" + H5 = "h5" + PC = "pc" + H5IOS = "h5_ios" + H5Android = "h5_android" + H5PC = "h5_pc" + Light = "light" +) + +const ( + CaptchaLen = 6 //验证码长度 +) + +const ( + TypeIOS = "ios" + TypeAndroid = "and" + TypeWeb = "h5" +) + +// 机型 +const ( + WebTypeIOS = "ios" + WebTypeAndroid = "android" + WebSysTypeH5 = "h5" +) + +// func HandleSysType(sysType string) (sys string) { +// if strings.Contains(strings.ToLower(sysType), TypeIOS) { +// sys = IOS +// } +// +// if strings.Contains(strings.ToLower(sysType), TypeAndroid) { +// sys = Android +// } +// +// if strings.Contains(strings.ToLower(sysType), TypeWeb) { +// sys = H5 +// } +// return +// } + +func ToWebSysType(sysType string) (sys string) { + if strings.Contains(strings.ToLower(sysType), TypeIOS) { + sys = WebTypeIOS + } + + if strings.Contains(strings.ToLower(sysType), TypeAndroid) { + sys = WebTypeAndroid + } + + if strings.Contains(strings.ToLower(sysType), TypeWeb) { + sys = WebSysTypeH5 + } + return +} +func ToNewSysType(sysType string) (sys string) { + if strings.Contains(strings.ToLower(sysType), TypeIOS) { + sys = WebTypeIOS + } + + if strings.Contains(strings.ToLower(sysType), TypeAndroid) { + sys = WebTypeAndroid + } + + if strings.Contains(strings.ToLower(sysType), TypeWeb) { + sys = WebSysTypeH5 + } + return +} \ No newline at end of file diff --git a/pub/common/enum/redisconst/const.go b/pub/common/enum/redisconst/const.go new file mode 100644 index 0000000..2286237 --- /dev/null +++ b/pub/common/enum/redisconst/const.go @@ -0,0 +1,226 @@ +package redisconst + +import ( + "fmt" + "strings" + "time" + "videoweb/pub/pubdef" +) + +const ( + visitLogFmt = "bd_visitlog:%s:%d" + FiveMVisit = "bd_visitFmt:%s" + BloomUser = "bd_bloom_user:%d" + AppUserToken = "bd_app_user_token:%d" + captchaKey = "bd_captcha_mobile_%s" + CaptchaExpire = 5 * time.Minute + replayNonceKey = "bd_replay:nonce:_%s" + mediaM3U8Fmt = "m3u8cache:%s" + MediaM3U8CacheExpire = time.Hour * 24 * 7 + + //禁止登陆设置 + userForbit = "userForbit:%d" + ipForbit = "ipForbit:%s" + + //每分钟Ip注册次数 + registerLimist = "registerLimit:%s" + RegisterLimistExpire = time.Minute * 30 + + //用户关注限制 + careLimitKey = "carelimit:%d" + CareLimitKeyExpire = 15 * time.Second + + //用户评论限制 + commentLimitKey = "commentlimit:%s" + CommentLimitKeyExpire = 5 * time.Second + CommentDayLimitKeyExpire = 10 * time.Hour + + ContactUsLimitKey = "contactuslimit:%s" + ContactusLimitKeyExpire = 1 * time.Minute + ContactusLimitKeyDayExpire = 10 + //每用户访问次数限制 + userCallLimit = "userCall:%d" + ipCallLimit = "ipCall:%s" + CallLimitExpire = time.Second * 10 + + userSidKey = "userSid:%d" + UserSidKeyExpire = time.Minute * 30 + + // 评论缓存key和过期时间 + CommentListKey = "commentList_%d_%d" + CommentListKeyExpire = time.Minute * 5 + + // 视频收藏列表分页缓存key、版本key和过期时间 + FavoriteListVersionKeyExpire = time.Hour * 24 + + FavoriteVideoListKey = "favoriteVideoList:v%d:%d:%d:%d" + FavoriteVideoListVersionKey = "favoriteVideoListVersion:%d" + FavoriteVideoListKeyExpire = time.Minute * 30 + + // 问卷提交频率限制(同一 IP / 用户每分钟只能提交一次) + questionnaireLimitIp = "questionnaireLimit:ip:%s" + questionnaireLimitUser = "questionnaireLimit:user:%d" + QuestionnaireLimitExpire = time.Minute + + // m3u8 / enkey 防盗链 + // IP 自然日正片去重计数(key 含日期,次日自动换桶) + m3u8DayRateLimitIp = "m3u8dayrate:ip:%s:%s" // %s=ip, %s=日期 20060102 + m3u8DailyMediaIpFmt = "m3u8:daily:media:ip:%s:%d:%s" // ip, mediaId, 日期 + M3U8DayRateLimitMax = int64(80) // 每 IP 每天最多 80 个不同正片(sys_cfg 未配置时的默认值) + // enkey 一次性票据:/h5/m3u8 鉴权通过后写入,/enkey 消费后删除 + enkeyTicketKey = "enkey:ticket:%s" // %s = token 前16位 + EnkeyTicketExpire = 15 * time.Minute + + // 访问IP去重统计:每个 appId 每天一个 SET + ipStatKey = "ipstat:%d:%s" // %d=appId, %s=日期 "20060102" + IpStatExpire = 49 * time.Hour // 保留49小时,确保昨日数据在今天仍可读 +) + +func BloomUserKey(userId int64) string { + return fmt.Sprintf(BloomUser, userId) +} + +func FiveMVisitKey(time string) string { + return fmt.Sprintf(FiveMVisit, time) +} + +func UserVisitLogKey(uid int64, t time.Time) string { + return fmt.Sprintf(visitLogFmt, t.Format("20060102"), uid) +} + +func MobileCaptchaKey(mobile string) string { + return fmt.Sprintf(captchaKey, mobile) +} + +func AppUserTokenKey(userId int64) string { + return fmt.Sprintf(AppUserToken, userId) +} + +// AppPlayletHomeCacheVersionKey 用于后台变更后切换短剧首页缓存版本。 +func AppPlayletHomeCacheVersionKey() string { + return fmt.Sprintf("app:playlet:home:version:%d", pubdef.AppId) +} + +// AppPlayletRankCacheVersionKey 用于短剧删除后刷新榜单缓存 +func AppPlayletRankCacheVersionKey() string { + return fmt.Sprintf("app:playlet:rank:version:%d", pubdef.AppId) +} + +func MediaM3U8Key(name string) string { + return fmt.Sprintf(mediaM3U8Fmt, name) +} +func UserForbitKey(userId int64) string { + return fmt.Sprintf(userForbit, userId) +} + +func RegisterLimitKey(ip string) string { + ip = strings.ReplaceAll(ip, ":", "-") + return fmt.Sprintf(registerLimist, ip) +} + +func CareLimitKey(userId int64) string { + return fmt.Sprintf(careLimitKey, userId) +} +func ReplayNonceKey(nonce string) string { + return fmt.Sprintf(replayNonceKey, nonce) +} + +func CommentLimitKey(ip string) string { + return fmt.Sprintf(commentLimitKey, ip) +} +func CommentOneDayLimitKey(ip string) string { + return fmt.Sprintf("commentdaylimit:%s", ip) +} + +func UserCallLimitKey(userId int64) string { + return fmt.Sprintf(userCallLimit, userId) +} + +func IpCallLimitKey(ip string) string { + return fmt.Sprintf(ipCallLimit, ip) +} + +func IpForbitKey(ip string) string { + ip = strings.ReplaceAll(ip, ":", "-") + return fmt.Sprintf(ipForbit, ip) +} + +func UserSidKey(userId int64) string { + return fmt.Sprintf(userSidKey, userId) +} + +// CommentListCacheKey 返回评论列表缓存key +func CommentListCacheKey(objectId int64, objectType pubdef.CommentObjectType) string { + return fmt.Sprintf(CommentListKey, objectId, objectType) +} + +func FavoriteVideoListCacheKey(userId, version, pageNum, pageSize int64) string { + return fmt.Sprintf(FavoriteVideoListKey, version, userId, pageNum, pageSize) +} + +func FavoriteVideoListVersionCacheKey(userId int64) string { + return fmt.Sprintf(FavoriteVideoListVersionKey, userId) +} + +func QuestionnaireLimitIpKey(ip string) string { + ip = strings.ReplaceAll(ip, ":", "-") + return fmt.Sprintf(questionnaireLimitIp, ip) +} + +func QuestionnaireLimitUserKey(userId int64) string { + return fmt.Sprintf(questionnaireLimitUser, userId) +} + +// M3U8DayRateLimitIpKey 按自然日(Asia/Shanghai)生成 IP 日限流 key +func M3U8DayRateLimitIpKey(ip string) string { + ip = normalizeM3U8Ip(ip) + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + day := time.Now().In(loc).Format("20060102") + return fmt.Sprintf(m3u8DayRateLimitIp, ip, day) +} + +// M3U8DayRateLimitExpire 返回距下一个自然日 0 点的时长,用于 key TTL +func M3U8DayRateLimitExpire() time.Duration { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + now := time.Now().In(loc) + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc) + d := next.Sub(now) + if d < time.Minute { + return time.Minute + } + return d +} + +// EnkeyTicketKey token 的前 16 个字符作为 key,避免存完整 token 太长 +func EnkeyTicketKey(token string) string { + if len(token) > 16 { + token = token[:16] + } + return fmt.Sprintf(enkeyTicketKey, token) +} + +func normalizeM3U8Ip(ip string) string { + return strings.ReplaceAll(ip, ":", "-") +} + +// M3U8DailyMediaIpKey 同一 IP 同一正片当天只计一次 +func M3U8DailyMediaIpKey(ip string, mediaId int64) string { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + day := time.Now().In(loc).Format("20060102") + return fmt.Sprintf(m3u8DailyMediaIpFmt, normalizeM3U8Ip(ip), mediaId, day) +} + +// IpStatKey 返回指定 appId 在某天的访问IP集合 key +// date 格式: "20060102" +func IpStatKey(appId int64, date string) string { + return fmt.Sprintf(ipStatKey, appId, date) +} diff --git a/pub/common/enum/video/video.go b/pub/common/enum/video/video.go new file mode 100644 index 0000000..219758d --- /dev/null +++ b/pub/common/enum/video/video.go @@ -0,0 +1,27 @@ +package video + +type VideoType int64 +type videoStatus int64 + +// 0-上架、1-未上架 +const ( + Enable videoStatus = iota + Disable +) + +// 1是AV、2是SP +const ( + _ VideoType = iota + AV + SP +) + +const AVLimit = 1 + +type recordType int64 + +const ( + _ recordType = iota + Watch + Downlaoad +) diff --git a/pub/common/errors/code.go b/pub/common/errors/code.go new file mode 100644 index 0000000..48e466d --- /dev/null +++ b/pub/common/errors/code.go @@ -0,0 +1,169 @@ +package errors + +type CodeType int64 + +const NotFind = "mongo: no documents in result" + +// 错误级别 +const ( + ParamLevel = 1000 // 参数级 + StoreLevel = 3000 // 存储级 + ServerLevel = 4000 // 服务级 + BusinessLevel = 6000 // 业务逻辑级 +) + +// app 端用到了 +// 1000, 重新登陆 +// 4002 短信发送失败 +// 6017 手机号码已绑定 +// 6023 user_video操作错误 +// 这里作出兼容 + +func (c CodeType) Error() string { + return "" +} + +const ( + InvalidateToken CodeType = ParamLevel + iota // 不可用的token,客户端需要重新登陆 + MethodNotFound // 方法为找到,指gin方法未找到 + ApiNotFound // api未找到 + Limiting // 限流 + NoToken // 无token 1004 +) + +const ( + DBQuery CodeType = StoreLevel + iota + DBInsert + DBUpdate + DBDelete + RedisSet + RedisGet + RedisDel + ProxyPutObject + EtcdGet + EtcdPut + RedisRPush + RedisLPop +) + +const ( + HttpClient CodeType = ServerLevel + iota // httpclient + TypePointer // 指针类型 + SmsLimit // 短信限制 + SmsSend // 短信发送失败 + SmsTmp // 短信模版 + SmsTmpParse // 短信模版解析 + UserId // 用户ID + OpenFile // 打开文件 + FsErr // seaweedfs + CtxTimeout // context超时 + UnmarshalJson // json + FSFiler // fs filer + HttpGet // + ReadData // read data + IOCopy // io copy + Pass // pass + PassConfID // pass config id + Tips // 提示错误 +) + +const ( + // 用户权限 + UserPermission CodeType = BusinessLevel + iota + Unknown // 未知错误 + InvalidKeyword // 无效的关键字 + UserName // 用户名称 + Mobile // 手机号码 + Captcha // 验证码 + CaptchaErr // 验证码错误 + Psw // 密码 + PayCode // 用户支付密码 + Number // 数字类型 + String // 字符串类型 + VideoId // 视频ID + Progress // 进度 + Ids // id数组 + CatagoryExist // 视频种类已存在 + MobileBinded // 手机号码已绑定 + Rate // 返佣金比例 + Brokerage // 返佣 + Logo // 用户logo + UploadFile // + EmptyFile // + Operate // user_video操作错误 + UserDisable // + NickName // + Gender // + InviteCode // + Coat // + ReLogin // + PopType // + AgentBinded // + M3u8FileName // + M3u8Read // + VideoName // + ExchangeCodeErr // 兑换码错误 + ChanAcount // + Recharge // + Withdraw // + ExportErr // 导出参数错误 + DomainErr // 主域名错误 + ForbiddenWord // 禁言 + ForbiddenLogin // 禁止登陆 + DuplicateLike // 重复点赞 + AliPayUnBind // 支付宝未绑定 + BankCardUnBind // 银行卡未绑定 + VideoUploadExist // 视频重复上传 + Task // + _ // 已移除帖子参数错误码,保留原数值 + _ // 已移除帖子重复发送错误码,保留原数值 + _ // 已移除帖子不存在错误码,保留原数值 + _ // 已移除帖子重复点赞错误码,保留原数值 + _ // 已移除帖子取消点赞错误码,保留原数值 + _ // 已移除帖子重复收藏错误码,保留原数值 + _ // 已移除帖子取消收藏错误码,保留原数值 + _ // 已移除帖子重复屏蔽错误码,保留原数值 + _ // 已移除帖子取消屏蔽错误码,保留原数值 + UserDoNotForSelf // 用户不能对自己进行此项操作 + UserWatchDupi // 不可重复关注同一用户 + UserNoWatch // 没有关注该用户,不可取消关注 + _ // 已移除帖子回复参数错误码,保留原数值 + _ // 已移除帖子评论不存在错误码,保留原数值 + _ // 已移除帖子评论已存在错误码,保留原数值 + NoWatchableUsers // 没有可以关注的用户池 + _ // 已移除帖子评论点赞错误码,保留原数值 + _ // 已移除帖子评论取消点赞错误码,保留原数值 + _ // 已移除帖子定价错误码,保留原数值 + _ // 已移除帖子已购买错误码,保留原数值 + NoBalance // 余额不足 + BalanceErr // 编辑余额错误 + NoAccount // 没账户 + AccountDisabled // 账户已被封禁 + BuySelf // 不能购买自己的内容 + RedisError // 服务器繁忙 + Button // 落地页按钮 + ReplyTimeShort // 回复间隔时间太短 + OperateLog // 日志出错 + RoleErr // 角色权限不够 + DocExists // 查找的文档已存在 + _ // 已移除帖子未知错误码,保留原数值 + SeekScoreConfirm // 商人追分确认 + GetOrderInfo // 商人充值订单报错 + PromSeq // 商区错误 + SignErr // 签名错误 + BadRequest // 传参错误 + ReplyPoolExisted // 评论已存在 + LimitIPErr // 限制IP登陆 +) + +// 代充专用 +const ( + TakeChatFail CodeType = iota + 18000 //发起代充聊天失败 18000 + GetOrderInfoFail //获取代理充值订单信息失败 18001 + AppIDNotExist //代充AppID 不存在 18002 + TraderNotExist //此商人不存在 18003 + TraderBalanceNotEnough //商人余额不足 18004 + TraderHasLocked //商人被禁止登陆 18005 + TraderSeekScoreFail //商人追分失败 18006 + TraderSeekScoreFailDueNotEnough //商人追分失败 18007 +) diff --git a/pub/common/errors/error.go b/pub/common/errors/error.go new file mode 100644 index 0000000..1421715 --- /dev/null +++ b/pub/common/errors/error.go @@ -0,0 +1,39 @@ +package errors + +import ( + "fmt" +) + +// SrvErr 错误定义 +type SrvErr struct { + Code CodeType `json:"code"` + Msg string `json:"msg"` + Tips string `json:"tip,omitempty"` + Cause string `json:"-"` +} + +// New create an error +func New(code CodeType, msg string, cause string) *SrvErr { + return &SrvErr{ + Code: code, + Msg: msg, + Cause: cause, + } +} + +// New create an error +func NewTips(code CodeType, tips string, cause string) *SrvErr { + return &SrvErr{ + Code: code, + Msg: tips, + Tips: tips, + Cause: cause, + } +} + +func (s *SrvErr) Error() string { + if s == nil { + return "" + } + return fmt.Sprintf("code = %d, msg = %s ,case = %v", s.Code, s.Msg, s.Cause) +} diff --git a/pub/common/errors/make.go b/pub/common/errors/make.go new file mode 100644 index 0000000..4d6cf54 --- /dev/null +++ b/pub/common/errors/make.go @@ -0,0 +1,24 @@ +package errors + +import ( + "fmt" + "runtime" +) + +func MakeWithErr(code CodeType, err error) error { + pc, fn, line, _ := runtime.Caller(1) + cause := fmt.Sprintf("[error] in %s[%s:%d] %v", runtime.FuncForPC(pc).Name(), fn, line, err.Error()) + return New(code, err.Error(), cause) +} + +func MakeWithMsg(code CodeType, msg string) error { + pc, fn, line, _ := runtime.Caller(1) + cause := fmt.Sprintf("[error] in %s[%s:%d] %v", runtime.FuncForPC(pc).Name(), fn, line, msg) + return New(code, msg, cause) +} + +func MakeWithTips(code CodeType, tips string) error { + pc, fn, line, _ := runtime.Caller(1) + cause := fmt.Sprintf("[error] in %s[%s:%d] %v", runtime.FuncForPC(pc).Name(), fn, line, tips) + return NewTips(code, tips, cause) +} diff --git a/pub/common/errors/msg_app.go b/pub/common/errors/msg_app.go new file mode 100644 index 0000000..a74ccfe --- /dev/null +++ b/pub/common/errors/msg_app.go @@ -0,0 +1,19 @@ +package errors + +// error tips +const ( + + //基础错误提示 + ET_Store = "存储错误" + ET_DBOp = "DB操作错误" + ET_RdsOp = "Rds操作错误" + ET_Param = "参数错误" + ET_DBQury = "DB查询错误" + ET_DBNoResult = "DB查询数据不存在" + ET_TransactionsErr = "事务运行错误" + //---------------------------------------------- + ET_UserNoExist = "用户不存在" + ET_BloggerApply = "博主申请条件不满足" + ET_BloggerAlreadyIs = "用户已经是博主" + ET_BalanceNotEnough = "余额不足" +) diff --git a/pub/common/fconst/fconst.go b/pub/common/fconst/fconst.go new file mode 100644 index 0000000..e100ba5 --- /dev/null +++ b/pub/common/fconst/fconst.go @@ -0,0 +1,6 @@ +package fconst + +const ( + FileDir = "file-tmp" + FsDir = "/fs" +) diff --git a/pub/common/godef/godef.go b/pub/common/godef/godef.go new file mode 100644 index 0000000..4a1f48e --- /dev/null +++ b/pub/common/godef/godef.go @@ -0,0 +1,26 @@ +package godef + +import ( + "runtime" + "videoweb/pub/common/log" + "videoweb/pub/common/tgbot" +) + +func Go(f func()) { + //atomic.AddInt64(&routineCount, 1) + go func() { + defer func() { + //atomic.AddInt64(&routineCount, -1) + if r := recover(); r != nil { + buf := make([]byte, 2048) + length := runtime.Stack(buf, false) + info := string(buf[:length]) + log.Error("cat a unkown excetion", log.Any("excetion :", info), log.Any("e", r)) + //发送tg消息 + msg := "goroutine crash,the stack:\n" + info + tgbot.SendToProgram("程序异常", msg) + } + }() + f() + }() +} diff --git a/pub/common/googauth/googauth.go b/pub/common/googauth/googauth.go new file mode 100644 index 0000000..3c086b6 --- /dev/null +++ b/pub/common/googauth/googauth.go @@ -0,0 +1,34 @@ +package googauth + +import ( + "github.com/dgryski/dgoogauth" +) + +const windowSize = 30 +const hotpWindowSize = 5 + +var HotpCfg = dgoogauth.OTPConfig{ + Secret: "MRSHM6LTMFYHAMJSGM2DKNQ=", + HotpCounter: 1, + WindowSize: hotpWindowSize, +} + +func New(secret string, name string) string { + cfg := &dgoogauth.OTPConfig{ + Secret: secret, + WindowSize: windowSize, + } + return cfg.ProvisionURI(name) +} + +func Verify(secret string, pwd string) (bool, error) { + cfg := &dgoogauth.OTPConfig{ + Secret: secret, + WindowSize: windowSize, + } + return cfg.Authenticate(pwd) +} + +func VerifyHotp(cfg *dgoogauth.OTPConfig, authCode string, counter int) (bool, error) { + return cfg.Authenticate(authCode) +} diff --git a/pub/common/googauth/htop.go b/pub/common/googauth/htop.go new file mode 100644 index 0000000..fa1e02b --- /dev/null +++ b/pub/common/googauth/htop.go @@ -0,0 +1,67 @@ +package googauth + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "strconv" +) + +// HOTP implementation +type HOTP struct { + secret []byte + digits int +} + +// At generate code +func (h HOTP) At(counter uint64) string { + counterBytes := make([]byte, 8) + binary.BigEndian.PutUint64(counterBytes, counter) + hash := hmac.New(sha1.New, h.secret) + hash.Write(counterBytes) + hs := hash.Sum(nil) + offset := hs[19] & 0x0f + binCodeBytes := make([]byte, 4) + binCodeBytes[0] = hs[offset] & 0x7f + binCodeBytes[1] = hs[offset+1] & 0xff + binCodeBytes[2] = hs[offset+2] & 0xff + binCodeBytes[3] = hs[offset+3] & 0xff + binCode := binary.BigEndian.Uint32(binCodeBytes) + mod := uint32(1) + for i := 0; i < h.digits; i++ { + mod *= 10 + } + code := binCode % mod + codeString := strconv.FormatUint(uint64(code), 10) + if len(codeString) < h.digits { + paddingByteLength := h.digits - len(codeString) + paddingBytes := make([]byte, paddingByteLength) + for i := 0; i < paddingByteLength; i++ { + paddingBytes[i] = '0' + } + codeString = string(paddingBytes) + codeString + } + return codeString +} + +// Verify verify OTP code +func (h HOTP) VerifyCode(code string, counter uint64) bool { + realCode := h.At(counter) + if realCode == code { + return true + } + return false +} + +// Verify verify OTP code +func (h HOTP) GenerateCode(counter uint64) string { + return h.At(counter) +} + +// NewHOTP generate new HOTP instance +func NewHOTP(secret []byte, digits int) (h *HOTP) { + h = new(HOTP) + h.secret = secret + h.digits = digits + return +} diff --git a/pub/common/http/http.go b/pub/common/http/http.go new file mode 100644 index 0000000..6bf8a60 --- /dev/null +++ b/pub/common/http/http.go @@ -0,0 +1,202 @@ +package http + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "reflect" + "time" +) + +var ( + client = http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConnsPerHost: 5, + MaxConnsPerHost: 100, + }, + } +) + +type ByteSize int64 + +const ( + _ = iota // ignore first value by assigning to blank identifier + KB ByteSize = 1 << (10 * iota) + MB +) + +var ( + outOfBodySizeErr = errors.New("out of body size limit") +) + +type ResponseBody struct { + StatusCode int + Data []byte +} + +type RequestFormData map[string]string + +// Post +// Note: 只会读取小于1M的数据 +func Post(url string, header map[string]string, body io.Reader) (fr *ResponseBody, err error) { + fr, err = do(http.MethodPost, url, header, body, int64(1024*MB)) + return +} + +func PostJson(url string, body io.Reader) (fr *ResponseBody, err error) { + header := map[string]string{} + header["Content-Type"] = "application/json" + fr, err = do(http.MethodPost, url, header, body, int64(1024*KB)) + return +} + +// PostFormData 提交标准的form表单 +func PostFormData(url string, fromdata RequestFormData) (fr *ResponseBody, err error) { + var b bytes.Buffer + writer := multipart.NewWriter(&b) + + for k, v := range fromdata { + if err = writer.WriteField(k, v); err != nil { + return + } + } + if err = writer.Close(); err != nil { + return + } + + header := map[string]string{"Content-Type": writer.FormDataContentType()} + fr, err = do(http.MethodPost, url, header, &b, 2*int64(KB)) + return +} + +// Get +// Note: 只会读取小于1M的数据 +func Get(url string, header map[string]string) (fr *ResponseBody, err error) { + fr, err = do(http.MethodGet, url, header, nil, int64(KB)) + return +} + +// GetLargeFile 获取大文件。2M +func GetLargeFile(url string, header map[string]string) (fr *ResponseBody, err error) { + fr, err = do(http.MethodGet, url, header, nil, int64(20*MB)) + return +} + +func do(method string, url string, header map[string]string, body io.Reader, size int64) (fr *ResponseBody, err error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return + } + for k, v := range header { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.ContentLength > size { + err = outOfBodySizeErr + return + } + + data, err := ioutil.ReadAll(io.LimitReader(resp.Body, size)) + if err != nil { + return + } + fr = &ResponseBody{ + StatusCode: resp.StatusCode, + Data: data, + } + return +} + +// 推送对象到文件服务器 +func PostObject(ctx context.Context, url string, header map[string]string, body []byte, objName string) (string, error) { + base64data := base64.StdEncoding.EncodeToString(body) + + req := struct { + Name string `json:"fileName"` + Data string `json:"fileData"` + }{ + Name: objName, + Data: base64data, + } + + jsonBytes, _ := json.Marshal(req) + + resp, err := Post(url, header, bytes.NewReader(jsonBytes)) + if err != nil { + return "", err + } + + type Obj struct { + Domain string `json:"domain"` + Name string `json:"fileName"` + } + + fsResp := struct { + Code int `json:"code"` + Object Obj `json:"data"` + Msg string `json:"msg"` + }{} + + if err = json.Unmarshal(resp.Data, &fsResp); err != nil { + return "", err + } + + if fsResp.Code != 200 || fsResp.Object.Name == "" { + return "", fmt.Errorf("上传fs失败") + } + + return fsResp.Object.Name, nil +} + +func PostWithBind(ctx context.Context, bind interface{}, url string, body io.Reader) error { + if err := verifyBind(bind); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "POST", url, body) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("response status code:%d", resp.StatusCode) + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(data, bind) +} + +func verifyBind(bind interface{}) error { + bType := reflect.TypeOf(bind) + if bType.Kind() != reflect.Ptr { + return errors.New("bind must be a Ptr") + } + return nil +} + +func GetWithBody(url string, header map[string]string, body io.Reader) (fr *ResponseBody, err error) { + fr, err = do(http.MethodGet, url, header, body, int64(20*MB)) + return +} \ No newline at end of file diff --git a/pub/common/http/http_test.go b/pub/common/http/http_test.go new file mode 100644 index 0000000..d99de2f --- /dev/null +++ b/pub/common/http/http_test.go @@ -0,0 +1,16 @@ +package http + +import "fmt" + +func ExampleGet() { + urlstr := "https://palce.hzbeisheng.com/api/sms" + data, err := Get(urlstr, nil) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(string(data.Data)) + + // Output: + +} diff --git a/pub/common/httputil/httputil.go b/pub/common/httputil/httputil.go new file mode 100644 index 0000000..bfadd58 --- /dev/null +++ b/pub/common/httputil/httputil.go @@ -0,0 +1,321 @@ +package httputil + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "videoweb/pub/common/log" + + "github.com/ddliu/go-httpclient" +) + +// HTTPClient htttp客户端 +type HTTPClient struct { + client *httpclient.HttpClient +} + +type ProxyCfg struct { + Host string + Timeout int //秒 + Source string +} + +func (p *ProxyCfg) Build(rawurl string, rawHeaders map[string]string) (string, map[string]string, error) { + u, err := url.Parse(rawurl) + if err != nil { + return "", nil, err + } + target := url.URL{Scheme: u.Scheme, Host: u.Host} + u.Host = p.Host + u.Scheme = "http" + m := make(map[string]string) + m["X-Proxy-Target-Host"] = target.String() + m["X-Proxy-Source"] = "ys" + if p.Timeout > 0 { + m["X-Proxy-Timeout"] = strconv.Itoa(p.Timeout) + } + if len(rawHeaders) > 0 { + for k, v := range rawHeaders { + m[k] = v + } + } + return u.String(), m, nil +} + +type httpClientPool struct { + cliPool map[string]*httpclient.HttpClient + poolSize int + offset int +} + +// HTTPResponse http响应 +type HTTPResponse struct { + StatusCode int `json:"statusCode"` + Body *httpclient.Response `json:"boby"` +} + +var httpCliPool = httpClientPool{cliPool: map[string]*httpclient.HttpClient{}, poolSize: 50} + +func New() *HTTPClient { + h := httpclient.NewHttpClient().Defaults(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 120, + }) + return &HTTPClient{client: h} +} + +func NewCtx(ctx context.Context) *HTTPClient { + h := httpclient.NewHttpClient().WithOptions(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 10, + httpclient.OPT_CONTEXT: ctx, + }) + return &HTTPClient{client: h.Begin()} +} + +func Begin() *HTTPClient { + h := httpclient.NewHttpClient().Defaults(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 60, + }) + return &HTTPClient{client: h.Begin()} +} + +// GetHTTPClient 获取httpclient +func Client() *HTTPClient { + cliID := "CLIENT-ID-" + if len(httpCliPool.cliPool) < httpCliPool.poolSize { + h := httpclient.NewHttpClient().Defaults(httpclient.Map{ + httpclient.OPT_CONNECTTIMEOUT: 10, + httpclient.OPT_TIMEOUT: 120, + }) + cliID = cliID + strconv.FormatInt(int64(len(httpCliPool.cliPool)+1), 10) + httpCliPool.cliPool[cliID] = h + httpCliPool.offset = httpCliPool.poolSize + return &HTTPClient{client: h} + } + if httpCliPool.offset == httpCliPool.poolSize+1 { + httpCliPool.offset = 1 + } + cliID = cliID + strconv.FormatInt(int64(httpCliPool.offset), 10) + httpCliPool.offset = httpCliPool.offset + 1 + h, ok := httpCliPool.cliPool[cliID] + if !ok || h == nil { + h = httpclient.NewHttpClient().WithOption(httpclient.OPT_TIMEOUT, 60) + httpCliPool.cliPool[cliID] = h + return &HTTPClient{client: h} + } + return &HTTPClient{client: h} +} + +func jsonUnmarshalResp(bind interface{}, resp *HTTPResponse) error { + defer func() { _ = resp.Body.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + b, err := resp.Body.ReadAll() + if err != nil { + log.Error("httputil jsonUnmarshalResp readall err", log.E(err)) + return err + } + err = json.Unmarshal(b, bind) + if err != nil { + log.Error("httputil jsonUnmarshalResp Unmarshal err", log.E(err)) + } + return err +} + +// Get Get方法 +func (h *HTTPClient) Get(url string, headers map[string]string, params ...interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Get(url, params...) + if err != nil { + log.Error(fmt.Sprintf("http client Get method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +func (h *HTTPClient) PGet(p *ProxyCfg, url string, headers map[string]string, params ...interface{}) (*HTTPResponse, error) { + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return nil, err + } + return h.Get(purl, pheaders, params) +} + +// Get GetBytes +func (h *HTTPClient) GetBytes(url string, headers map[string]string, params ...interface{}) ([]byte, error) { + h.client.WithHeaders(headers) + response, err := h.client.Get(url, params...) + if err != nil { + log.Error(fmt.Sprintf("http client Get method failed %+v:", err)) + return nil, err + } + data, err := response.ReadAll() + if err != nil { + log.ZapLog.Error("resp readAll errror", log.Any("Error", err)) + return data, err + } + defer func() { + _ = response.Body.Close() + }() + return data, nil +} + +// GetWithJResp 结果json.Unmarshal到bind中 +func (h *HTTPClient) GetWithJResp(bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + resp, err := h.Get(url, headers, params...) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +func (h *HTTPClient) PGetWithJResp(p *ProxyCfg, bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return 0, err + } + return h.GetWithJResp(bind, purl, pheaders, params...) +} + +// Get Get方法 +func (h *HTTPClient) Delete(url string, headers map[string]string, params ...interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Delete(url, params...) + if err != nil { + log.Error(fmt.Sprintf("http client Delete method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// DeleteWithJResp 结果json.Unmarshal到bind中 +func (h *HTTPClient) DeleteWithJResp(bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + resp, err := h.Delete(url, headers, params...) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +// Post Post方法 +func (h *HTTPClient) Post(url string, headers map[string]string, params interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Post(url, params) + if err != nil { + log.Error(fmt.Sprintf("http client Post method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +func (h *HTTPClient) PPost(p *ProxyCfg, bind interface{}, url string, headers map[string]string, params interface{}) (*HTTPResponse, error) { + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return nil, err + } + return h.Post(purl, pheaders, params) +} + +// POSTJson PostJson方法 +func (h *HTTPClient) POSTJson(url string, headers map[string]string, data interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.PostJson(url, data) + if err != nil { + log.Error(fmt.Sprintf("http client POSTJson method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// POSTJson PostJson方法, bind json.Unmarshal +func (h *HTTPClient) POSTJsonWithJResp(bind interface{}, url string, headers map[string]string, data interface{}) (int, error) { + resp, err := h.POSTJson(url, headers, data) + if err != nil { + log.Error("POSTJsonWithResp err", log.E(err)) + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +func (h *HTTPClient) PPOSTJsonWithJResp(p *ProxyCfg, bind interface{}, url string, headers map[string]string, data interface{}) (int, error) { + if p == nil { + return 0, fmt.Errorf("ProxyCfg is nil") + } + purl, pheaders, err := p.Build(url, headers) + if err != nil { + return 0, err + } + return h.POSTJsonWithJResp(bind, purl, pheaders, data) +} + +// PostMultipart PostMultipart 上传文件 +func (h *HTTPClient) PostMultipart(url string, headers map[string]string, params interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.PostMultipart(url, params) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// Put 流式传输 可用于上传文件 +func (h *HTTPClient) Put(url string, headers map[string]string, body io.Reader) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.Put(url, body) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// PutJSON PutJSON方法 +func (h *HTTPClient) PutJSON(url string, headers map[string]string, data interface{}) (*HTTPResponse, error) { + h.client.WithHeaders(headers) + response, err := h.client.PutJson(url, data) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// Header Header +func (h *HTTPClient) Header(url string) (*HTTPResponse, error) { + response, err := h.client.Head(url) + if err != nil { + log.Error(fmt.Sprintf("http client PostMultipart method failed %+v:", err)) + return nil, err + } + return &HTTPResponse{StatusCode: response.StatusCode, Body: response}, nil +} + +// POSTWithJResp 结果json.Unmarshal到bind中 用于上传文件, content-type :"application/x-www-form-urlencoded" 如参数以@开头 则为上传文件 +func (h *HTTPClient) POSTWithJResp(bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + resp, err := h.Post(url, headers, params) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} + +// POSTWithJResp 结果json.Unmarshal到bind中 用于上传文件, content-type :"application/x-www-form-urlencoded" 如参数以@开头 则为上传文件 +func (h *HTTPClient) PPOSTWithJResp(p *ProxyCfg, bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + resp, err := h.PPost(p, bind, url, headers, params) + if err != nil { + return 0, err + } + err = jsonUnmarshalResp(bind, resp) + return resp.StatusCode, err +} diff --git a/pub/common/httputil/httputil_test.go b/pub/common/httputil/httputil_test.go new file mode 100644 index 0000000..3c2ea11 --- /dev/null +++ b/pub/common/httputil/httputil_test.go @@ -0,0 +1,105 @@ +package httputil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "testing" +) + +type ResponseInfo struct { + Gzipped bool `json:"gzipped"` + Method string `json:"method"` + Origin string `json:"origin"` + Useragent string `json:"user-agent"` // http://httpbin.org/user-agent + Form map[string]string `json:"form"` + Files map[string]string `json:"files"` + Headers map[string]string `json:"headers"` + Cookies map[string]string `json:"cookies"` +} + +var info ResponseInfo + +type Resport struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data"` +} + +func Test_Get_Httpcli(t *testing.T) { + client := Client() + var headers = map[string]string{"auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVjZTJiNzNmZTI3Y2YyMmE4ZjQyMTVkMCIsInVzZXJOYW1lIjoidHVib2xpbmcxNjgiLCJpc0xvZ2luIjp0cnVlLCJsb2dpblRpbWUiOjE1NjQxNDYwNzI5MDUsImlhdCI6MTU2NDE0NjA3Mn0.VQICBk71KDITus83pZEq3C1y_WbKPZc0HUfmIm_eby8"} + respo, err := client.Get("https://no5.xiexiaojia.com/appjmall/api/showCore/appShowList", headers) + defer respo.Body.Body.Close() + if err != nil { + fmt.Printf("%+v", err) + } + resport := Resport{} + body, err := respo.Body.ReadAll() + //反序列化 + err = json.Unmarshal(body, &resport) + fmt.Println("response", resport) + //案例获取数据 + fmt.Println("response comName:", resport.Data.([]interface{})[0].(map[string]interface{})["comName"]) +} + +func Test_Post_Httpcli(t *testing.T) { + client := Client() + fmt.Println("http 客户端", client) + var headers = map[string]string{"auth_token": "xxxxx"} + var params = map[string]string{"userId": "5ce2b73fe27cf22a8f4215d0", "price": "5000", "count": "1", "commId": "5ce40e03e27cf22a8f4215d8", + "comName": "日本ceruru.b 脐带血面膜保湿补水修复5片", + "comImageCar": "http://oam.szbslhb.com/4,01fb06b4adf1.jpg", + "attributeCar": "", + } + respo, err := client.Post("https://no5.xiexiaojia.com/appjmall/api/cart/addCommToCart", headers, params) + defer respo.Body.Body.Close() + if err != nil { + fmt.Printf("%+v", err) + } + resport := Resport{} + body, err := respo.Body.ReadAll() + if err != nil { + res, _ := respo.Body.ToString() + fmt.Println("response", res) + return + } + //反序列化 + err = json.Unmarshal(body, &resport) + fmt.Println("response", resport) +} + +func Test_PostJson_Httpcli(t *testing.T) { + client := Client() + var headers = map[string]string{"Content-Type": "application/json"} + fmt.Println("http 客户端", client) + respo, err := client.Get("https://www.kancloud.cn/cattong/go_command_tutorial/261347", headers) + defer respo.Body.Body.Close() + if err != nil { + fmt.Printf("%+v", err) + } + body, err := respo.Body.ToString() + fmt.Println("response", body) +} + +func Test_PutFile_Httpcli(t *testing.T) { + client := Client() + file, err := os.Open("/Users/mac/Downloads/1567069549ABBQCTFC.mp4") + info, _ := file.Stat() + + var headers = map[string]string{ + "Content-Type": "application/octet-stream", + "Authorization": "bucketName=bucket-01;accessKey=FSd58ed59fccc16812903c37068de8969b;ttl=1h", + "Content-Disposition": "fileName=test02.mp4;size=" + strconv.FormatInt(info.Size(), 10), + "Signature": "9cea21bf95f235e7d9c5a70888cf742cfb9c946ed25aef8b70ce77c953ccbff0", + } + fmt.Println("http 客户端", client) + respo, err := client.Put("http://localhost:8888/fs/api/uploadStream", headers, file) + defer respo.Body.Body.Close() + if err != nil { + fmt.Printf("%+v", err) + } + body, err := respo.Body.ToString() + fmt.Println("response", body) +} diff --git a/pub/common/imgutil/decrypt.go b/pub/common/imgutil/decrypt.go new file mode 100644 index 0000000..95565d6 --- /dev/null +++ b/pub/common/imgutil/decrypt.go @@ -0,0 +1,74 @@ +package imgutil + +import "bytes" + +// 与管理后台 ShowImg / imgCode.js 保持一致。 +var ( + encryptMagicNumber = []byte{0x88, 0xA8, 0x30, 0xCB, 0x10, 0x76} + decryptKey = []byte{50, 48, 49, 57, 121, 115, 97, 112, 112, 55, 53, 50, 55} // 2019ysapp7527 + plainImageHeaders = [][]byte{ + {0xFF, 0xD8, 0xFF}, // jpeg + {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, // png + {0x47, 0x49, 0x46}, // gif + } +) + +const ( + encryptedPrefixLen = 100 + encryptXORAllKey = 0xA3 +) + +// DecryptImage 按后台 handleVerAutoImg 的规则解密图床图片。 +// 已是 jpg/png/gif 则原样返回。 +func DecryptImage(data []byte) []byte { + if len(data) == 0 { + return data + } + out := append([]byte(nil), data...) + if hasEncryptMagic(out) { + return xorAllLength(out) + } + if !isPlainImage(out) { + xorPrefix(out, decryptKey, encryptedPrefixLen) + } + return out +} + +func hasEncryptMagic(data []byte) bool { + return len(data) >= len(encryptMagicNumber) && bytes.Equal(data[:len(encryptMagicNumber)], encryptMagicNumber) +} + +func isPlainImage(data []byte) bool { + for _, header := range plainImageHeaders { + if len(data) >= len(header) && bytes.Equal(data[:len(header)], header) { + return true + } + } + return false +} + +func xorPrefix(src, key []byte, length int) { + if len(key) == 0 || len(src) == 0 { + return + } + if length <= 0 || length > len(src) { + length = len(src) + } + for i := 0; i < length; { + for j := 0; j < len(key) && i < length; j++ { + src[i] ^= key[j] + i++ + } + } +} + +func xorAllLength(src []byte) []byte { + out := make([]byte, 0, len(src)) + for i, b := range src { + if i < len(encryptMagicNumber) && b == encryptMagicNumber[i] { + continue + } + out = append(out, b^encryptXORAllKey) + } + return out +} diff --git a/pub/common/imgutil/resize.go b/pub/common/imgutil/resize.go new file mode 100644 index 0000000..d7960ae --- /dev/null +++ b/pub/common/imgutil/resize.go @@ -0,0 +1,151 @@ +package imgutil + +import ( + "bytes" + "crypto/sha1" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + _ "image/png" + "net/url" + "path" + "path/filepath" + "strings" + "unicode" +) + +const ( + // CoverResizeLimitKB 与 media utils.Resize 一致:超过该大小才缩放 + CoverResizeLimitKB = 60 + // CoverMaxWidth 与 media Thumbnail 一致 + CoverMaxWidth = 750 + // CoverMaxHeight 与 media Thumbnail 一致 + CoverMaxHeight = 1560 + // CoverJPEGQuality 压缩后统一输出 JPEG + CoverJPEGQuality = 75 +) + +// IsGIF 按文件头判断是否为 GIF,不依赖扩展名。 +func IsGIF(data []byte) bool { + return len(data) >= 3 && bytes.Equal(data[:3], []byte{0x47, 0x49, 0x46}) +} + +// NeedCompress 原图是否超过 media Resize 阈值,未超过则无需压缩重传。 +// GIF 保持原格式和动效,不参与压缩。 +func NeedCompress(data []byte) bool { + if IsGIF(data) { + return false + } + return int64(len(data))/1024 > CoverResizeLimitKB +} + +// CompressCover 按 media Resize(60KB, 750×1560) 压缩封面。 +// GIF 原样返回;其它格式未超过阈值时原样返回,超过则缩放后统一输出 JPEG。 +func CompressCover(data []byte, fileName string) ([]byte, string, error) { + if len(data) == 0 { + return nil, "", fmt.Errorf("图片内容为空") + } + outName := compressFileName(fileName) + if IsGIF(data) { + return data, withExt(outName, ".gif"), nil + } + if !NeedCompress(data) { + return data, outName, nil + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, "", fmt.Errorf("解码图片失败: %w", err) + } + thumb := thumbnail(img, CoverMaxWidth, CoverMaxHeight) + var buf bytes.Buffer + if err = jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: CoverJPEGQuality}); err != nil { + return nil, "", err + } + return buf.Bytes(), withExt(outName, ".jpg"), nil +} + +// SafeCoverFileName 生成仅含 ASCII 的上传文件名。 +// 原封面路径含中文时,直接用 path.Base 会把中文带进上传名,图床返回地址会异常。 +func SafeCoverFileName(src string) string { + src = strings.TrimSpace(src) + ext := asciiImageExt(src) + if src == "" { + return "playlet_cover" + ext + } + sum := sha1.Sum([]byte(src)) + return fmt.Sprintf("playlet_cover_%x%s", sum[:8], ext) +} + +func thumbnail(img image.Image, maxW, maxH int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxW && h <= maxH { + return img + } + wr := float64(maxW) / float64(w) + hr := float64(maxH) / float64(h) + scale := wr + if hr < wr { + scale = hr + } + nw := int(float64(w) * scale) + nh := int(float64(h) * scale) + if nw < 1 { + nw = 1 + } + if nh < 1 { + nh = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + for y := 0; y < nh; y++ { + sy := b.Min.Y + y*h/nh + for x := 0; x < nw; x++ { + sx := b.Min.X + x*w/nw + dst.Set(x, y, img.At(sx, sy)) + } + } + return dst +} + +func compressFileName(fileName string) string { + fileName = filepath.Base(strings.TrimSpace(fileName)) + if fileName == "" || fileName == "." || fileName == string(filepath.Separator) || !isASCIIFileName(fileName) { + return "playlet_cover.jpg" + } + return fileName +} + +func asciiImageExt(src string) string { + name := src + if u, err := url.Parse(src); err == nil && u.Path != "" { + name = u.Path + } + name = strings.Split(path.Base(name), "?")[0] + if dec, err := url.PathUnescape(name); err == nil { + name = dec + } + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + return ext + default: + return ".jpg" + } +} + +func isASCIIFileName(name string) bool { + if !strings.Contains(name, ".") { + return false + } + for _, r := range name { + if r > unicode.MaxASCII || r < 32 || r == '"' || r == '/' || r == '\\' { + return false + } + } + return true +} + +func withExt(fileName, ext string) string { + return strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ext +} diff --git a/pub/common/ipaddr/ip.go b/pub/common/ipaddr/ip.go new file mode 100644 index 0000000..0568f83 --- /dev/null +++ b/pub/common/ipaddr/ip.go @@ -0,0 +1,420 @@ +package ipaddr + +import ( + "fmt" + "log" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/lionsoul2014/ip2region/binding/golang/xdb" +) + +// XDBDetector 基于 xdb 的海外IP检测器 +type XDBDetector struct { + searcher *xdb.Searcher + cache sync.Map + cacheTTL time.Duration +} + +// RegionInfo 区域信息 +type RegionInfo struct { + Country string `json:"country"` + Region string `json:"region"` + Province string `json:"province"` + City string `json:"city"` + ISP string `json:"isp"` + IsOverseas bool `json:"is_overseas"` + IsChinaMainland bool `json:"is_china_mainland"` +} + +type IPVersion string + +const ( + IPv4 IPVersion = "IPv4" + IPv6 IPVersion = "IPv6" + InvalidIP IPVersion = "Invalid" +) + +// GetIPVersion 获取IP版本 +func GetIPVersion(ip string) IPVersion { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return InvalidIP + } + + if parsedIP.To4() != nil { + return IPv4 + } + return IPv6 +} + +// IsIPv4 判断是否为IPv4 +func IsIPv4(ip string) bool { + return GetIPVersion(ip) == IPv4 +} + +// NewXDBDetector 创建 xdb 检测器 +func NewXDBDetectorV6(dbPath string) (*XDBDetector, error) { + // 自动查找数据库文件 + if dbPath == "" { + dbPath = findXDBFile() + if dbPath == "" { + return nil, fmt.Errorf("未找到 ip2region.xdb 数据库文件") + } + } + + // 检查文件是否存在 + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return nil, fmt.Errorf("数据库文件不存在: %s", dbPath) + } + + // 读取整个 xdb 文件到内存 + cBuff, err := xdb.LoadContentFromFile(dbPath) + if err != nil { + return nil, fmt.Errorf("加载 xdb 内容失败: %v", err) + } + + // 创建搜索器 + searcher, err := xdb.NewWithBuffer(xdb.IPv6, cBuff) + if err != nil { + return nil, fmt.Errorf("创建搜索器失败: %v", err) + } + + detector := &XDBDetector{ + searcher: searcher, + cacheTTL: 24 * time.Hour, + } + + log.Printf("XDBDetector 初始化成功,数据库: %s", dbPath) + return detector, nil +} + +// NewXDBDetector 创建 xdb 检测器 +func NewXDBDetectorV4(dbPath string) (*XDBDetector, error) { + // 自动查找数据库文件 + if dbPath == "" { + dbPath = findXDBFile() + if dbPath == "" { + return nil, fmt.Errorf("未找到 ip2region.xdb 数据库文件") + } + } + + // 检查文件是否存在 + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return nil, fmt.Errorf("数据库文件不存在: %s", dbPath) + } + + // 读取整个 xdb 文件到内存 + cBuff, err := xdb.LoadContentFromFile(dbPath) + if err != nil { + return nil, fmt.Errorf("加载 xdb 内容失败: %v", err) + } + + // 创建搜索器 + searcher, err := xdb.NewWithBuffer(xdb.IPv4, cBuff) + if err != nil { + return nil, fmt.Errorf("创建搜索器失败: %v", err) + } + + detector := &XDBDetector{ + searcher: searcher, + cacheTTL: 24 * time.Hour, + } + + log.Printf("XDBDetector 初始化成功,数据库: %s", dbPath) + return detector, nil +} + +// Search 搜索IP信息 +func (d *XDBDetector) Search(ip string) (string, error) { + if d.searcher == nil { + return "", fmt.Errorf("搜索器未初始化") + } + + // 验证IP格式 + if net.ParseIP(ip) == nil { + return "", fmt.Errorf("无效的IP地址: %s", ip) + } + + return d.searcher.SearchByStr(ip) +} + +// SearchRegion 搜索并解析区域信息 +func (d *XDBDetector) SearchRegion(ip string) (*RegionInfo, error) { + regionStr, err := d.Search(ip) + if err != nil { + return nil, err + } + + return d.parseRegionString(regionStr), nil +} + +// parseRegionString 解析区域字符串 +// 格式: 国家|区域|省份|城市|ISP +func (d *XDBDetector) parseRegionString(regionStr string) *RegionInfo { + parts := strings.Split(regionStr, "|") + + // 确保有足够的字段 + for len(parts) < 5 { + parts = append(parts, "") + } + + country := strings.TrimSpace(parts[0]) + region := strings.TrimSpace(parts[1]) + province := strings.TrimSpace(parts[2]) + city := strings.TrimSpace(parts[3]) + isp := strings.TrimSpace(parts[4]) + + isChinaMainland := d.isChinaMainland(country) + isOverseas := !isChinaMainland + + // 特别行政区处理 + if !isChinaMainland && d.isChinaSpecialRegion(country) { + // 可以根据业务需求调整特别行政区是否视为海外 + isOverseas = false + } + + return &RegionInfo{ + Country: country, + Region: region, + Province: province, + City: city, + ISP: isp, + IsOverseas: isOverseas, + IsChinaMainland: isChinaMainland, + } +} + +// IsOverseasIP 判断是否为海外IP +func (d *XDBDetector) IsOverseasIP(ip string) (bool, error) { + // 检查缓存 + if cached, ok := d.getFromCache(ip); ok { + return cached, nil + } + + // 内网IP默认为国内 + if d.isPrivateIP(ip) { + d.setCache(ip, false) + return false, nil + } + + regionInfo, err := d.SearchRegion(ip) + if err != nil { + return false, err + } + + d.setCache(ip, regionInfo.IsOverseas) + return regionInfo.IsOverseas, nil +} + +// isChinaMainland 判断是否为中国大陆 +func (d *XDBDetector) isChinaMainland(country string) bool { + if country == "" { + return false + } + + chinaKeywords := []string{"中国", "China", "CN"} + for _, keyword := range chinaKeywords { + if country == keyword { + return true + } + } + + lowerCountry := strings.ToLower(country) + for _, keyword := range []string{"china", "cn", "中国"} { + if strings.Contains(lowerCountry, keyword) { + return true + } + } + + return false +} + +// isChinaSpecialRegion 判断是否为中国特别行政区 +func (d *XDBDetector) isChinaSpecialRegion(region string) bool { + specialRegions := []string{ + "台湾", "台湾省", "Taiwan", "TW", + "香港", "Hong Kong", "HK", + "澳门", "Macao", "Macau", "MO", + } + + for _, keyword := range specialRegions { + if region == keyword { + return true + } + } + + return false +} + +// BatchIsOverseas 批量判断海外IP +func (d *XDBDetector) BatchIsOverseas(ips []string) (map[string]bool, error) { + results := make(map[string]bool) + + for _, ip := range ips { + isOverseas, err := d.IsOverseasIP(ip) + if err != nil { + // 失败时默认为海外(安全策略) + results[ip] = true + continue + } + results[ip] = isOverseas + } + + return results, nil +} + +// isPrivateIP 判断内网IP +func (d *XDBDetector) isPrivateIP(ip string) bool { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false + } + + privateBlocks := []string{ + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", + "127.0.0.0/8", "169.254.0.0/16", + "::1/128", "fc00::/7", "fe80::/10", + } + + for _, block := range privateBlocks { + _, ipNet, err := net.ParseCIDR(block) + if err == nil && ipNet.Contains(parsedIP) { + return true + } + } + + return false +} + +// 缓存管理 +func (d *XDBDetector) getFromCache(ip string) (bool, bool) { + if item, ok := d.cache.Load(ip); ok { + if cacheItem, ok := item.(*cacheResult); ok { + if time.Since(cacheItem.timestamp) < d.cacheTTL { + return cacheItem.isOverseas, true + } + d.cache.Delete(ip) + } + } + return false, false +} + +func (d *XDBDetector) setCache(ip string, isOverseas bool) { + d.cache.Store(ip, &cacheResult{ + isOverseas: isOverseas, + timestamp: time.Now(), + }) +} + +type cacheResult struct { + isOverseas bool + timestamp time.Time +} + +// Close 关闭检测器 +func (d *XDBDetector) Close() { + if d.searcher != nil { + d.searcher.Close() + } +} + +// findXDBFile 查找 xdb 文件 +func findXDBFile() string { + paths := []string{ + "ip2region.xdb", + "data/ip2region.xdb", + "./ip2region.xdb", + "../ip2region.xdb", + "/usr/local/share/ip2region/ip2region.xdb", + } + + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +// ResolveClientIP 解析客户端 IP,用于 m3u8 限流、token 签发与 ipstat 统计。 +// CDN/LB 链路下 Gin ClientIP 常返回离源站最近的节点 IP(如 CloudFront); +// 此时优先取 X-Forwarded-For 最左侧公网 IP(真实访客)。 +func ResolveClientIP(c *gin.Context) string { + if c == nil { + return "" + } + remoteIP := normalizeIPString(c.RemoteIP()) + clientIP := normalizeIPString(c.ClientIP()) + + if shouldTrustForwardedHeaders(remoteIP, clientIP) { + if ip := firstPublicIPFromHeader(c.GetHeader("CF-Connecting-IP")); ip != "" { + return ip + } + if ip := firstPublicIPFromHeader(c.GetHeader("X-Forwarded-For")); ip != "" { + return ip + } + if ip := normalizeIPString(c.GetHeader("X-Real-IP")); ip != "" && !isPrivateOrLoopbackIP(ip) { + return ip + } + } + + if clientIP != "" && !isPrivateOrLoopbackIP(clientIP) { + return clientIP + } + if clientIP != "" { + return clientIP + } + return remoteIP +} + +// shouldTrustForwardedHeaders 判断是否处于反代/CDN 链路,可信任 XFF/X-Real-IP。 +// 公网直连时不信任,避免客户端伪造 XFF。 +func shouldTrustForwardedHeaders(remoteIP, clientIP string) bool { + if isPrivateOrLoopbackIP(remoteIP) { + return true + } + // Gin 从 XFF 解析出的 ClientIP 与 TCP 直连 IP 不同,说明经过了 LB/CDN。 + return clientIP != "" && remoteIP != "" && clientIP != remoteIP +} + +func normalizeIPString(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + ip := net.ParseIP(raw) + if ip == nil { + return raw + } + if v4 := ip.To4(); v4 != nil { + return v4.String() + } + return ip.String() +} + +func firstPublicIPFromHeader(xff string) string { + xff = strings.TrimSpace(xff) + if xff == "" { + return "" + } + for _, part := range strings.Split(xff, ",") { + ip := normalizeIPString(strings.TrimSpace(part)) + if ip != "" && !isPrivateOrLoopbackIP(ip) { + return ip + } + } + return "" +} + +func isPrivateOrLoopbackIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} diff --git a/pub/common/laosiji/client.go b/pub/common/laosiji/client.go new file mode 100644 index 0000000..d58bb5c --- /dev/null +++ b/pub/common/laosiji/client.go @@ -0,0 +1,115 @@ +package laosiji + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var ( + // LaosijiDomains 老司机上传凭证缓存。由 web server 初始化。 + LaosijiDomains *Domains + // LaosijiClient 老司机业务 API 客户端。由 web server 初始化。 + LaosijiClient *Client +) + +// Client 老司机「业务 API」客户端(区别于上传站 upload_url)。 +type Client struct { + baseURL string + appid string + appkey string + hc *http.Client +} + +// New 创建业务 API 客户端。baseURL 含 /lsjapi 前缀。 +func New(baseURL, appid, appkey string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + appid: appid, + appkey: appkey, + hc: &http.Client{Timeout: 40 * time.Second}, + } +} + +// SetHTTPClient 替换底层 http.Client(测试/自定义超时用)。 +func (c *Client) SetHTTPClient(hc *http.Client) { c.hc = hc } + +type apiResp struct { + Status string `json:"status"` + Data string `json:"data"` + Error string `json:"error"` +} + +// ErrEmptyData status=y 但 data 为空。 +var ErrEmptyData = errors.New("laosiji: empty response data") + +// Call 发起一次业务 API 调用。 +func (c *Client) Call(ctx context.Context, path string, req, out interface{}) error { + reqJSON := []byte("{}") + if req != nil { + b, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("laosiji: marshal req: %w", err) + } + reqJSON = b + } + encBody, err := EncryptBase64(reqJSON, c.appkey) + if err != nil { + return fmt.Errorf("laosiji: encrypt req: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(encBody)) + if err != nil { + return err + } + httpReq.Header.Set("appid", c.appid) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.hc.Do(httpReq) + if err != nil { + return fmt.Errorf("laosiji: http do: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("laosiji: read resp: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("laosiji: http %d: %s", resp.StatusCode, truncate(body, 200)) + } + + var outer apiResp + if err := json.Unmarshal(body, &outer); err != nil { + return fmt.Errorf("laosiji: unmarshal outer: %w (body=%s)", err, truncate(body, 200)) + } + if outer.Status != "y" { + return fmt.Errorf("laosiji: api status=%q error=%q", outer.Status, outer.Error) + } + if out == nil { + return nil + } + if outer.Data == "" { + return ErrEmptyData + } + plain, err := DecryptBase64(outer.Data, c.appkey) + if err != nil { + return fmt.Errorf("laosiji: decrypt data: %w", err) + } + if err := json.Unmarshal(plain, out); err != nil { + return fmt.Errorf("laosiji: unmarshal data: %w (plain=%s)", err, truncate(plain, 200)) + } + return nil +} + +func truncate(b []byte, n int) string { + if len(b) > n { + return string(b[:n]) + "..." + } + return string(b) +} diff --git a/pub/common/laosiji/crypto.go b/pub/common/laosiji/crypto.go new file mode 100644 index 0000000..e9550fd --- /dev/null +++ b/pub/common/laosiji/crypto.go @@ -0,0 +1,89 @@ +package laosiji + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "errors" +) + +var ( + ErrInvalidCipher = errors.New("laosiji crypto: invalid ciphertext length") + ErrInvalidPadding = errors.New("laosiji crypto: invalid pkcs7 padding") +) + +func AesKey(appkey string) []byte { + key := make([]byte, 16) + copy(key, appkey) + return key +} + +func EncryptBase64(plain []byte, appkey string) (string, error) { + block, err := aes.NewCipher(AesKey(appkey)) + if err != nil { + return "", err + } + padded := pkcs7Pad(plain, block.BlockSize()) + out := make([]byte, len(padded)) + ecbEncrypt(block, out, padded) + return base64.StdEncoding.EncodeToString(out), nil +} + +func DecryptBase64(b64 string, appkey string) ([]byte, error) { + ct, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(AesKey(appkey)) + if err != nil { + return nil, err + } + bs := block.BlockSize() + if len(ct) == 0 || len(ct)%bs != 0 { + return nil, ErrInvalidCipher + } + out := make([]byte, len(ct)) + ecbDecrypt(block, out, ct) + return pkcs7Unpad(out, bs) +} + +func ecbEncrypt(block cipher.Block, dst, src []byte) { + bs := block.BlockSize() + for len(src) > 0 { + block.Encrypt(dst[:bs], src[:bs]) + src = src[bs:] + dst = dst[bs:] + } +} + +func ecbDecrypt(block cipher.Block, dst, src []byte) { + bs := block.BlockSize() + for len(src) > 0 { + block.Decrypt(dst[:bs], src[:bs]) + src = src[bs:] + dst = dst[bs:] + } +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + pad := blockSize - len(data)%blockSize + return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...) +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + n := len(data) + if n == 0 || n%blockSize != 0 { + return nil, ErrInvalidPadding + } + pad := int(data[n-1]) + if pad == 0 || pad > blockSize { + return nil, ErrInvalidPadding + } + for i := n - pad; i < n; i++ { + if int(data[i]) != pad { + return nil, ErrInvalidPadding + } + } + return data[:n-pad], nil +} diff --git a/pub/common/laosiji/domains.go b/pub/common/laosiji/domains.go new file mode 100644 index 0000000..6c818db --- /dev/null +++ b/pub/common/laosiji/domains.go @@ -0,0 +1,77 @@ +package laosiji + +import ( + "context" + "sync" + "time" +) + +const SystemDomainsPath = "/lsjapi/system/domains" + +type SystemDomains struct { + ImgFreeCdn string `json:"img_free_cdn"` + ImgVipCdn string `json:"img_vip_cdn"` + ImgCnCdn string `json:"img_cn_cdn"` + ImgSourceCdn string `json:"img_source_cdn"` + MovieFreeCdn string `json:"movie_free_cdn"` + MovieVipCdn string `json:"movie_vip_cdn"` + MovieCnCdn string `json:"movie_cn_cdn"` + MovieSourceCdn string `json:"movie_source_cdn"` + UploadURL string `json:"upload_url"` + MediaDir string `json:"media_dir"` + UploadKey string `json:"upload_key"` + UserID string `json:"user_id"` +} + +type Domains struct { + client *Client + + mu sync.RWMutex + cur *SystemDomains + fetchedAt time.Time +} + +func NewDomains(client *Client) *Domains { + return &Domains{client: client} +} + +func (d *Domains) Refresh(ctx context.Context) (*SystemDomains, error) { + var sd SystemDomains + if err := d.client.Call(ctx, SystemDomainsPath, map[string]string{}, &sd); err != nil { + return nil, err + } + d.mu.Lock() + d.cur = &sd + d.fetchedAt = time.Now() + d.mu.Unlock() + return &sd, nil +} + +func (d *Domains) Get(ctx context.Context) (*SystemDomains, error) { + d.mu.RLock() + cur := d.cur + d.mu.RUnlock() + if cur != nil { + return cur, nil + } + return d.Refresh(ctx) +} + +func (d *Domains) StartAutoRefresh(interval time.Duration) (stop func()) { + done := make(chan struct{}) + go func() { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-done: + return + case <-t.C: + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + _, _ = d.Refresh(ctx) + cancel() + } + } + }() + return func() { close(done) } +} diff --git a/pub/common/laosiji/transcoding.go b/pub/common/laosiji/transcoding.go new file mode 100644 index 0000000..bfaa00e --- /dev/null +++ b/pub/common/laosiji/transcoding.go @@ -0,0 +1,292 @@ +package laosiji + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + videoTranscodingVersion = "2.0" + transcodeSuccessStatus = 5 + transcodeNoNeedStatus = 4 + transcodeFailedStatus = -1 + transcodeQueueLimit = 20 +) + +var errTranscodeTaskNotFound = errors.New("laosiji transcode task not found") + +type uploadAPIResp struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` + Time string `json:"time"` + Error string `json:"error"` + ErrorCode int `json:"errorCode"` +} + +type transcodeTask struct { + ID string `json:"id"` + FileID string `json:"file_id"` + FileURL string `json:"file_url"` + Status int `json:"status"` + Duration int `json:"duration"` + Height int `json:"height"` + Width int `json:"width"` + TranscodeError string `json:"transcode_error"` + TranscodeFile string `json:"transcode_file"` + PreviewVtt string `json:"preview_vtt"` + AudioFile string `json:"audio_file"` + SmartCover string `json:"smart_cover"` + PreviewDir string `json:"preview_dir"` + SrcMetaInfo *MetaInfo `json:"src_meta_info"` + HevcMetaInfo *MetaInfo `json:"hevc_meta_info"` +} + +// MetaInfo 视频元信息(src_meta_info / hevc_meta_info 共用结构)。 +type MetaInfo struct { + Codec string `json:"codec"` + Profile string `json:"profile"` + Width int `json:"width"` + Height int `json:"height"` + Fps float64 `json:"fps"` + VideoBitrate int `json:"video_bitrate"` + PixFmt string `json:"pix_fmt"` + AudioCodec string `json:"audio_codec"` + AudioBitrate int `json:"audio_bitrate"` + AudioSampleRate int `json:"audio_sample_rate"` + AudioChannels int `json:"audio_channels"` + Bitrate int `json:"bitrate"` + Size int64 `json:"size"` + Duration float64 `json:"duration"` +} + +type transcodeQueueInfo struct { + Waiting int `json:"waiting"` + Done int `json:"done"` + Error int `json:"error"` +} + +// TranscodeResult 转码任务状态快照。 +type TranscodeResult struct { + Status int + Done bool + Failed bool + HevcURL string + PreviewVtt string + AudioFile string + SmartCover string + HevcMeta *MetaInfo +} + +// ResolveTranscodeFileID 优先使用媒资 fileId,缺失时回退为 fileURL 的 md5。 +func ResolveTranscodeFileID(fileID, fileURL string) string { + if id := strings.TrimSpace(fileID); id != "" { + return id + } + return md5Hex(fileURL) +} + +func (c *Client) SubmitTranscoding(ctx context.Context, fileURL, fileID string, domains SystemDomains, videoId int64) (TranscodeResult, error) { + fileURL = strings.TrimSpace(fileURL) + if fileURL == "" { + return TranscodeResult{}, fmt.Errorf("empty transcode source url") + } + fileID = ResolveTranscodeFileID(fileID, fileURL) + + task, err := c.queryVideoTranscoding(ctx, domains, fileID) + if err == nil { + return taskToResult(task), nil + } + if !errors.Is(err, errTranscodeTaskNotFound) { + return TranscodeResult{}, err + } + task, err = c.createVideoTranscoding(ctx, domains, fileID, fileURL, videoId) + if err != nil { + return TranscodeResult{}, err + } + return taskToResult(task), nil +} + +func (c *Client) QueryTranscoding(ctx context.Context, fileURL, fileID string, domains SystemDomains) (result TranscodeResult, found bool, err error) { + fileURL = strings.TrimSpace(fileURL) + if fileURL == "" { + return TranscodeResult{}, false, fmt.Errorf("empty transcode source url") + } + fileID = ResolveTranscodeFileID(fileID, fileURL) + + task, err := c.queryVideoTranscoding(ctx, domains, fileID) + if err != nil { + if errors.Is(err, errTranscodeTaskNotFound) { + return TranscodeResult{}, false, nil + } + return TranscodeResult{}, false, err + } + return taskToResult(task), true, nil +} + +func (c *Client) createVideoTranscoding(ctx context.Context, domains SystemDomains, fileID, fileURL string, videoId int64) (transcodeTask, error) { + if q, err := c.getVideoTranscodingQueue(ctx, domains); err == nil && q.Waiting >= transcodeQueueLimit { + return transcodeTask{}, fmt.Errorf("h264toh265 queue full waiting:%d limit:%d", q.Waiting, transcodeQueueLimit) + } + endpoint := joinURLPath(domains.UploadURL, "upload/videoTranscoding") + extData, _ := json.Marshal(map[string]interface{}{ + "project": "ttrgw", + "url": fileURL, + "type": "h265", + "videoId": videoId, + }) + return c.uploadAPIGet(ctx, endpoint, map[string]string{ + "v": videoTranscodingVersion, + "key": domains.UploadKey, + "file_id": fileID, + "file_url": fileURL, + "notice_url": "", + "ext_data": string(extData), + }) +} + +func (c *Client) queryVideoTranscoding(ctx context.Context, domains SystemDomains, fileID string) (transcodeTask, error) { + endpoint := joinURLPath(domains.UploadURL, "upload/queryVideoTranscoding") + return c.uploadAPIGet(ctx, endpoint, map[string]string{ + "v": videoTranscodingVersion, + "key": domains.UploadKey, + "file_id": fileID, + }) +} + +func taskToResult(task transcodeTask) TranscodeResult { + r := TranscodeResult{Status: task.Status} + switch { + case task.Status == transcodeSuccessStatus || task.Status == transcodeNoNeedStatus: + if task.Status == transcodeSuccessStatus { + if f := strings.TrimSpace(task.TranscodeFile); f != "" { + r.Done = true + r.HevcURL = joinURLPath("/laosiji/m3m", f) + } + } else { + r.Done = true + } + if r.Done { + r.PreviewVtt = laosijiPreviewURL(task.PreviewDir, task.PreviewVtt) + r.AudioFile = laosijiURL(task.AudioFile) + r.SmartCover = laosijiPreviewURL(task.PreviewDir, task.SmartCover) + r.HevcMeta = task.HevcMetaInfo + } + case task.Status <= transcodeFailedStatus: + r.Failed = true + } + return r +} + +func laosijiURL(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return joinURLPath("/laosiji", name) +} + +func laosijiPreviewURL(previewDir, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return joinURLPath(joinURLPath("/laosiji", strings.TrimSpace(previewDir)), name) +} + +func (c *Client) getVideoTranscodingQueue(ctx context.Context, domains SystemDomains) (transcodeQueueInfo, error) { + endpoint := joinURLPath(domains.UploadURL, "upload/getVideoTranscodingQueueInfo") + data, err := c.uploadAPICall(ctx, endpoint, map[string]string{ + "v": videoTranscodingVersion, + "key": domains.UploadKey, + }) + if err != nil { + return transcodeQueueInfo{}, err + } + var q transcodeQueueInfo + if len(data) > 0 { + if err = json.Unmarshal(data, &q); err != nil { + return transcodeQueueInfo{}, err + } + } + return q, nil +} + +func (c *Client) uploadAPIGet(ctx context.Context, endpoint string, params map[string]string) (transcodeTask, error) { + data, err := c.uploadAPICall(ctx, endpoint, params) + if err != nil { + return transcodeTask{}, err + } + var task transcodeTask + if len(data) > 0 { + if err = json.Unmarshal(data, &task); err != nil { + return transcodeTask{}, err + } + } + return task, nil +} + +func (c *Client) uploadAPICall(ctx context.Context, endpoint string, params map[string]string) (json.RawMessage, error) { + parsed, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + query := parsed.Query() + for k, v := range params { + query.Set(k, v) + } + parsed.RawQuery = query.Encode() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, err + } + resp, err := c.hc.Do(httpReq) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upload api http status:%d body:%s", resp.StatusCode, truncate(body, 300)) + } + var apiResp uploadAPIResp + if err = json.Unmarshal(body, &apiResp); err != nil { + return nil, err + } + if apiResp.Status != "y" { + if apiResp.ErrorCode == 2000 { + return nil, errTranscodeTaskNotFound + } + return nil, fmt.Errorf("upload api errorCode:%d error:%s", apiResp.ErrorCode, apiResp.Error) + } + return apiResp.Data, nil +} + +func md5Hex(value string) string { + sum := md5.Sum([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func joinURLPath(host, uri string) string { + host = strings.TrimRight(strings.TrimSpace(host), "/") + uri = strings.TrimLeft(strings.TrimSpace(uri), "/") + if host == "" { + return uri + } + if uri == "" { + return host + } + return host + "/" + uri +} diff --git a/pub/common/linode/linode.go b/pub/common/linode/linode.go new file mode 100644 index 0000000..b43230c --- /dev/null +++ b/pub/common/linode/linode.go @@ -0,0 +1,212 @@ +package linode + +import ( + "bytes" + "errors" + "io" + "mime" + "os" + "path" + "path/filepath" + "videoweb/pub/common/fconst" + "videoweb/pub/common/log" + + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/karrick/godirwalk" + "github.com/xxjwxc/gowp/workpool" +) + +const ( + //region = "default" + //endpoint = "https://ap-south-1.linodeobjects.com" + //accessKey = "0RALF74H45ANVYPRQA1Y" + //secretKey = "S1yCKUqxFJHqlFPZXJ3BbD8T6P0Yz6loUDntUrsS" + //bucket = "fengshang" + M = 1024 * 1024 + G = M * 1024 + defaultConcurrency = 150 + defaultTryMax = 5 + defaultContentType = "application/octet-stream" +) + +type Client struct { + session *session.Session + cfg Config +} +type UploadReq struct { + Key string + ContentType string + Data []byte +} + +func (u *UploadReq) Valid() bool { + return u.Key != "" && len(u.Data) != 0 +} + +type Config struct { + Region string + Endpoint string + AccessKey string + SecretKey string + Bucket string + Concurrency int + TryMax int +} + +func traverseDir(d string) ([]string, error) { + var fileNames []string + fn := func(name string, entry *godirwalk.Dirent) error { + if !entry.IsRegular() { + return nil + } + fileNames = append(fileNames, name) + return nil + } + pathName := d + err := godirwalk.Walk(pathName, &godirwalk.Options{ + Unsorted: true, + Callback: fn, + }) + if err != nil { + log.Info("list files failed", log.Any("path", pathName), log.E(err)) + return nil, err + } + return fileNames, nil +} + +func New(cfg Config) (*Client, error) { + if cfg.TryMax <= 0 { + cfg.TryMax = defaultTryMax + } + if cfg.Concurrency <= 0 { + cfg.Concurrency = defaultConcurrency + } + s, err := session.NewSession(&aws.Config{ + Region: aws.String(cfg.Region), + Endpoint: aws.String(cfg.Endpoint), + Credentials: credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, ""), + }) + if err != nil { + log.Error("Linode NewSession err", log.E(err)) + return nil, err + } + return &Client{session: s, cfg: cfg}, nil +} + +//func WithDefault() (*Client, error) { +// return New(&Config{Concurrency: defaultConcurrency, TryMax: defaultTryMax}) +//} + +func (c *Client) UploadFolder(folderName string) error { + absFiles, err := traverseDir(folderName) + if err != nil { + return err + } + if len(absFiles) == 0 { + return nil + } + wp := workpool.New(c.cfg.Concurrency) + for _, absFilename := range absFiles { + var absName = absFilename + wp.Do(func() error { + var err error + for i := 0; i < c.cfg.TryMax; i++ { + start := time.Now() + err = c.uploadToLinNode(absName) + diff := time.Now().Sub(start) + if diff < time.Second { + time.Sleep(time.Second - diff) + } + if err == nil { + break + } + } + return err + }) + } + return wp.Wait() +} + +func (c *Client) UploadRaw(key, contentType string, body io.Reader) (string, error) { + if contentType == "" { + contentType = defaultContentType + } + input := &s3manager.UploadInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(key), + ACL: aws.String("public-read"), + Body: body, + ContentType: aws.String(contentType), + } + result, err := s3manager.NewUploader(c.session, func(uploader *s3manager.Uploader) { + uploader.PartSize = 1 * G + }).Upload(input) + if err != nil { + log.Info("UpdateRaw Upload Err", log.Any("key", key), log.E(err)) + return "", err + } + log.Info("UpdateRaw Upload OK", log.Any("key", key), log.Any("Location", result.Location)) + return "", nil +} +func (c *Client) Upload(req UploadReq) error { + if !req.Valid() { + return errors.New("invalid upload request") + } + if req.ContentType == "" { + req.ContentType = defaultContentType + ext := path.Ext(req.Key) + if ext != "" { + req.ContentType = mime.TypeByExtension(ext) + } + } + var err error + for i := 0; i < defaultTryMax; i++ { + buf := bytes.NewReader(req.Data) + input := &s3.PutObjectInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(req.Key), + ACL: aws.String("public-read"), + Body: buf, + ContentType: aws.String(req.ContentType), + } + + _, err := s3.New(c.session).PutObject(input) + if err == nil { + log.Info("Linode Upload OK", log.Any("key", req.Key)) + return nil + } + time.Sleep(time.Millisecond) //出让CPU + log.Error("Linode Upload Try err", log.Any("key", req.Key), log.Any("tryCount", i+1), log.E(err)) + } + log.Error("Linode Upload err", log.Any("key", req.Key), log.E(err)) + return err +} + +// 多线程调用 +func (m *Client) uploadToLinNodeWithKey(fileName, key string) error { + f, err := os.OpenFile(fileName, os.O_RDONLY, 0644) + if err != nil { + log.Info("uploadToLinNodeWithKey OpenFile", log.Any("fileName", fileName), log.E(err)) + return err + } + defer func() { _ = f.Close() }() + ext := filepath.Ext(fileName) + contentType := "" + if ext != "" { + contentType = mime.TypeByExtension(ext) + } + _, err = m.UploadRaw(key, contentType, f) + return err +} + +func (m *Client) uploadToLinNode(absFilename string) error { + key := strings.TrimPrefix(absFilename, fconst.FsDir) + return m.uploadToLinNodeWithKey(absFilename, key) +} diff --git a/pub/common/linode/linode_test.go b/pub/common/linode/linode_test.go new file mode 100644 index 0000000..6d94a97 --- /dev/null +++ b/pub/common/linode/linode_test.go @@ -0,0 +1,28 @@ +package linode + +import ( + "errors" + "fmt" + "github.com/xxjwxc/gowp/workpool" + "testing" +) + +func TestAA(t *testing.T) { + wp := workpool.New(100) + for i := 0; i < 200000; i++ { + var t = i + wp.Do(func() error { + fmt.Println(t) + if t == 22 { + return errors.New("111") + } + return nil + }) + } + err := wp.Wait() + if err != nil { + fmt.Println("fail", err) + } else { + fmt.Println("OK") + } +} diff --git a/pub/common/lock/cache.go b/pub/common/lock/cache.go new file mode 100644 index 0000000..5d8a314 --- /dev/null +++ b/pub/common/lock/cache.go @@ -0,0 +1,86 @@ +package lock + +import ( + "sync" +) + +type IIdent interface { + GetID() int64 +} + +type Cache[T IIdent] struct { + dict map[int64]T + lock sync.RWMutex +} + +func NewCache[T IIdent]() *Cache[T] { + return &Cache[T]{ + dict: make(map[int64]T), + } +} + +func (c *Cache[T]) Get(Id int64) (T, bool) { + c.lock.RLock() + defer c.lock.RUnlock() + t, ok := c.dict[Id] + return t, ok +} + +func (c *Cache[T]) Keys() []int64 { + c.lock.RLock() + defer c.lock.RUnlock() + keys := make([]int64, 0, len(c.dict)) + for k := range c.dict { + keys = append(keys, k) + } + return keys +} + +func (c *Cache[T]) Set(items ...T) { + c.lock.Lock() + defer c.lock.Unlock() + for _, item := range items { + c.dict[item.GetID()] = item + } +} + +func (c *Cache[T]) Del(items ...T) { + c.lock.Lock() + defer c.lock.Unlock() + for _, item := range items { + delete(c.dict, item.GetID()) + } +} + +func (c *Cache[T]) Update(dict map[int64]T) { + c.lock.Lock() + defer c.lock.Unlock() + c.dict = dict +} + +func (c *Cache[T]) SetItems(items []T) { + c.lock.Lock() + defer c.lock.Unlock() + for _, item := range items { + c.dict[item.GetID()] = item + } +} + +func (c *Cache[T]) Has(Id int64) bool { + c.lock.RLock() + defer c.lock.RUnlock() + _, ok := c.dict[Id] + return ok +} + +func (c *Cache[T]) GetItems(Ids []int64) []T { + ret := make([]T, 0) + c.lock.RLock() + defer c.lock.RUnlock() + for _, id := range Ids { + if item, ok := c.dict[id]; ok { + ret = append(ret, item) + } + } + return ret +} diff --git a/pub/common/lock/dict.go b/pub/common/lock/dict.go new file mode 100644 index 0000000..10b7979 --- /dev/null +++ b/pub/common/lock/dict.go @@ -0,0 +1,143 @@ +package lock + +import ( + "sync" +) + +type Dict[K comparable, V any] struct { + dict map[K]V + lock sync.RWMutex +} + +func NewDict[K comparable, V any]() *Dict[K, V] { + return &Dict[K, V]{ + dict: make(map[K]V), + } +} + +// Count get dict key count +func (d *Dict[K, V]) Count() int { + d.lock.RLock() + defer d.lock.RUnlock() + return len(d.dict) +} + +// Keys get dict keys +func (d *Dict[K, V]) Keys() []K { + d.lock.RLock() + defer d.lock.RUnlock() + keys := make([]K, 0, len(d.dict)) + for k := range d.dict { + keys = append(keys, k) + } + return keys +} + +// Set set k v to dict, overwrite old value if exists +func (d *Dict[K, V]) Set(key K, value V) { + d.lock.Lock() + defer d.lock.Unlock() + d.dict[key] = value +} + +// Has if exists, return true +func (d *Dict[K, V]) Has(key K) bool { + d.lock.RLock() + defer d.lock.RUnlock() + _, exists := d.dict[key] + return exists +} + +// Get if exists, return value and true +func (d *Dict[K, V]) Get(key K) (V, bool) { + d.lock.RLock() + defer d.lock.RUnlock() + value, exists := d.dict[key] + return value, exists +} + +// Del if exists and del success, return true +func (d *Dict[K, V]) Del(key K) bool { + d.lock.Lock() + defer d.lock.Unlock() + _, exists := d.dict[key] + delete(d.dict, key) + return exists +} + +// Remove if exists and remove success, return old value and true +func (d *Dict[K, V]) Remove(key K) (V, bool) { + d.lock.Lock() + defer d.lock.Unlock() + value, exists := d.dict[key] + delete(d.dict, key) + return value, exists +} + +// SetIfNotExists if not exists and set success, return true +func (d *Dict[K, V]) SetIfNotExists(key K, value V) bool { + d.lock.Lock() + defer d.lock.Unlock() + _, exists := d.dict[key] + if !exists { + d.dict[key] = value + return true + } + return false +} + +// GetOrSetIfNotExists if key exists return old:true, or, set new value, and return new:false +func (d *Dict[K, V]) GetOrSetIfNotExists(key K, new V) (V, bool) { + d.lock.Lock() + defer d.lock.Unlock() + old, exists := d.dict[key] + if !exists { + d.dict[key] = new + return new, false + } + return old, true +} + +// Travel travels all dict keys and values +func (d *Dict[K, V]) Travel(travel func(key K, value V) bool) { + d.lock.RLock() + defer d.lock.RUnlock() + for key, value := range d.dict { + cont := travel(key, value) + if !cont { + break + } + } +} + +// Clear dict directly +func (d *Dict[K, V]) Clear() { + d.lock.Lock() + defer d.lock.Unlock() + d.dict = map[K]V{} +} + +// Update replace dict directly +func (d *Dict[K, V]) Update(dict map[K]V) { + d.lock.Lock() + defer d.lock.Unlock() + d.dict = dict +} + +// Replace replace dict directly and return old dict +func (d *Dict[K, V]) Replace(dict map[K]V) map[K]V { + d.lock.Lock() + defer d.lock.Unlock() + old := d.dict + d.dict = dict + return old +} + +// Append dict directly +func (d *Dict[K, V]) Append(dict map[K]V) { + d.lock.Lock() + defer d.lock.Unlock() + for k, v := range dict { + d.dict[k] = v + } +} diff --git a/pub/common/lock/queue.go b/pub/common/lock/queue.go new file mode 100644 index 0000000..531d557 --- /dev/null +++ b/pub/common/lock/queue.go @@ -0,0 +1,75 @@ +package lock + +import "sync" + +// Queue 定义一个泛型队列结构 +type Queue[T any] struct { + items []T + lock sync.RWMutex // 添加读写锁 +} + +// NewQueue 创建一个新的队列 +func NewQueue[T any]() *Queue[T] { + return &Queue[T]{ + items: make([]T, 0), + } +} + +// Enqueue 入队操作 - 将元素添加到队列末尾 +func (q *Queue[T]) Enqueue(item T) { + q.lock.Lock() // 写操作需要写锁 + defer q.lock.Unlock() + + q.items = append(q.items, item) +} + +// Dequeue 出队操作 - 从队列头部移除并返回元素 +func (q *Queue[T]) Dequeue() (T, bool) { + q.lock.Lock() // 写操作需要写锁 + defer q.lock.Unlock() + + var zero T + if len(q.items) == 0 { + return zero, false + } + + item := q.items[0] + q.items = q.items[1:] + return item, true +} + +// Peek 查看队列头部元素但不移除 +func (q *Queue[T]) Peek() (T, bool) { + q.lock.RLock() // 读操作使用读锁 + defer q.lock.RUnlock() + + var zero T + if len(q.items) == 0 { + return zero, false + } + return q.items[0], true +} + +// IsEmpty 检查队列是否为空 +func (q *Queue[T]) IsEmpty() bool { + q.lock.RLock() // 读操作使用读锁 + defer q.lock.RUnlock() + + return len(q.items) == 0 +} + +// Size 返回队列中的元素个数 +func (q *Queue[T]) Size() int { + q.lock.RLock() // 读操作使用读锁 + defer q.lock.RUnlock() + + return len(q.items) +} + +// Clear 清空队列 +func (q *Queue[T]) Clear() { + q.lock.Lock() // 写操作需要写锁 + defer q.lock.Unlock() + + q.items = make([]T, 0) +} diff --git a/pub/common/log/log.go b/pub/common/log/log.go new file mode 100644 index 0000000..29ca8d3 --- /dev/null +++ b/pub/common/log/log.go @@ -0,0 +1,128 @@ +package log + +import ( + "context" + "fmt" + "log" + "os" + "time" + "videoweb/pub/common/log/zapcorex" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var ( + zapLogger *zap.Logger + zapLoggerS *zap.SugaredLogger + ZapLog *zap.Logger + ZapLogS *zap.SugaredLogger + Debug = zapLogger.Debug + Info = zapLogger.Info + Warn = zapLogger.Warn + Error = zapLogger.Error + Panic = zapLogger.Panic + Fatal = zapLogger.Fatal + Debugf = zapLoggerS.Debugf + Infof = zapLoggerS.Infof + Warnf = zapLoggerS.Warnf + Errorf = zapLoggerS.Errorf + Fatalf = zapLoggerS.Fatalf + Debugw = zapLoggerS.Debugw + Infow = zapLoggerS.Infow + Warnw = zapLoggerS.Warnw + Errorw = zapLoggerS.Errorw + Fatalw = zapLoggerS.Fatalw +) + +func newEncoderConfig() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + // Keys can be anything except the empty string. + TimeKey: "T", + LevelKey: "L", + NameKey: "N", + CallerKey: "C", + MessageKey: "M", + StacktraceKey: "S", + FunctionKey: "F", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalColorLevelEncoder, + EncodeTime: timeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + ConsoleSeparator: " ", + } +} + +func init() { + core := zapcore.NewCore( + zapcorex.NewConsoleEncoder(newEncoderConfig()), + zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout)), + zap.DebugLevel, + ) + zapLogger = zap.New(core, zap.AddCaller()) + zapLoggerS = zapLogger.Sugar() + ZapLog = zapLogger + ZapLogS = zapLoggerS + Debug = zapLogger.Debug + Info = zapLogger.Info + Warn = zapLogger.Warn + Error = zapLogger.Error + Panic = zapLogger.Panic + Fatal = zapLogger.Fatal + Debugf = zapLoggerS.Debugf + Infof = zapLoggerS.Infof + Warnf = zapLoggerS.Warnf + Errorf = zapLoggerS.Errorf + Fatalf = zapLoggerS.Fatalf + Debugw = zapLoggerS.Debugw + Infow = zapLoggerS.Infow + Warnw = zapLoggerS.Warnw + Errorw = zapLoggerS.Errorw + Fatalw = zapLoggerS.Fatalw +} + +func timeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.In(time.Local).Format("2006-01-02 15:04:05")) +} + +type Field = zap.Field + +func Any(key string, value interface{}) Field { + return zap.Any(key, value) +} + +func E(err error) Field { + return Any("err", err) +} + +type contextKey int64 + +var ( + loggerContextKey contextKey +) + +func ContextWithLogger(ctx context.Context, l *zap.SugaredLogger) context.Context { + return context.WithValue(ctx, loggerContextKey, l) +} + +func LoggerFromContext(ctx context.Context) *zap.SugaredLogger { + v := ctx.Value(loggerContextKey) + if v == nil { + return ZapLogS + } + return v.(*zap.SugaredLogger) +} + +type Logger struct{} + +// Info logs routine messages about cron's operation. +func (s *Logger) Info(msg string, keysAndValues ...interface{}) { + _ = log.Default().Output(2, msg+" "+fmt.Sprint(keysAndValues...)) +} + +// Error logs an error condition. +func (s *Logger) Error(err error, msg string, keysAndValues ...interface{}) { + line := fmt.Sprintf("%s %v %s", msg, err, fmt.Sprint(keysAndValues...)) + _ = log.Default().Output(2, line) +} diff --git a/pub/common/log/zapcorex/bufferpool/bufferpool.go b/pub/common/log/zapcorex/bufferpool/bufferpool.go new file mode 100644 index 0000000..dad583a --- /dev/null +++ b/pub/common/log/zapcorex/bufferpool/bufferpool.go @@ -0,0 +1,31 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package bufferpool houses zap's shared internal buffer pool. Third-party +// packages can recreate the same functionality with buffers.NewPool. +package bufferpool + +import "go.uber.org/zap/buffer" + +var ( + _pool = buffer.NewPool() + // Get retrieves a buffer from the pool, creating one if necessary. + Get = _pool.Get +) diff --git a/pub/common/log/zapcorex/color.go b/pub/common/log/zapcorex/color.go new file mode 100644 index 0000000..6b8faa7 --- /dev/null +++ b/pub/common/log/zapcorex/color.go @@ -0,0 +1,44 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package color adds coloring functionality for TTY output. +package zapcorex + +import "fmt" + +// Foreground colors. +const ( + Black Color = iota + 30 + Red + Green + Yellow + Blue + Magenta + Cyan + White +) + +// Color represents a text color. +type Color uint8 + +// Add adds the coloring to the given string. +func (c Color) Add(s string) string { + return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) +} diff --git a/pub/common/log/zapcorex/console_encoder.go b/pub/common/log/zapcorex/console_encoder.go new file mode 100644 index 0000000..51ea796 --- /dev/null +++ b/pub/common/log/zapcorex/console_encoder.go @@ -0,0 +1,150 @@ +package zapcorex + +import ( + "fmt" + "strings" + "sync" + + "videoweb/pub/common/log/zapcorex/bufferpool" + + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" +) + +var _sliceEncoderPool = sync.Pool{ + New: func() interface{} { + return &sliceArrayEncoder{elems: make([]interface{}, 0, 2)} + }, +} + +func getSliceEncoder() *sliceArrayEncoder { + return _sliceEncoderPool.Get().(*sliceArrayEncoder) +} + +func putSliceEncoder(e *sliceArrayEncoder) { + e.elems = e.elems[:0] + _sliceEncoderPool.Put(e) +} + +type consoleEncoder struct { + *jsonEncoder +} + +// NewConsoleEncoder creates an encoder whose output is designed for human - +// rather than machine - consumption. It serializes the core log entry data +// (message, level, timestamp, etc.) in a plain-text format and leaves the +// structured context as JSON. +// +// Note that although the console encoder doesn't use the keys specified in the +// encoder configuration, it will omit any element whose key is set to the empty +// string. +func NewConsoleEncoder(cfg zapcore.EncoderConfig) zapcore.Encoder { + if cfg.ConsoleSeparator == "" { + // Use a default delimiter of '\t' for backwards compatibility + cfg.ConsoleSeparator = "\t" + } + return consoleEncoder{newJSONEncoder(cfg, true)} +} + +func (c consoleEncoder) Clone() zapcore.Encoder { + return consoleEncoder{c.jsonEncoder.Clone().(*jsonEncoder)} +} + +func (c consoleEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + line := bufferpool.Get() + + // We don't want the entry's metadata to be quoted and escaped (if it's + // encoded as strings), which means that we can't use the JSON encoder. The + // simplest option is to use the memory encoder and fmt.Fprint. + // + // If this ever becomes a performance bottleneck, we can implement + // ArrayEncoder for our plain-text format. + arr := getSliceEncoder() + if c.TimeKey != "" && c.EncodeTime != nil { + c.EncodeTime(ent.Time, arr) + } + if c.LevelKey != "" && c.EncodeLevel != nil { + c.EncodeLevel(ent.Level, arr) + } + if ent.LoggerName != "" && c.NameKey != "" { + nameEncoder := c.EncodeName + + if nameEncoder == nil { + // Fall back to FullNameEncoder for backward compatibility. + nameEncoder = zapcore.FullNameEncoder + } + + nameEncoder(ent.LoggerName, arr) + } + + for i := range arr.elems { + if i > 0 { + line.AppendString(c.ConsoleSeparator) + } + fmt.Fprint(line, arr.elems[i]) + } + + putSliceEncoder(arr) + + // Add the message itself. + if c.MessageKey != "" { + c.addSeparatorIfNecessary(line) + line.AppendString(ent.Message) + } + + // Add any structured context. + c.writeContext(line, fields) + + if ent.Caller.Defined { + if c.FunctionKey != "" { + c.addSeparatorIfNecessary(line) + if idx := strings.LastIndex(ent.Caller.Function, "/"); idx > 0 { + line.AppendString(White.Add(ent.Caller.Function[idx+1:])) + } else { + line.AppendString(White.Add(ent.Caller.Function)) + } + } + + line.AppendString(c.ConsoleSeparator) + if c.CallerKey != "" && c.EncodeCaller != nil { + line.AppendString(White.Add(ent.Caller.TrimmedPath())) + } + } + + // If there's no stacktrace key, honor that; this allows users to force + // single-line output. + if ent.Stack != "" && c.StacktraceKey != "" { + line.AppendByte('\n') + line.AppendString(ent.Stack) + } + + line.AppendString(c.LineEnding) + return line, nil +} + +func (c consoleEncoder) writeContext(line *buffer.Buffer, extra []zapcore.Field) { + context := c.jsonEncoder.Clone().(*jsonEncoder) + defer func() { + // putJSONEncoder assumes the buffer is still used, but we write out the buffer so + // we can free it. + context.buf.Free() + putJSONEncoder(context) + }() + + addFields(context, extra) + context.closeOpenNamespaces() + if context.buf.Len() == 0 { + return + } + + c.addSeparatorIfNecessary(line) + line.AppendByte('{') + line.Write(context.buf.Bytes()) + line.AppendByte('}') +} + +func (c consoleEncoder) addSeparatorIfNecessary(line *buffer.Buffer) { + if line.Len() > 0 { + line.AppendString(c.ConsoleSeparator) + } +} diff --git a/pub/common/log/zapcorex/field.go b/pub/common/log/zapcorex/field.go new file mode 100644 index 0000000..d5f91e8 --- /dev/null +++ b/pub/common/log/zapcorex/field.go @@ -0,0 +1,9 @@ +package zapcorex + +import "go.uber.org/zap/zapcore" + +func addFields(enc zapcore.ObjectEncoder, fields []zapcore.Field) { + for i := range fields { + fields[i].AddTo(enc) + } +} diff --git a/pub/common/log/zapcorex/json_encoder.go b/pub/common/log/zapcorex/json_encoder.go new file mode 100644 index 0000000..95f8975 --- /dev/null +++ b/pub/common/log/zapcorex/json_encoder.go @@ -0,0 +1,518 @@ +package zapcorex + +import ( + "encoding/base64" + "encoding/json" + "math" + "sync" + "time" + "unicode/utf8" + + "videoweb/pub/common/log/zapcorex/bufferpool" + + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" +) + +// For JSON-escaping; see jsonEncoder.safeAddString below. +const _hex = "0123456789abcdef" + +var _jsonPool = sync.Pool{New: func() interface{} { + return &jsonEncoder{} +}} + +func getJSONEncoder() *jsonEncoder { + return _jsonPool.Get().(*jsonEncoder) +} + +func putJSONEncoder(enc *jsonEncoder) { + if enc.reflectBuf != nil { + enc.reflectBuf.Free() + } + enc.EncoderConfig = nil + enc.buf = nil + enc.spaced = false + enc.openNamespaces = 0 + enc.reflectBuf = nil + enc.reflectEnc = nil + _jsonPool.Put(enc) +} + +type jsonEncoder struct { + *zapcore.EncoderConfig + buf *buffer.Buffer + spaced bool // include spaces after colons and commas + openNamespaces int + + // for encoding generic values by reflection + reflectBuf *buffer.Buffer + reflectEnc *json.Encoder +} + +// NewJSONEncoder creates a fast, low-allocation JSON encoder. The encoder +// appropriately escapes all field keys and values. +// +// Note that the encoder doesn't deduplicate keys, so it's possible to produce +// a message like +// +// {"foo":"bar","foo":"baz"} +// +// This is permitted by the JSON specification, but not encouraged. Many +// libraries will ignore duplicate key-value pairs (typically keeping the last +// pair) when unmarshaling, but users should attempt to avoid adding duplicate +// keys. +func NewJSONEncoder(cfg zapcore.EncoderConfig) zapcore.Encoder { + return newJSONEncoder(cfg, false) +} + +func newJSONEncoder(cfg zapcore.EncoderConfig, spaced bool) *jsonEncoder { + return &jsonEncoder{ + EncoderConfig: &cfg, + buf: bufferpool.Get(), + spaced: spaced, + } +} + +func (enc *jsonEncoder) AddArray(key string, arr zapcore.ArrayMarshaler) error { + enc.addKey(key) + return enc.AppendArray(arr) +} + +func (enc *jsonEncoder) AddObject(key string, obj zapcore.ObjectMarshaler) error { + enc.addKey(key) + return enc.AppendObject(obj) +} + +func (enc *jsonEncoder) AddBinary(key string, val []byte) { + enc.AddString(key, base64.StdEncoding.EncodeToString(val)) +} + +func (enc *jsonEncoder) AddByteString(key string, val []byte) { + enc.addKey(key) + enc.AppendByteString(val) +} + +func (enc *jsonEncoder) AddBool(key string, val bool) { + enc.addKey(key) + enc.AppendBool(val) +} + +func (enc *jsonEncoder) AddComplex128(key string, val complex128) { + enc.addKey(key) + enc.AppendComplex128(val) +} + +func (enc *jsonEncoder) AddDuration(key string, val time.Duration) { + enc.addKey(key) + enc.AppendDuration(val) +} + +func (enc *jsonEncoder) AddFloat64(key string, val float64) { + enc.addKey(key) + enc.AppendFloat64(val) +} + +func (enc *jsonEncoder) AddInt64(key string, val int64) { + enc.addKey(key) + enc.AppendInt64(val) +} + +func (enc *jsonEncoder) resetReflectBuf() { + if enc.reflectBuf == nil { + enc.reflectBuf = bufferpool.Get() + enc.reflectEnc = json.NewEncoder(enc.reflectBuf) + + // For consistency with our custom JSON encoder. + enc.reflectEnc.SetEscapeHTML(false) + } else { + enc.reflectBuf.Reset() + } +} + +var nullLiteralBytes = []byte("null") + +// Only invoke the standard JSON encoder if there is actually something to +// encode; otherwise write JSON null literal directly. +func (enc *jsonEncoder) encodeReflected(obj interface{}) ([]byte, error) { + if obj == nil { + return nullLiteralBytes, nil + } + enc.resetReflectBuf() + if err := enc.reflectEnc.Encode(obj); err != nil { + return nil, err + } + enc.reflectBuf.TrimNewline() + return enc.reflectBuf.Bytes(), nil +} + +func (enc *jsonEncoder) AddReflected(key string, obj interface{}) error { + valueBytes, err := enc.encodeReflected(obj) + if err != nil { + return err + } + enc.addKey(key) + _, err = enc.buf.Write(valueBytes) + return err +} + +func (enc *jsonEncoder) OpenNamespace(key string) { + enc.addKey(key) + enc.buf.AppendByte('{') + enc.openNamespaces++ +} + +func (enc *jsonEncoder) AddString(key, val string) { + enc.addKey(key) + enc.AppendString(val) +} + +func (enc *jsonEncoder) AddTime(key string, val time.Time) { + enc.addKey(key) + enc.AppendTime(val) +} + +func (enc *jsonEncoder) AddUint64(key string, val uint64) { + enc.addKey(key) + enc.AppendUint64(val) +} + +func (enc *jsonEncoder) AppendArray(arr zapcore.ArrayMarshaler) error { + enc.addElementSeparator() + enc.buf.AppendByte('[') + err := arr.MarshalLogArray(enc) + enc.buf.AppendByte(']') + return err +} + +func (enc *jsonEncoder) AppendObject(obj zapcore.ObjectMarshaler) error { + enc.addElementSeparator() + enc.buf.AppendByte('{') + err := obj.MarshalLogObject(enc) + enc.buf.AppendByte('}') + return err +} + +func (enc *jsonEncoder) AppendBool(val bool) { + enc.addElementSeparator() + enc.buf.AppendBool(val) +} + +func (enc *jsonEncoder) AppendByteString(val []byte) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddByteString(val) + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendComplex128(val complex128) { + enc.addElementSeparator() + // Cast to a platform-independent, fixed-size type. + r, i := float64(real(val)), float64(imag(val)) + enc.buf.AppendByte('"') + // Because we're always in a quoted string, we can use strconv without + // special-casing NaN and +/-Inf. + enc.buf.AppendFloat(r, 64) + enc.buf.AppendByte('+') + enc.buf.AppendFloat(i, 64) + enc.buf.AppendByte('i') + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendDuration(val time.Duration) { + cur := enc.buf.Len() + if e := enc.EncodeDuration; e != nil { + e(val, enc) + } + if cur == enc.buf.Len() { + // User-supplied EncodeDuration is a no-op. Fall back to nanoseconds to keep + // JSON valid. + enc.AppendInt64(int64(val)) + } +} + +func (enc *jsonEncoder) AppendInt64(val int64) { + enc.addElementSeparator() + enc.buf.AppendInt(val) +} + +func (enc *jsonEncoder) AppendReflected(val interface{}) error { + valueBytes, err := enc.encodeReflected(val) + if err != nil { + return err + } + enc.addElementSeparator() + _, err = enc.buf.Write(valueBytes) + return err +} + +func (enc *jsonEncoder) AppendString(val string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddString(val) + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendTimeLayout(time time.Time, layout string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.buf.AppendTime(time, layout) + enc.buf.AppendByte('"') +} + +func (enc *jsonEncoder) AppendTime(val time.Time) { + cur := enc.buf.Len() + if e := enc.EncodeTime; e != nil { + e(val, enc) + } + if cur == enc.buf.Len() { + // User-supplied EncodeTime is a no-op. Fall back to nanos since epoch to keep + // output JSON valid. + enc.AppendInt64(val.UnixNano()) + } +} + +func (enc *jsonEncoder) AppendUint64(val uint64) { + enc.addElementSeparator() + enc.buf.AppendUint(val) +} + +func (enc *jsonEncoder) AddComplex64(k string, v complex64) { enc.AddComplex128(k, complex128(v)) } +func (enc *jsonEncoder) AddFloat32(k string, v float32) { enc.AddFloat64(k, float64(v)) } +func (enc *jsonEncoder) AddInt(k string, v int) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddInt32(k string, v int32) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddInt16(k string, v int16) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddInt8(k string, v int8) { enc.AddInt64(k, int64(v)) } +func (enc *jsonEncoder) AddUint(k string, v uint) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUint32(k string, v uint32) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUint16(k string, v uint16) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUint8(k string, v uint8) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AddUintptr(k string, v uintptr) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonEncoder) AppendComplex64(v complex64) { enc.AppendComplex128(complex128(v)) } +func (enc *jsonEncoder) AppendFloat64(v float64) { enc.appendFloat(v, 64) } +func (enc *jsonEncoder) AppendFloat32(v float32) { enc.appendFloat(float64(v), 32) } +func (enc *jsonEncoder) AppendInt(v int) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendInt32(v int32) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendInt16(v int16) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendInt8(v int8) { enc.AppendInt64(int64(v)) } +func (enc *jsonEncoder) AppendUint(v uint) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUint32(v uint32) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUint16(v uint16) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUint8(v uint8) { enc.AppendUint64(uint64(v)) } +func (enc *jsonEncoder) AppendUintptr(v uintptr) { enc.AppendUint64(uint64(v)) } + +func (enc *jsonEncoder) Clone() zapcore.Encoder { + clone := enc.clone() + clone.buf.Write(enc.buf.Bytes()) + return clone +} + +func (enc *jsonEncoder) clone() *jsonEncoder { + clone := getJSONEncoder() + clone.EncoderConfig = enc.EncoderConfig + clone.spaced = enc.spaced + clone.openNamespaces = enc.openNamespaces + clone.buf = bufferpool.Get() + return clone +} + +func (enc *jsonEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + final := enc.clone() + final.buf.AppendByte('{') + + if final.LevelKey != "" { + final.addKey(final.LevelKey) + cur := final.buf.Len() + final.EncodeLevel(ent.Level, final) + if cur == final.buf.Len() { + // User-supplied EncodeLevel was a no-op. Fall back to strings to keep + // output JSON valid. + final.AppendString(ent.Level.String()) + } + } + if final.TimeKey != "" { + final.AddTime(final.TimeKey, ent.Time) + } + if ent.LoggerName != "" && final.NameKey != "" { + final.addKey(final.NameKey) + cur := final.buf.Len() + nameEncoder := final.EncodeName + + // if no name encoder provided, fall back to FullNameEncoder for backwards + // compatibility + if nameEncoder == nil { + nameEncoder = zapcore.FullNameEncoder + } + + nameEncoder(ent.LoggerName, final) + if cur == final.buf.Len() { + // User-supplied EncodeName was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.LoggerName) + } + } + if ent.Caller.Defined { + if final.CallerKey != "" { + final.addKey(final.CallerKey) + cur := final.buf.Len() + final.EncodeCaller(ent.Caller, final) + if cur == final.buf.Len() { + // User-supplied EncodeCaller was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.Caller.String()) + } + } + if final.FunctionKey != "" { + final.addKey(final.FunctionKey) + final.AppendString(ent.Caller.Function) + } + } + if final.MessageKey != "" { + final.addKey(enc.MessageKey) + final.AppendString(ent.Message) + } + if enc.buf.Len() > 0 { + final.addElementSeparator() + final.buf.Write(enc.buf.Bytes()) + } + addFields(final, fields) + final.closeOpenNamespaces() + if ent.Stack != "" && final.StacktraceKey != "" { + final.AddString(final.StacktraceKey, ent.Stack) + } + final.buf.AppendByte('}') + if final.LineEnding != "" { + final.buf.AppendString(final.LineEnding) + } else { + final.buf.AppendString(zapcore.DefaultLineEnding) + } + + ret := final.buf + putJSONEncoder(final) + return ret, nil +} + +func (enc *jsonEncoder) truncate() { + enc.buf.Reset() +} + +func (enc *jsonEncoder) closeOpenNamespaces() { + for i := 0; i < enc.openNamespaces; i++ { + enc.buf.AppendByte('}') + } +} + +func (enc *jsonEncoder) addKey(key string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddString(key) + enc.buf.AppendByte('"') + enc.buf.AppendByte(':') + if enc.spaced { + enc.buf.AppendByte(' ') + } +} + +func (enc *jsonEncoder) addElementSeparator() { + last := enc.buf.Len() - 1 + if last < 0 { + return + } + switch enc.buf.Bytes()[last] { + case '{', '[', ':', ',', ' ': + return + default: + enc.buf.AppendByte(',') + if enc.spaced { + enc.buf.AppendByte(' ') + } + } +} + +func (enc *jsonEncoder) appendFloat(val float64, bitSize int) { + enc.addElementSeparator() + switch { + case math.IsNaN(val): + enc.buf.AppendString(`"NaN"`) + case math.IsInf(val, 1): + enc.buf.AppendString(`"+Inf"`) + case math.IsInf(val, -1): + enc.buf.AppendString(`"-Inf"`) + default: + enc.buf.AppendFloat(val, bitSize) + } +} + +// safeAddString JSON-escapes a string and appends it to the internal buffer. +// Unlike the standard library's encoder, it doesn't attempt to protect the +// user from browser vulnerabilities or JSONP-related problems. +func (enc *jsonEncoder) safeAddString(s string) { + for i := 0; i < len(s); { + if enc.tryAddRuneSelf(s[i]) { + i++ + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if enc.tryAddRuneError(r, size) { + i++ + continue + } + enc.buf.AppendString(s[i : i+size]) + i += size + } +} + +// safeAddByteString is no-alloc equivalent of safeAddString(string(s)) for s []byte. +func (enc *jsonEncoder) safeAddByteString(s []byte) { + for i := 0; i < len(s); { + if enc.tryAddRuneSelf(s[i]) { + i++ + continue + } + r, size := utf8.DecodeRune(s[i:]) + if enc.tryAddRuneError(r, size) { + i++ + continue + } + enc.buf.Write(s[i : i+size]) + i += size + } +} + +// tryAddRuneSelf appends b if it is valid UTF-8 character represented in a single byte. +func (enc *jsonEncoder) tryAddRuneSelf(b byte) bool { + if b >= utf8.RuneSelf { + return false + } + if 0x20 <= b && b != '\\' && b != '"' { + enc.buf.AppendByte(b) + return true + } + switch b { + case '\\', '"': + enc.buf.AppendByte('\\') + enc.buf.AppendByte(b) + case '\n': + enc.buf.AppendByte('\\') + enc.buf.AppendByte('n') + case '\r': + enc.buf.AppendByte('\\') + enc.buf.AppendByte('r') + case '\t': + enc.buf.AppendByte('\\') + enc.buf.AppendByte('t') + default: + // Encode bytes < 0x20, except for the escape sequences above. + enc.buf.AppendString(`\u00`) + enc.buf.AppendByte(_hex[b>>4]) + enc.buf.AppendByte(_hex[b&0xF]) + } + return true +} + +func (enc *jsonEncoder) tryAddRuneError(r rune, size int) bool { + if r == utf8.RuneError && size == 1 { + enc.buf.AppendString(`\ufffd`) + return true + } + return false +} diff --git a/pub/common/log/zapcorex/memory_encoder.go b/pub/common/log/zapcorex/memory_encoder.go new file mode 100644 index 0000000..646a142 --- /dev/null +++ b/pub/common/log/zapcorex/memory_encoder.go @@ -0,0 +1,183 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package zapcorex + +import ( + "time" + + "go.uber.org/zap/zapcore" +) + +// MapObjectEncoder is an ObjectEncoder backed by a simple +// map[string]interface{}. It's not fast enough for production use, but it's +// helpful in tests. +type MapObjectEncoder struct { + // Fields contains the entire encoded log context. + Fields map[string]interface{} + // cur is a pointer to the namespace we're currently writing to. + cur map[string]interface{} +} + +// NewMapObjectEncoder creates a new map-backed ObjectEncoder. +func NewMapObjectEncoder() *MapObjectEncoder { + m := make(map[string]interface{}) + return &MapObjectEncoder{ + Fields: m, + cur: m, + } +} + +// AddArray implements ObjectEncoder. +func (m *MapObjectEncoder) AddArray(key string, v zapcore.ArrayMarshaler) error { + arr := &sliceArrayEncoder{elems: make([]interface{}, 0)} + err := v.MarshalLogArray(arr) + m.cur[key] = arr.elems + return err +} + +// AddObject implements ObjectEncoder. +func (m *MapObjectEncoder) AddObject(k string, v zapcore.ObjectMarshaler) error { + newMap := NewMapObjectEncoder() + m.cur[k] = newMap.Fields + return v.MarshalLogObject(newMap) +} + +// AddBinary implements ObjectEncoder. +func (m *MapObjectEncoder) AddBinary(k string, v []byte) { m.cur[k] = v } + +// AddByteString implements ObjectEncoder. +func (m *MapObjectEncoder) AddByteString(k string, v []byte) { m.cur[k] = string(v) } + +// AddBool implements ObjectEncoder. +func (m *MapObjectEncoder) AddBool(k string, v bool) { m.cur[k] = v } + +// AddDuration implements ObjectEncoder. +func (m MapObjectEncoder) AddDuration(k string, v time.Duration) { m.cur[k] = v } + +// AddComplex128 implements ObjectEncoder. +func (m *MapObjectEncoder) AddComplex128(k string, v complex128) { m.cur[k] = v } + +// AddComplex64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddComplex64(k string, v complex64) { m.cur[k] = v } + +// AddFloat64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddFloat64(k string, v float64) { m.cur[k] = v } + +// AddFloat32 implements ObjectEncoder. +func (m *MapObjectEncoder) AddFloat32(k string, v float32) { m.cur[k] = v } + +// AddInt implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt(k string, v int) { m.cur[k] = v } + +// AddInt64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt64(k string, v int64) { m.cur[k] = v } + +// AddInt32 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt32(k string, v int32) { m.cur[k] = v } + +// AddInt16 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt16(k string, v int16) { m.cur[k] = v } + +// AddInt8 implements ObjectEncoder. +func (m *MapObjectEncoder) AddInt8(k string, v int8) { m.cur[k] = v } + +// AddString implements ObjectEncoder. +func (m *MapObjectEncoder) AddString(k string, v string) { m.cur[k] = v } + +// AddTime implements ObjectEncoder. +func (m MapObjectEncoder) AddTime(k string, v time.Time) { m.cur[k] = v } + +// AddUint implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint(k string, v uint) { m.cur[k] = v } + +// AddUint64 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint64(k string, v uint64) { m.cur[k] = v } + +// AddUint32 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint32(k string, v uint32) { m.cur[k] = v } + +// AddUint16 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint16(k string, v uint16) { m.cur[k] = v } + +// AddUint8 implements ObjectEncoder. +func (m *MapObjectEncoder) AddUint8(k string, v uint8) { m.cur[k] = v } + +// AddUintptr implements ObjectEncoder. +func (m *MapObjectEncoder) AddUintptr(k string, v uintptr) { m.cur[k] = v } + +// AddReflected implements ObjectEncoder. +func (m *MapObjectEncoder) AddReflected(k string, v interface{}) error { + m.cur[k] = v + return nil +} + +// OpenNamespace implements ObjectEncoder. +func (m *MapObjectEncoder) OpenNamespace(k string) { + ns := make(map[string]interface{}) + m.cur[k] = ns + m.cur = ns +} + +// sliceArrayEncoder is an ArrayEncoder backed by a simple []interface{}. Like +// the MapObjectEncoder, it's not designed for production use. +type sliceArrayEncoder struct { + elems []interface{} +} + +func (s *sliceArrayEncoder) AppendArray(v zapcore.ArrayMarshaler) error { + enc := &sliceArrayEncoder{} + err := v.MarshalLogArray(enc) + s.elems = append(s.elems, enc.elems) + return err +} + +func (s *sliceArrayEncoder) AppendObject(v zapcore.ObjectMarshaler) error { + m := NewMapObjectEncoder() + err := v.MarshalLogObject(m) + s.elems = append(s.elems, m.Fields) + return err +} + +func (s *sliceArrayEncoder) AppendReflected(v interface{}) error { + s.elems = append(s.elems, v) + return nil +} + +func (s *sliceArrayEncoder) AppendBool(v bool) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendByteString(v []byte) { s.elems = append(s.elems, string(v)) } +func (s *sliceArrayEncoder) AppendComplex128(v complex128) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendComplex64(v complex64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendDuration(v time.Duration) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendFloat64(v float64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendFloat32(v float32) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt(v int) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt64(v int64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt32(v int32) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt16(v int16) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendInt8(v int8) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendString(v string) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendTime(v time.Time) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint(v uint) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint64(v uint64) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint32(v uint32) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint16(v uint16) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUint8(v uint8) { s.elems = append(s.elems, v) } +func (s *sliceArrayEncoder) AppendUintptr(v uintptr) { s.elems = append(s.elems, v) } diff --git a/pub/common/m3u8/enkey.go b/pub/common/m3u8/enkey.go new file mode 100644 index 0000000..407e155 --- /dev/null +++ b/pub/common/m3u8/enkey.go @@ -0,0 +1,63 @@ +package m3u8 + +import ( + "bytes" + "context" + "io/ioutil" + "sync" +) + +var ( + cacheEnkey []byte + cacheLsjEnkey []byte + initOnce sync.Once + cacheNewEnkey []byte + newEnkeyErr error + newEnkeyOnce sync.Once +) + +func initEnkey() { + initOnce.Do(func() { + if len(cacheEnkey) == 0 { + data, err := ioutil.ReadFile("conf/m3u8.key") + if err != nil { + panic(err) + } + cacheEnkey = data + } + if len(cacheLsjEnkey) == 0 { + data, err := ioutil.ReadFile("conf/lsjm3u8.key") + if err != nil { + panic(err) + } + cacheLsjEnkey = data + } + }) +} + +func Enkey(ctx context.Context) (buffer *bytes.Buffer, err error) { + if cacheEnkey == nil { + initEnkey() + } + buffer = bytes.NewBuffer(cacheEnkey) + return +} + +// NewEnkey 返回短剧原片使用的解密密钥。 +func NewEnkey(ctx context.Context) (buffer *bytes.Buffer, err error) { + newEnkeyOnce.Do(func() { + cacheNewEnkey, newEnkeyErr = ioutil.ReadFile("conf/newm3u8.key") + }) + if newEnkeyErr != nil { + return nil, newEnkeyErr + } + return bytes.NewBuffer(cacheNewEnkey), nil +} + +func LsjEnkey(ctx context.Context) (buffer *bytes.Buffer, err error) { + if cacheLsjEnkey == nil { + initEnkey() + } + buffer = bytes.NewBuffer(cacheLsjEnkey) + return +} diff --git a/pub/common/m3u8/media_play_list.go b/pub/common/m3u8/media_play_list.go new file mode 100644 index 0000000..60c6c7b --- /dev/null +++ b/pub/common/m3u8/media_play_list.go @@ -0,0 +1,192 @@ +package m3u8 + +import ( + "net/url" + "path" + "strings" + "time" + "videoweb/pub/common/utils" + + "github.com/grafov/m3u8" +) + +func makeMediaPlayList(from *m3u8.MediaPlaylist, cdnDomain string, dirPath string, keyUri string, authKey string) (media *m3u8.MediaPlaylist, err error) { + media, err = m3u8.NewMediaPlaylist(0, from.Count()) + if err != nil { + return + } + base := utils.JoinUrlPath(cdnDomain, dirPath) + now := time.Now() + + for _, segment := range from.Segments { + if segment == nil { + continue + } + sg := new(m3u8.MediaSegment) + if strings.Contains(dirPath, "laosiji") { + segment.URI = removeDomainPrefix(segment.URI) + } + sg.URI = utils.JoinUrlPath(base, segment.URI) + if len(authKey) > 0 { + urlPath := utils.JoinUrlPath(dirPath, segment.URI) + urlAuth := generateUrlAuth(now, urlPath, authKey) + sg.URI = sg.URI + urlAuth + } + sg.Title = segment.Title + sg.Duration = segment.Duration + if err = media.AppendSegment(sg); err != nil { + return + } + } + _ = media.SetDefaultKey("AES-128", keyUri, "", "", "") + media.Close() + return +} + +func makeMediaPlayListLaosiji(from *m3u8.MediaPlaylist, cdnDomain string, dirPath string, keyUri string, authKey string) (media *m3u8.MediaPlaylist, err error) { + media, err = m3u8.NewMediaPlaylist(0, from.Count()) + if err != nil { + return + } + + now := time.Now() + isFMP4 := from.Map != nil + media.SetVersion(from.Version()) + if isFMP4 && media.Version() < 5 { + media.SetVersion(5) + } + media.SeqNo = from.SeqNo + media.MediaType = from.MediaType + media.DiscontinuitySeq = from.DiscontinuitySeq + media.StartTime = from.StartTime + media.StartTimePrecise = from.StartTimePrecise + media.Iframe = from.Iframe + + if from.Map != nil { + mapURI, mapErr := rewriteLaosijiMediaURI(from.Map.URI, cdnDomain, dirPath, "") + if mapErr != nil { + return nil, mapErr + } + mapURI = appendLaosijiURLAuth(mapURI, now, authKey) + media.SetDefaultMap(mapURI, from.Map.Limit, from.Map.Offset) + } + + for _, segment := range from.Segments { + if segment == nil { + continue + } + + sg := new(m3u8.MediaSegment) + sg.URI, err = rewriteLaosijiMediaURI(segment.URI, cdnDomain, dirPath, fromMapURI(from)) + if err != nil { + return nil, err + } + sg.URI = appendLaosijiURLAuth(sg.URI, now, authKey) + if from.Map == nil && segment.Map != nil { + segmentMapURI, mapErr := rewriteLaosijiMediaURI(segment.Map.URI, cdnDomain, dirPath, "") + if mapErr != nil { + return nil, mapErr + } + sg.Map = &m3u8.Map{ + URI: appendLaosijiURLAuth(segmentMapURI, now, authKey), + Limit: segment.Map.Limit, + Offset: segment.Map.Offset, + } + isFMP4 = true + } + sg.Title = segment.Title + sg.Duration = segment.Duration + sg.Limit = segment.Limit + sg.Offset = segment.Offset + sg.Discontinuity = segment.Discontinuity + if err = media.AppendSegment(sg); err != nil { + return + } + } + if from.TargetDuration > media.TargetDuration { + media.TargetDuration = from.TargetDuration + } + if isFMP4 && media.Version() < 5 { + media.SetVersion(5) + } + // 老司机旧 TS 仍使用 AES-128 key;fMP4/HEVC 依赖 EXT-X-MAP,不能再强制插入 EXT-X-KEY。 + if !isFMP4 { + _ = media.SetDefaultKey("AES-128", keyUri, "0x64cf692cebc516e4a86ac6081aae50ce", "", "") + } + media.Close() + return +} + +func rewriteLaosijiMediaURI(originalURI, cdnDomain, playlistDir, mapURI string) (string, error) { + originalURI = strings.TrimSpace(originalURI) + if originalURI == "" { + return "", nil + } + + parsed, err := url.Parse(originalURI) + if err != nil { + return "", err + } + if !parsed.IsAbs() { + if mapURL, mapErr := url.Parse(strings.TrimSpace(mapURI)); mapErr == nil && mapURL.IsAbs() { + base := *mapURL + base.RawQuery = "" + base.Fragment = "" + base.Path = path.Dir(base.Path) + "/" + parsed = base.ResolveReference(parsed) + } else { + cleanDir := strings.TrimPrefix(strings.TrimSpace(playlistDir), "/") + cleanDir = strings.TrimPrefix(cleanDir, "laosiji/m3m/") + cleanDir = strings.TrimPrefix(cleanDir, "laosiji/") + cleanDir = strings.TrimPrefix(cleanDir, "m3m/") + parsed.Path = path.Join("/", cleanDir, parsed.Path) + } + } + + return replaceDomainAndPath(parsed.String(), utils.JoinUrlPath(cdnDomain, "/laosiji")) +} + +func appendLaosijiURLAuth(mediaURL string, now time.Time, authKey string) string { + if mediaURL == "" || authKey == "" { + return mediaURL + } + signPath := mediaURL + if parsed, err := url.Parse(mediaURL); err == nil { + signPath = parsed.Path + } + return mediaURL + generateUrlAuth(now, signPath, authKey) +} + +func fromMapURI(playlist *m3u8.MediaPlaylist) string { + if playlist == nil || playlist.Map == nil { + return "" + } + return playlist.Map.URI +} + +func replaceDomainAndPath(originalURL, newBase string) (string, error) { + parsedURL, err := url.Parse(originalURL) + if err != nil { + return "", err + } + + newBaseURL, err := url.Parse(newBase) + if err != nil { + return "", err + } + + parsedURL.Scheme = newBaseURL.Scheme + parsedURL.Host = newBaseURL.Host + parsedURL.Path = path.Join(newBaseURL.Path, strings.TrimPrefix(parsedURL.Path, "")) + parsedURL.RawQuery = "" + + return parsedURL.String(), nil +} + +func removeDomainPrefix(originalURL string) string { + parsedURL, err := url.Parse(originalURL) + if err != nil { + return originalURL + } + return strings.TrimPrefix(parsedURL.Path, "") +} diff --git a/pub/common/m3u8/play_list.go b/pub/common/m3u8/play_list.go new file mode 100644 index 0000000..c72bd5d --- /dev/null +++ b/pub/common/m3u8/play_list.go @@ -0,0 +1,199 @@ +package m3u8 + +import ( + "bytes" + "fmt" + "io" + "strings" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/errors" + "videoweb/pub/common/http" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/redis" + "videoweb/pub/storage/proxy" + + "github.com/grafov/m3u8" +) + +const ( + keyUri = "/enkey" + webkeyUri = "/api/web/enkey" + h5KeyUri = "/enkey" + AuthKey = "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN" + keyUriLsj = "/lsjenkey" + keyUriWebLsj = "/api/web/lsjenkey" +) + +// resolveTsAuthKey 优先使用传入的 tsAuthKey,为空则回退默认 AuthKey。 +func resolveTsAuthKey(tsAuthKey string) string { + if key := strings.TrimSpace(tsAuthKey); key != "" { + return key + } + return AuthKey +} + +var headerMediaPlayList *m3u8.MediaPlaylist + +// TransFormWithSafely 被检查权限的播放列表。 +// tsAuthKey: 普通 TS CDN 鉴权;lsjM3u8Yuan: 老司机源站;cdnAuthKey: 老司机分片 CDN 鉴权。 +func TransFormWithSafely(req *Req, cdnDomain, origDomain string, web bool, tsAuthKey, lsjM3u8Yuan, cdnAuthKey string) (*m3u8.MediaPlaylist, *bytes.Buffer, error) { + if req.Name == "" { + return nil, nil, errors.MakeWithMsg(errors.M3u8FileName, "file name is empty") + } + if cdnDomain == "" || origDomain == "" { + return nil, nil, errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty") + } + if strings.Contains(req.Name, "hc237") { + req.Name = "/laosiji" + req.Name + } + if strings.Contains(req.Name, "laosiji") { + return TransformLaosiji(req.Name, cdnDomain, web, lsjM3u8Yuan, cdnAuthKey) + } + return transform(req.Name, cdnDomain, origDomain, web, tsAuthKey) +} + +func TransformLaosiji(name, cdnDomain string, web bool, lsjM3u8Yuan, cdnAuthKey string, customKeyUri ...string) (media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + // fMP4/HEVC 分片 CDN 使用传入的视频 CDN(web 为 MediaWebCdn,app/h5 为 TsCdn), + // 与 mdsm GetCDNUrlList()[0] 一致;拉取源 m3u8 仍走 lsjM3u8Yuan。 + m3u8reader, err := readFromRedis(laosijiUpstreamPath(name), lsjM3u8Yuan) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + + authKey := strings.TrimSpace(cdnAuthKey) + keyurl := keyUriLsj + if web { + keyurl = keyUriWebLsj + } + if len(customKeyUri) > 0 && customKeyUri[0] != "" { + keyurl = customKeyUri[0] + } + + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + dirPath := utils.PathForInfo(name).Dir + media, err = makeMediaPlayListLaosiji(pl, cdnDomain, dirPath, keyurl, authKey) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + mpl := p.(*m3u8.MasterPlaylist) + buff = mpl.Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +func transform(name, cdnDomain, origDomain string, web bool, tsAuthKey string) (media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + mediaVerson := proxy.AppendsMediaS3Version(name) + if len(mediaVerson) > 0 { + name = mediaVerson + name + } + + m3u8reader, err := readFromRedis(name, origDomain) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + + authKey := resolveTsAuthKey(tsAuthKey) + keyurl := keyUri + if web { + keyurl = webkeyUri + } + + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + dirPath := utils.PathForInfo(name).Dir + media, err = makeMediaPlayList(pl, cdnDomain, dirPath, keyurl, authKey) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + mpl := p.(*m3u8.MasterPlaylist) + buff = mpl.Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +func readFromCDN(name, domain string) (reader io.Reader, err error) { + requestUrl := utils.JoinUrlPath(domain, name) + resp, err := http.GetLargeFile(requestUrl, nil) + if err != nil { + return + } + if resp.StatusCode != 200 { + msg := fmt.Sprintf("read m3u8 fail, url: %s, status code %d", requestUrl, resp.StatusCode) + err = errors.MakeWithMsg(errors.M3u8Read, msg) + return + } + reader = bytes.NewReader(resp.Data) + return +} + +func readFromRedis(name, domain string) (reader io.Reader, err error) { + m3u8Key := redisconst.MediaM3U8Key(name) + data, err := redis.GetBytes(m3u8Key) + if err == nil && len(data) > 0 { + reader = bytes.NewReader(data) + return + } + if strings.Contains(name, "hc237") { + name = "/m3m/" + name + } + requestUrl := utils.JoinUrlPath(domain, name) + resp, err := http.GetLargeFile(requestUrl, nil) + if err != nil { + return + } + if resp.StatusCode != 200 { + msg := fmt.Sprintf("read m3u8 fail, url: %s, status code %d", requestUrl, resp.StatusCode) + err = errors.MakeWithMsg(errors.M3u8Read, msg) + return + } + if err := redis.Set(m3u8Key, resp.Data, + redisconst.MediaM3U8CacheExpire); err != nil { + log.Info("m3u8:readFromRedis redis set err", log.E(err)) + } + reader = bytes.NewReader(resp.Data) + return +} + +func GetHeaderMediaSegUrls() []string { + urls := make([]string, 0) + if headerMediaPlayList == nil { + return urls + } + for _, segment := range headerMediaPlayList.Segments { + if segment == nil { + continue + } + urls = append(urls, segment.URI) + } + return urls +} + +// laosijiUpstreamPath maps internal laosiji m3u8 path to upstream m3m path. +func laosijiUpstreamPath(name string) string { + name = strings.TrimPrefix(name, "/") + name = strings.TrimPrefix(name, "laosiji/m3m/") + name = strings.TrimPrefix(name, "laosiji/") + return "m3m/" + name +} diff --git a/pub/common/m3u8/transcode_source.go b/pub/common/m3u8/transcode_source.go new file mode 100644 index 0000000..359d66f --- /dev/null +++ b/pub/common/m3u8/transcode_source.go @@ -0,0 +1,116 @@ +package m3u8 + +import ( + "bytes" + "fmt" + "strings" + "videoweb/pub/common/errors" + "videoweb/pub/common/utils" + "videoweb/pub/storage/proxy" + + "github.com/grafov/m3u8" +) + +const lsjKeyIV = "0x64cf692cebc516e4a86ac6081aae50ce" + +// TransformForTranscode 生成转码专用播放列表,供老司机转码服务器拉取。 +func TransformForTranscode(name, origDomain, cdnDomain, webM3u8Host, lsjM3u8Yuan, lsjTsYuan string) (media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + if strings.Contains(name, "hc237") { + name = "/laosiji" + name + } + if strings.Contains(name, "laosiji") { + return transcodeLaosiji(name, webM3u8Host, lsjM3u8Yuan, lsjTsYuan) + } + return transcodeNormal(name, origDomain, cdnDomain, webM3u8Host) +} + +func transcodeLaosiji(name, webM3u8Host, lsjM3u8Yuan, lsjTsYuan string) (media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + m3u8reader, err := readFromRedis(laosijiUpstreamPath(name), lsjM3u8Yuan) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + keyURI := utils.JoinUrlPath(webM3u8Host, keyUriWebLsj) + // ts 走 CDN(TsYuan/laosiji),与正常播放的 CDN 路径一致。 + media, err = makeTranscodeSegList(pl, utils.JoinUrlPath(lsjTsYuan, "/laosiji"), "", keyURI, lsjKeyIV, true) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + buff = p.(*m3u8.MasterPlaylist).Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +func transcodeNormal(name, origDomain, cdnDomain, webM3u8Host string) (media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + if v := proxy.AppendsMediaS3Version(name); len(v) > 0 { + name = v + name + } + m3u8reader, err := readFromRedis(name, origDomain) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + dirPath := utils.PathForInfo(name).Dir + keyURI := utils.JoinUrlPath(webM3u8Host, webkeyUri) + // ts 走 CDN(CdnDomain),与正常播放一致。 + media, err = makeTranscodeSegList(pl, cdnDomain, dirPath, keyURI, "", false) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + buff = p.(*m3u8.MasterPlaylist).Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +func makeTranscodeSegList(from *m3u8.MediaPlaylist, srcDomain, dirPath, keyURI, iv string, laosiji bool) (media *m3u8.MediaPlaylist, err error) { + media, err = m3u8.NewMediaPlaylist(0, from.Count()) + if err != nil { + return + } + base := utils.JoinUrlPath(srcDomain, dirPath) + for _, segment := range from.Segments { + if segment == nil { + continue + } + sg := new(m3u8.MediaSegment) + if laosiji { + if u, e := replaceDomainAndPath(segment.URI, srcDomain); e == nil { + sg.URI = u + } else { + sg.URI = segment.URI + } + } else { + sg.URI = utils.JoinUrlPath(base, segment.URI) + } + sg.Title = segment.Title + sg.Duration = segment.Duration + if err = media.AppendSegment(sg); err != nil { + return + } + } + _ = media.SetDefaultKey("AES-128", keyURI, iv, "", "") + media.Close() + return +} diff --git a/pub/common/m3u8/transformer.go b/pub/common/m3u8/transformer.go new file mode 100644 index 0000000..b40bc82 --- /dev/null +++ b/pub/common/m3u8/transformer.go @@ -0,0 +1,152 @@ +package m3u8 + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/utils" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" + + "github.com/grafov/m3u8" +) + +// TransformEx m3u8 文件转换。 +// apiType(1:app,2:h5app,3:web) +// authKey: TS CDN 鉴权(空则回退默认 AuthKey) +// lsjM3u8Yuan / cdnAuthKey: 老司机源站与分片鉴权 +// customKeyUri: 非空时直接使用,用于将鉴权参数嵌入 EXT-X-KEY URI +func TransformEx(name, cdnDomain, origDomain string, apiType int, authKey, lsjM3u8Yuan, cdnAuthKey string, customKeyUri ...string) ( + media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + return transformEx(name, cdnDomain, origDomain, apiType, authKey, lsjM3u8Yuan, cdnAuthKey, true, customKeyUri...) +} + +// TransformExOriginalPath 按库里的原片路径读取 m3u8,不自动追加 v1/v2/v3。 +func TransformExOriginalPath(name, cdnDomain, origDomain string, apiType int, authKey, lsjM3u8Yuan, cdnAuthKey string, customKeyUri ...string) ( + media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + return transformEx(name, cdnDomain, origDomain, apiType, authKey, lsjM3u8Yuan, cdnAuthKey, false, customKeyUri...) +} + +func transformEx(name, cdnDomain, origDomain string, apiType int, authKey, lsjM3u8Yuan, cdnAuthKey string, appendMediaVersion bool, customKeyUri ...string) ( + media *m3u8.MediaPlaylist, buff *bytes.Buffer, err error) { + + if name == "" { + err = errors.MakeWithMsg(errors.M3u8FileName, "m3u8 file name is empty") + return + } + if cdnDomain == "" || origDomain == "" { + err = errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty") + return + } + if strings.Contains(name, "hc237") { + name = "/laosiji" + name + } + if strings.Contains(name, "laosiji") { + return TransformLaosiji(name, cdnDomain, false, lsjM3u8Yuan, cdnAuthKey, customKeyUri...) + } + + if appendMediaVersion { + mediaVerson := proxy.AppendsMediaS3Version(name) + if len(mediaVerson) > 0 { + name = mediaVerson + name + } + } + authKey = resolveTsAuthKey(authKey) + + m3u8reader, err := readFromRedis(name, origDomain) + if err != nil { + return + } + p, t, err := m3u8.DecodeFrom(m3u8reader, false) + if err != nil { + err = errors.MakeWithErr(errors.M3u8Read, err) + return + } + apiPath := keyUri + if len(customKeyUri) > 0 && customKeyUri[0] != "" { + apiPath = customKeyUri[0] + } else if apiType == 2 { + apiPath = h5KeyUri + } else if apiType == 3 { + apiPath = webkeyUri + } + + switch t { + case m3u8.MEDIA: + pl := p.(*m3u8.MediaPlaylist) + dirPath := utils.PathForInfo(name).Dir + media, err = makeTSList(pl, cdnDomain, dirPath, apiPath, authKey) + if err != nil { + return + } + buff = media.Encode() + case m3u8.MASTER: + mpl := p.(*m3u8.MasterPlaylist) + buff = mpl.Encode() + default: + err = errors.MakeWithMsg(errors.M3u8FileName, fmt.Sprintf("invalid m3u8 type %d", t)) + } + return +} + +func makeTSList(from *m3u8.MediaPlaylist, cdnDomain string, + dirPath string, apiPath string, authKey string) ( + media *m3u8.MediaPlaylist, err error) { + media, err = m3u8.NewMediaPlaylist(from.WinSize(), from.Count()) + if err != nil { + return + } + now := time.Now() + base := utils.JoinUrlPath(cdnDomain, dirPath) + for _, segment := range from.Segments { + if segment == nil { + continue + } + sg := new(m3u8.MediaSegment) + sg.URI = utils.JoinUrlPath(base, segment.URI) + if len(authKey) > 0 { + urlPath := utils.JoinUrlPath(dirPath, segment.URI) + urlAuth := generateUrlAuth(now, urlPath, authKey) + sg.URI = sg.URI + urlAuth + } + sg.Title = segment.Title + sg.Duration = segment.Duration + if err = media.AppendSegment(sg); err != nil { + return + } + } + _ = media.SetDefaultKey("AES-128", apiPath, "", "", "") + media.Close() + return +} + +func generateUrlAuth(now time.Time, path string, authKey string) string { + timestamp := now.Unix() + signStr := fmt.Sprintf("%s-%d-0-0-%s", path, timestamp, authKey) + md5Str := getMD5Sign(signStr) + // TS CDN 鉴权参数后追加应用标识,便于流量统计。 + urlAuth := fmt.Sprintf("?md=%d-0-0-%s&c=%d", timestamp, md5Str, pubdef.AppId) + return urlAuth +} + +func generateUrlAuthQiniu(now time.Time, path string, authKey string) string { + timestamp := strconv.FormatInt(now.Add(2*time.Hour).Unix(), 16) + base64 := path + signStr := authKey + base64 + timestamp + md5Str := getMD5Sign(signStr) + urlAuth := fmt.Sprintf("?sign=%s&t=%s", md5Str, timestamp) + return urlAuth +} + +func getMD5Sign(buf string) string { + md5Ctx := md5.New() + md5Ctx.Write([]byte(buf)) + cipherStr := md5Ctx.Sum(nil) + nsign := hex.EncodeToString(cipherStr) + return nsign +} diff --git a/pub/common/m3u8/types.go b/pub/common/m3u8/types.go new file mode 100644 index 0000000..97a1614 --- /dev/null +++ b/pub/common/m3u8/types.go @@ -0,0 +1,13 @@ +package m3u8 + +type Req struct { + Token string `json:"token" form:"token"` + VId int64 `json:"vId" form:"vId"` + Name string `json:"name" form:"name"` + NeedHeader bool `json:"needHeader" form:"needHeader"` +} + +type PostVideoReq struct { + Token string `json:"token" form:"token"` + Name string `json:"name" form:"name" binding:"required" comment:"must be set"` +} diff --git a/pub/common/m3u8/validate.go b/pub/common/m3u8/validate.go new file mode 100644 index 0000000..a7b4927 --- /dev/null +++ b/pub/common/m3u8/validate.go @@ -0,0 +1,18 @@ +package m3u8 + +//sErr "sz-app-server/pub/common/errors" + +// func CheckParams(req *Req) (err error) { +// if req.Token == "" { +// err = sErr.MakeWithMsg(sErr.NoToken, "no token") +// return +// } +// if req.VId <= 0 { +// err = sErr.MakeWithMsg(sErr.VideoId, "invalid video id") +// return +// } +// if req.Name == "" { +// err = sErr.MakeWithMsg(sErr.VideoName, "invalid name") +// } +// return +// } diff --git a/pub/common/m3u8/vtt.go b/pub/common/m3u8/vtt.go new file mode 100644 index 0000000..c08beb3 --- /dev/null +++ b/pub/common/m3u8/vtt.go @@ -0,0 +1,96 @@ +package m3u8 + +import ( + "bufio" + "bytes" + "io" + "strings" + "videoweb/pub/common/errors" + "videoweb/pub/common/utils" + "videoweb/pub/storage/proxy" +) + +// TransformVtt 读取雪碧图 vtt 文件,并把其中的缩略图路径替换为图片 cdn 地址。 +// 兼容现有 /laosiji 路径资源:laosiji 的 vtt 需移除 /laosiji 前缀后,从 lsjImageYuan 源站读取。 +// imgCdn 图片 cdn 域名(缩略图访问地址) +// origDomain 普通媒资的源站域名 +// lsjImageYuan 老司机图片源站域名(由 app/web 项目从配置 lsjMediaConf.imageYuan 传入) +func TransformVtt(name, imgCdn, origDomain, lsjImageYuan string) (buff []byte, err error) { + if name == "" { + err = errors.MakeWithMsg(errors.M3u8FileName, "vtt file name is empty") + return + } + if imgCdn == "" || origDomain == "" { + err = errors.MakeWithMsg(errors.DomainErr, "imgCdn or origDomain is empty") + return + } + + mediaVersion := proxy.AppendsMediaS3Version(name) + if len(mediaVersion) > 0 { + name = mediaVersion + name + } + + originName := name + if strings.Contains(name, "laosiji") { + if lsjImageYuan == "" { + err = errors.MakeWithMsg(errors.DomainErr, "lsjImageYuan is empty") + return + } + originName = stripLaosijiPrefix(name) + origDomain = lsjImageYuan + } + + srcReader, err := readFromRedis(originName, origDomain) + if err != nil { + return + } + raw, err := io.ReadAll(srcReader) + if err != nil { + return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt reader fail: "+err.Error()) + } + + dirPath := utils.PathForInfo(name).Dir + buff = rewriteVttWithCDN(raw, imgCdn, dirPath) + return +} + +func stripLaosijiPrefix(name string) string { + p := strings.TrimPrefix(name, "/") + p = strings.TrimPrefix(p, "laosiji") + p = strings.TrimPrefix(p, "/") + return p +} + +func rewriteVttWithCDN(vttData []byte, imgCdn, dirPath string) []byte { + imgCdn = strings.TrimRight(strings.TrimSpace(imgCdn), "/") + + var out bytes.Buffer + scanner := bufio.NewScanner(bytes.NewReader(vttData)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + trim := strings.TrimSpace(line) + + if idx := strings.Index(trim, "#xywh="); idx >= 0 { + p := strings.TrimSpace(trim[:idx]) + xywh := trim[idx:] + + var newPath string + switch { + case strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://"): + newPath = p + case strings.HasPrefix(p, "/"): + newPath = imgCdn + p + default: + newPath = utils.JoinUrlPath(utils.JoinUrlPath(imgCdn, strings.TrimRight(dirPath, "/")), p) + } + out.WriteString(newPath + xywh + "\n") + continue + } + + out.WriteString(line + "\n") + } + + return out.Bytes() +} diff --git a/pub/common/maths/maths.go b/pub/common/maths/maths.go new file mode 100644 index 0000000..523f9bd --- /dev/null +++ b/pub/common/maths/maths.go @@ -0,0 +1,68 @@ +package maths + +import ( + "math/big" + "strings" + + uuid "github.com/satori/go.uuid" +) + +const CODE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +const CodeLenth = 62 + +/** + * 10进制 为 62进制 字符串。大数字类型 + * + * param: *big.Int number 待转换的数字,非常大的 + * return: string + */ +func From10To62Big(number *big.Int) string { + tmpNumber := big.NewInt(0) + var result []byte + for number.Cmp(big.NewInt(0)) != 0 { + var tmp []byte + tmpNumber.Rem(number, big.NewInt(CodeLenth)) + tmp = append(tmp, CODE62[tmpNumber.Int64()]) + result = append(tmp, result...) + number.Div(number, big.NewInt(CodeLenth)) + } + return string(result) +} + +/** + * 10进制 为 62进制 字符串。大数字类型 + * + * param: int64 num 待转换的数字 + * return: string + */ +func From10To62(number int64) string { + var result []byte + for number != 0 { + var tmp []byte + number1 := number % CodeLenth + tmp = append(tmp, CODE62[number1]) + result = append(tmp, result...) + number = number / CodeLenth + } + return string(result) +} + +func GenUUID() string { + u1 := uuid.Must(uuid.NewV4(), nil).String() + uuidStr := strings.Replace(u1, "-", "", -1) + number, _ := new(big.Int).SetString(uuidStr, 16) + return From10To62Big(number) +} + +func Abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +func Gen32String() string { + u1 := uuid.Must(uuid.NewV4(), nil).String() + uuidStr := strings.Replace(u1, "-", "", -1) + return uuidStr +} diff --git a/pub/common/netutil/netutil.go b/pub/common/netutil/netutil.go new file mode 100644 index 0000000..5edf224 --- /dev/null +++ b/pub/common/netutil/netutil.go @@ -0,0 +1,55 @@ +package netutil + +import ( + "net" + "net/url" + "path" + "strings" +) + +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "" +} + +func MergeURL(baseURL, relativePath string) string { + if relativePath == "" { + return "" + } + // 解析基础URL + base, err := url.Parse(baseURL) + if err != nil { + return "" + } + + // 如果相对路径是绝对URL,直接返回 + if strings.HasPrefix(relativePath, "http://") || strings.HasPrefix(relativePath, "https://") { + return relativePath + } + + // 合并路径 + mergedPath := path.Join(base.Path, relativePath) + + // 处理路径中的 ./ 和 ../ + mergedPath = path.Clean(mergedPath) + + // 确保路径以/开头(如果原始base.Path以/结尾) + if strings.HasSuffix(base.Path, "/") && !strings.HasPrefix(mergedPath, "/") { + mergedPath = "/" + mergedPath + } + + // 更新URL的Path部分 + base.Path = mergedPath + + return base.String() +} diff --git a/pub/common/netutil/netutil_test.go b/pub/common/netutil/netutil_test.go new file mode 100644 index 0000000..fc87a5a --- /dev/null +++ b/pub/common/netutil/netutil_test.go @@ -0,0 +1,10 @@ +package netutil + +import ( + "fmt" + "testing" +) + +func Test_GetLocalIP(t *testing.T) { + fmt.Println(GetLocalIP()) +} diff --git a/pub/common/pageopt/match.go b/pub/common/pageopt/match.go new file mode 100644 index 0000000..e6185af --- /dev/null +++ b/pub/common/pageopt/match.go @@ -0,0 +1,580 @@ +package pageopt + +import ( + "reflect" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// A A from Array +type A = []interface{} + +// M M from Map +type M = bson.M + +// *******************************************// +// Matcher +type Matcher interface { + Filter() M +} + +// MergeM +func MergeM(mats []Matcher) M { + merge := M{} + for _, m := range mats { + if m == nil { + continue + } + ql := m.Filter() + for k, v := range ql { + merge[k] = v + } + } + return merge +} + +// *******************************************// +type MergeMatch struct { + merge M +} + +func NewMergeMatch(mats ...Matcher) Matcher { + mergeM := MergeM(mats) + return &MergeMatch{mergeM} +} + +func (m *MergeMatch) Filter() M { + return m.merge +} + +// *******************************************// +// AssignMatch +type AssignMatch struct { + Key string + Val interface{} +} + +func NewAssignMatch(key string, val interface{}) Matcher { + return &AssignMatch{ + key, + val, + } +} + +func (m *AssignMatch) Filter() M { + if IsEmpt(m.Val) { + return M{} + } + return M{ + m.Key: m.Val, + } +} + +// *******************************************// + +type Sort = bson.D + +func SortDesc(Key string) Sort { + return Sort{{Key, -1}} +} + +func SortAsc(Key string) Sort { + return Sort{{Key, 1}} +} + +// *******************************************// +// RegexMatch +type RegexMatch struct { + Key string + Val interface{} + Options string +} + +func NewRegexMatch(key string, val interface{}, opt string) Matcher { + return &RegexMatch{ + Key: key, + Val: val, + Options: opt, + } +} + +func (r *RegexMatch) Filter() M { + if IsEmpt(r.Val) { + return M{} + } + return M{ + r.Key: M{"$regex": r.Val, "$options": r.Options}, + } +} + +// *******************************************// +func IsNil(i interface{}) bool { + vi := reflect.ValueOf(i) + return vi.IsNil() +} + +func IsEmpt(i interface{}) bool { + v := reflect.ValueOf(i) + switch v.Kind() { + case reflect.Ptr: + return v.IsNil() + case reflect.Slice, reflect.Array: + return v.Len() == 0 + } + return false +} + +// *******************************************// + +type NEMatch struct { + Key string + NE interface{} +} + +func (m *NEMatch) Filter() M { + if IsEmpt(m.NE) { + return M{} + } + return M{m.Key: M{"$ne": m.NE}} +} + +func NewNEMatch(Key string, ne interface{}) Matcher { + return &NEMatch{ + Key: Key, + NE: ne, + } +} + +// *******************************************// +// GEMatch +type GEMatch struct { + Key string + GT interface{} +} + +func NewGEMatch(Key string, gt interface{}) Matcher { + return &GEMatch{ + Key: Key, + GT: gt, + } +} + +func (m *GEMatch) Filter() M { + if IsEmpt(m.GT) { + return M{} + } + return M{m.Key: M{"$gt": m.GT}} +} + +// *******************************************// +// GTEMatch +type GTEMatch struct { + Key string + GTE interface{} +} + +func NewGTEMatch(Key string, gte interface{}) Matcher { + return >EMatch{ + Key: Key, + GTE: gte, + } +} + +func (m *GTEMatch) Filter() M { + if IsEmpt(m.GTE) { + return M{} + } + return M{m.Key: M{"$gte": m.GTE}} +} + +// *******************************************// + +// GTMatch +type GTMatch struct { + Key string + GT interface{} +} + +func NewGTMatch(Key string, gt interface{}) Matcher { + return >Match{ + Key: Key, + GT: gt, + } +} + +func (m *GTMatch) Filter() M { + if IsEmpt(m.GT) { + return M{} + } + return M{m.Key: M{"$gt": m.GT}} +} + +// *******************************************// +// LTMatch +type LTMatch struct { + Key string + LT interface{} +} + +func NewLTMatch(Key string, lt interface{}) Matcher { + return <Match{ + Key: Key, + LT: lt, + } +} + +func (m *LTMatch) Filter() M { + if IsEmpt(m.LT) { + return M{} + } + return M{m.Key: M{"$lt": m.LT}} +} + +// *******************************************// + +// LTEMatch +type LTEMatch struct { + Key string + LTE interface{} +} + +func NewLTEMatch(Key string, lte interface{}) Matcher { + return <EMatch{ + Key: Key, + LTE: lte, + } +} + +func (m *LTEMatch) Filter() M { + if IsEmpt(m.LTE) { + return M{} + } + return M{m.Key: M{"$lte": m.LTE}} +} + +// *******************************************// +// GTEAndLTEMatch +type GTEAndLTEMatch struct { + Key string + GTE interface{} + LTE interface{} +} + +func NewGTEAndLTEMatch(Key string, gte, lte interface{}) Matcher { + return >EAndLTEMatch{ + Key, + gte, + lte, + } +} + +func (m *GTEAndLTEMatch) Filter() M { + if IsEmpt(m.GTE) || IsEmpt(m.LTE) { + return M{} + } + + return M{ + m.Key: M{ + "$gte": m.GTE, + "$lte": m.LTE, + }, + } +} + +// *******************************************// +// GTEAndLTMatch +type GTEAndLTMatch struct { + Key string + GTE interface{} + LT interface{} +} + +func NewGTEAndLTMatch(key string, gte, lt interface{}) Matcher { + return >EAndLTMatch{ + key, + gte, + lt, + } +} + +func (m *GTEAndLTMatch) Filter() M { + if IsEmpt(m.GTE) || IsEmpt(m.LT) { + return M{} + } + + return M{ + m.Key: M{ + "$gte": m.GTE, + "$lt": m.LT, + }, + } +} + +// *******************************************// + +// ExistsMatch +type ExistsMatch struct { + Key string + IsExisted *bool +} + +func NewExistsMatch(key string, isExisted *bool) Matcher { + return &ExistsMatch{ + Key: key, + IsExisted: isExisted, + } +} + +func (e *ExistsMatch) Filter() M { + if IsEmpt(e.IsExisted) { + return M{} + } + return M{ + e.Key: M{"$exists": e.IsExisted}, + } +} + +// *******************************************// +// InMatch +type InMatch struct { + Key string + Slice interface{} +} + +func NewInMatch(key string, slice interface{}) Matcher { + return &InMatch{ + Key: key, + Slice: slice, + } +} + +func (i *InMatch) Filter() M { + if IsNil(i.Slice) { + return M{} + } + return M{ + i.Key: M{"$in": i.Slice}, + } +} + +// *******************************************// +// NinMatch +type NinMatch struct { + Key string + Slice interface{} +} + +func NewNinMatch(key string, slice interface{}) Matcher { + return &NinMatch{ + Key: key, + Slice: slice, + } +} + +// *******************************************// +func (n *NinMatch) Filter() M { + if IsNil(n.Slice) { + return M{} + } + return M{ + n.Key: M{"$nin": n.Slice}, + } +} + +// *******************************************// +// ManualOrMatch +type ManualOrMatch struct { + Key string + Mats []Matcher +} + +func NewManualOrMatch(key string, mats []Matcher) Matcher { + return &ManualOrMatch{ + Key: key, + Mats: mats, + } +} + +func (o *ManualOrMatch) Filter() M { + if len(o.Mats) == 0 { + return M{} + } + fs := []interface{}{} + for _, v := range o.Mats { + f := v.Filter() + if len(f) != 0 { + fs = append(fs, v.Filter()) + } + } + if len(fs) == 0 { + return M{} + } + return M{"$or": fs} +} + +// *******************************************// +// BitsAllSetMatch +type BitsAllSetMatch struct { + Key string + Bits []int +} + +func NewBitsAllSetMatch(key string, bits []int) Matcher { + return &BitsAllSetMatch{ + Key: key, + Bits: bits, + } +} + +func (b *BitsAllSetMatch) Filter() M { + if len(b.Bits) == 0 { + return M{} + } + return M{ + b.Key: M{"$bitsAllSet": b.Bits}, + } +} + +// BitsAllClearMatch +type BitsAllClearMatch struct { + Key string + Bits []int +} + +// *******************************************// +func NewBitsAllClearMatch(key string, bits []int) Matcher { + return &BitsAllClearMatch{ + Key: key, + Bits: bits, + } +} + +func (b *BitsAllClearMatch) Filter() M { + if len(b.Bits) == 0 { + return M{} + } + return M{ + b.Key: M{"$bitsAllClear": b.Bits}, + } +} + +// *******************************************// + +// UIDMatch +type UIDMatch struct { + UID *int64 +} + +func (u *UIDMatch) New() Matcher { + return NewAssignMatch("userId", u.UID) +} + +// UIDInMatch +type UIDInMatch struct { + UIDS []int64 +} + +func (u *UIDInMatch) New() Matcher { + return NewInMatch("userId", u.UIDS) +} + +// *******************************************// +// UIDMatch +type UserUIDMatch struct { + UID *int64 +} + +func (u *UserUIDMatch) New() Matcher { + return NewAssignMatch("id", u.UID) +} + +// UIDInMatch +type UserUIDInMatch struct { + UIDS []int64 +} + +func (u *UserUIDInMatch) New() Matcher { + return NewInMatch("id", u.UIDS) +} + +// *******************************************// + +// IDMatch +type IDMatch struct { + ID *primitive.ObjectID +} + +func (i *IDMatch) New() Matcher { + return NewAssignMatch("_id", i.ID) +} + +// IDMatch +type IDint64Match struct { + Id *int64 +} + +func (i *IDint64Match) New() Matcher { + return NewAssignMatch("id", i.Id) +} + +// *******************************************// +// IDInMatch +type IDInMatch struct { + IDs []primitive.ObjectID +} + +func (c *IDInMatch) New() Matcher { + return NewInMatch("_id", c.IDs) +} + +// *******************************************// +// CreatedAtGTEMatch +type CreatedAtGTEMatch struct { + GTE *time.Time +} + +func (c *CreatedAtGTEMatch) New() Matcher { + return NewGTEMatch("createdAt", c.GTE) +} + +// *******************************************// +// CreatedAtLTMatch +type CreatedAtLTMatch struct { + LT *time.Time +} + +func (c *CreatedAtLTMatch) New() Matcher { + return NewLTMatch("createdAt", c.LT) +} + +// *******************************************// +// CreatedAtGTEAndLTMatch +type CreatedAtGTEAndLTMatch struct { + GTE *time.Time + LT *time.Time +} + +func (c *CreatedAtGTEAndLTMatch) New() Matcher { + return NewGTEAndLTMatch("createdAt", c.GTE, c.LT) +} + +// // *******************************************// +// type Int32Match struct { +// key string //值 +// value *int32 +// } +// +// func (i *Int32Match) New() Matcher { +// return NewAssignMatch("_id", i.ID) +// } +// +// type Int32InMatch struct { +// key string //值 +// values []int32 +// } +// +// func (c *Int32InMatch) New() Matcher { +// return NewInMatch("_id", c.IDs) +// } diff --git a/pub/common/playletcache/cache.go b/pub/common/playletcache/cache.go new file mode 100644 index 0000000..fb3818f --- /dev/null +++ b/pub/common/playletcache/cache.go @@ -0,0 +1,39 @@ +package playletcache + +import ( + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/redis" +) + +// ClearHomeCache 切换缓存版本,旧数据由 TTL 自动过期 +func ClearHomeCache() { + if redis.Client() == nil { + return + } + if err := redis.Client().Incr(redisconst.AppPlayletHomeCacheVersionKey()).Err(); err != nil { + log.Warn("更新短剧首页缓存版本失败", log.E(err)) + } +} + +// ClearRankCache 更新短剧榜单缓存版本 +func ClearRankCache() { + if redis.Client() == nil { + return + } + if err := redis.Client().Incr(redisconst.AppPlayletRankCacheVersionKey()).Err(); err != nil { + log.Warn("更新短剧榜单缓存版本失败", log.E(err)) + } +} + +// RankCacheVersion 查询短剧榜单缓存版本 +func RankCacheVersion() int64 { + if redis.Client() == nil { + return 0 + } + version, err := redis.Client().Get(redisconst.AppPlayletRankCacheVersionKey()).Int64() + if err != nil { + return 0 + } + return version +} diff --git a/pub/common/pointers/pointers.go b/pub/common/pointers/pointers.go new file mode 100644 index 0000000..908c79d --- /dev/null +++ b/pub/common/pointers/pointers.go @@ -0,0 +1,83 @@ +package pointers + +import "time" + +func Bool(v bool) *bool { return &v } +func Int(v int) *int { return &v } +func Int32(v int32) *int32 { return &v } +func Int64(v int64) *int64 { return &v } +func Uint32(v uint32) *uint32 { return &v } +func Uint64(v uint64) *uint64 { return &v } +func Float32(v float32) *float32 { return &v } +func Float64(v float64) *float64 { return &v } +func String(v string) *string { return &v } +func Time(v time.Time) *time.Time { return &v } +func StringSlice(v []string) *[]string { return &v } +func Int64Slice(v []int64) *[]int64 { return &v } + +func GetBool(v *bool) bool { + if v != nil { + return *v + } + return false +} +func GetInt(v *int) int { + if v != nil { + return *v + } + return 0 +} +func GetInt32(v *int32) int32 { + if v != nil { + return *v + } + return 0 +} +func GetInt64(v *int64) int64 { + if v != nil { + return *v + } + return 0 +} +func GetInt64Slice(v *[]int64) []int64 { + if v != nil { + return *v + } + return []int64{} +} +func GetUint32(v *uint32) uint32 { + if v != nil { + return *v + } + return 0 +} +func GetUint64(v *uint64) uint64 { + if v != nil { + return *v + } + return 0 +} +func GetFloat32(v *float32) float32 { + if v != nil { + return *v + } + return 0 +} +func GetFloat64(v *float64) float64 { + if v != nil { + return *v + } + return 0 +} +func GetString(v *string) string { + if v != nil { + return *v + } + return "" +} +func GetStringSlice(v *[]string) []string { + if v != nil { + return *v + } + return []string{} +} diff --git a/pub/common/random/number.go b/pub/common/random/number.go new file mode 100644 index 0000000..af0d5c3 --- /dev/null +++ b/pub/common/random/number.go @@ -0,0 +1,53 @@ +package random + +import ( + "fmt" + "math/rand" + "time" +) + +const letterBytesAll = "0123456789abcdefghijklmnopqrstvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func init() { + rand.Seed(time.Now().Unix()) +} + +func Int(n int) int { + return rand.Intn(n) +} + +func SliceRand(ids []int64) (result []int64) { + result = make([]int64, 0) + if len(ids) <= 0 { + return + } + copy := make([]int64, 0) + for _, id := range ids { + copy = append(copy, id) + } + num := Int(len(copy)) + 1 + for i := 0; i < num; i++ { + index := Int(len(copy)) + result = append(result, copy[index]) + copy = append(copy[:index], copy[index+1:]...) + } + return +} + +func RandDigits(n uint) string { + s := "" + for n != 0 { + s += fmt.Sprintf("%d", rand.Intn(10)) + n-- + } + return s +} + +// RandStrRandrAll 生成指定位数的随机数 +func RandStrRandrAll(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytesAll[rand.Int63()%int64(len(letterBytesAll))] + } + return string(b) +} diff --git a/pub/common/recomapi/define.go b/pub/common/recomapi/define.go new file mode 100644 index 0000000..11969a4 --- /dev/null +++ b/pub/common/recomapi/define.go @@ -0,0 +1,42 @@ +package recomapi + +import "time" + +const ( + FBT_Star string = "star" + FBT_Like string = "like" + FBT_Read string = "read" +) + +type RSItemInfo struct { + Comment string `json:"Comment"` + ItemId string `json:"ItemId"` + Labels []string `json:"Labels"` + Timestamp time.Time `json:"Timestamp"` +} + +type RSItemInfoList []*RSItemInfo + +type RSUserInfo struct { + Comment string `json:"Comment"` + UserId string `json:"UserId"` + Labels []string `json:"Labels"` + Subscribe []string `json:"Subscribe"` + Timestamp time.Time `json:"Timestamp"` +} + +type RSUserInfoList []*RSUserInfo + +type APIResult struct { + RowAffected int64 `json:"RowAffected"` +} + +type FeedbackInfo struct { + Comment string `json:"Comment"` + FeedbackType string `json:"FeedbackType"` + ItemId string `json:"ItemId"` + Timestamp time.Time `json:"Timestamp"` + UserId string `json:"UserId"` +} + +type FeedbackInfoList []*FeedbackInfo diff --git a/pub/common/recomapi/feedback.go b/pub/common/recomapi/feedback.go new file mode 100644 index 0000000..c933a08 --- /dev/null +++ b/pub/common/recomapi/feedback.go @@ -0,0 +1,45 @@ +package recomapi + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" +) + +// AddOneFeedback 添加一个推荐反馈 +func AddOneFeedback(url string, key string, + userId int64, mediaId int64, fbType string, comment string) ( + int64, *srvdef.SrvErr) { + feedBack := &FeedbackInfo{ + UserId: strconv.FormatInt(userId, 10), + ItemId: strconv.FormatInt(mediaId, 10), + FeedbackType: fbType, + Comment: comment, + Timestamp: time.Now(), + } + return AddManyFeedbacks(url, key, FeedbackInfoList{feedBack}) +} + +func AddManyFeedbacks(url string, key string, feedbacks FeedbackInfoList) ( + int64, *srvdef.SrvErr) { + reqUrl := url + "/api/feedback" + resp := &APIResult{} + reqBody, _ := json.Marshal(feedbacks) + header := map[string]string{"X-API-Key": key} + code, err := utils.POSTJsonWithJResp(resp, reqUrl, header, reqBody) + if err != nil { + log.Error("recomapi:AddManyFeedbacks POSTJsonWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return 0, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:AddManyFeedbacks POSTJsonWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return 0, srvdef.New(srvdef.Err, "", "") + } + return resp.RowAffected, nil +} diff --git a/pub/common/recomapi/itemapi.go b/pub/common/recomapi/itemapi.go new file mode 100644 index 0000000..51578a7 --- /dev/null +++ b/pub/common/recomapi/itemapi.go @@ -0,0 +1,106 @@ +package recomapi + +import ( + "encoding/json" + "fmt" + "net/http" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" +) + +func InsertOneItem(url string, key string, itemInfo *RSItemInfo) *srvdef.SrvErr { + reqUrl := url + "/api/item" + resp := &APIResult{} + reqBody, _ := json.Marshal(itemInfo) + header := map[string]string{"X-API-Key": key} + code, err := utils.POSTJsonWithJResp(resp, reqUrl, header, reqBody) + if err != nil { + log.Error("recomapi:InsertOneItem POSTJsonWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:InsertOneItem POSTJsonWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return srvdef.New(srvdef.Err, "", "") + } + return nil +} + +func InsertManyItems(url string, key string, itemInfos RSItemInfoList) *srvdef.SrvErr { + reqUrl := url + "/api/items" + reqBody, _ := json.Marshal(itemInfos) + header := map[string]string{"X-API-Key": key} + code, err := utils.POSTJsonWithJResp(nil, reqUrl, header, reqBody) + if err != nil { + log.Error("recomapi:InsertManyItems POSTJsonWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:InsertManyItems POSTJsonWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return srvdef.New(srvdef.Err, "", "") + } + return nil +} + +func GetOneItem(url string, key string, itemId string) (*RSItemInfo, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/item/%s", url, itemId) + resp := &RSItemInfo{} + header := map[string]string{"X-API-Key": key} + code, err := utils.GetWithJResp(resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:GetOneItem GetWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return nil, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:GetOneItem GetWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return nil, srvdef.New(srvdef.Err, "", "") + } + log.Info("Test", log.Any("resp", resp)) + return resp, nil +} + +func GetManyItems(url string, key string, cursor string, size int64) (RSItemInfoList, string, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/items?n=%d&cursor=%s", url, size, cursor) + type Resp struct { + Cursor string `json:"Cursor"` + Items RSItemInfoList `json:"Items"` + } + var resp Resp + header := map[string]string{"X-API-Key": key} + code, err := utils.GetWithJResp(&resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:GetOneItem GetWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return nil, "", srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:GetOneItem GetWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return nil, "", srvdef.New(srvdef.Err, "", "") + } + return resp.Items, resp.Cursor, nil +} + +func DeleteOneItem(url string, key string, itemId string) (int64, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/item/%s", url, itemId) + resp := &APIResult{} + header := map[string]string{"X-API-Key": key} + code, err := utils.DeleteWithJResp(resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:DeleteOneItem DeleteWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return 0, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:DeleteOneItem DeleteWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return 0, srvdef.New(srvdef.Err, "", "") + } + return resp.RowAffected, nil +} diff --git a/pub/common/recomapi/recomapi.go b/pub/common/recomapi/recomapi.go new file mode 100644 index 0000000..b4356a6 --- /dev/null +++ b/pub/common/recomapi/recomapi.go @@ -0,0 +1,36 @@ +package recomapi + +import ( + "fmt" + "net/http" + "strconv" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" +) + +func GetRecomItems(url string, key string, userId int64, writeBack string, size int64) ( + []int64, *srvdef.SrvErr) { + reqUrl := fmt.Sprintf("%s/api/recommend/%d?n=%d&write-back=%s", url, userId, size, writeBack) + var resp []string + header := map[string]string{"X-API-Key": key} + code, err := utils.GetWithJResp(&resp, reqUrl, header, nil) + if err != nil { + log.Error("recomapi:GetRecomItems GetWithJResp failed", + log.Any("reqUrl", reqUrl), log.E(err)) + return nil, srvdef.New(srvdef.Err, "", "") + } + if code != http.StatusOK { + log.Error("recomapi:GetRecomItems GetWithJResp code error.", + log.Any("reqUrl", reqUrl), log.Any("code", code)) + return nil, srvdef.New(srvdef.Err, "", "") + } + itemIds := make([]int64, 0) + for _, v := range resp { + id, err := strconv.ParseInt(v, 10, 64) + if err == nil { + itemIds = append(itemIds, id) + } + } + return itemIds, nil +} diff --git a/pub/common/recomapi/recomapi_test.go b/pub/common/recomapi/recomapi_test.go new file mode 100644 index 0000000..7bdccd1 --- /dev/null +++ b/pub/common/recomapi/recomapi_test.go @@ -0,0 +1,77 @@ +package recomapi + +import ( + "fmt" + "testing" + "time" + "videoweb/pub/common/log" +) + +var ( + testUrl = "http://139.177.187.219:8087" + testKey = "" +) + +func Test_InsertOneItem(t *testing.T) { + itemInfo := &RSItemInfo{ + Comment: "Comment1", + ItemId: "10004", + Labels: []string{"lable1", "lable2", "lable3"}, + Timestamp: time.Now(), + } + serr := InsertOneItem(testUrl, testKey, itemInfo) + fmt.Printf("\nTest_pingReq %s %v\n", itemInfo, serr) +} + +func Test_InsertManyItems(t *testing.T) { + list := make(RSItemInfoList, 0) + itemInfo1 := &RSItemInfo{ + Comment: "Comment1", + ItemId: "10005", + Labels: []string{"lable1", "lable2", "lable3"}, + Timestamp: time.Now(), + } + list = append(list, itemInfo1) + itemInfo2 := &RSItemInfo{ + Comment: "Comment1", + ItemId: "10006", + Labels: []string{"lable1", "lable2", "lable3"}, + Timestamp: time.Now(), + } + list = append(list, itemInfo2) + serr := InsertManyItems(testUrl, testKey, list) + fmt.Printf("\nTest_InsertManyItems %v\n", serr) +} + +func Test_GetOneItem(t *testing.T) { + itemInfo, serr := GetOneItem(testUrl, testKey, "10004") + fmt.Printf("\nTest_GetOneItem %v %v\n", itemInfo, serr) +} + +func Test_GetManyItem(t *testing.T) { + itemInfos, cursor, serr := GetManyItems(testUrl, testKey, "10003", 2) + log.Info("Test_GetManyItem", log.Any("itemInfos", itemInfos), + log.Any("cursor", cursor), log.E(serr)) + //fmt.Printf("\nTest_GetOneItem %v %v\n", itemInfos, serr) +} + +func Test_DeleteOneItem(t *testing.T) { + count, serr := DeleteOneItem(testUrl, testKey, "10004") + log.Info("Test_DeleteOneItem", log.Any("count", count), log.E(serr)) + //fmt.Printf("\nTest_GetOneItem %v %v\n", itemInfos, serr) +} + +////////////////////////////////////////////////////////////// +//feedback + +func Test_AddOneFeedback(t *testing.T) { + count, serr := AddOneFeedback(testUrl, testKey, 18531, 16346, FBT_Star, "feedback1") + fmt.Printf("\nTest_pingReq %d %v\n", count, serr) +} + +// /reccommend +func Test_GetManyRecomItems(t *testing.T) { + itemIds, serr := GetRecomItems(testUrl, testKey, 18531, "test", 10) + log.Info("Test_GetManyRecomItems", log.Any("itemIds", itemIds), log.E(serr)) + //fmt.Printf("\nTest_GetOneItem %v %v\n", itemInfos, serr) +} diff --git a/pub/common/recomapi/usersapi.go b/pub/common/recomapi/usersapi.go new file mode 100644 index 0000000..e8699bd --- /dev/null +++ b/pub/common/recomapi/usersapi.go @@ -0,0 +1 @@ +package recomapi diff --git a/pub/common/regapi.go b/pub/common/regapi.go new file mode 100644 index 0000000..aba3d64 --- /dev/null +++ b/pub/common/regapi.go @@ -0,0 +1,26 @@ +package common + +import ( + "github.com/gin-gonic/gin" +) + +// type FunHandle func(*gin.Context) (proto.Message, string, string, pkt_pub.EType) + +type ApiOpType int + +const ( + ApiGet ApiOpType = 1 + ApiPost ApiOpType = 2 + ApiPut ApiOpType = 3 +) + +// API接口设置 +type ApiSet struct { + Path string // 路径 + OpType ApiOpType // 操作类型 + Fun gin.HandlerFunc // 函数句柄 + IsWhite bool // 是否白名单 + Desc string // 描述 +} + +var ApiSetMaps = map[string]ApiSet{} diff --git a/pub/common/response/base_page.go b/pub/common/response/base_page.go new file mode 100644 index 0000000..a237fab --- /dev/null +++ b/pub/common/response/base_page.go @@ -0,0 +1,9 @@ +package response + +// BasePageResp 分页数据返回结构体 +type BasePageResp struct { + TotalCount int64 `json:"totalCount"` // 总数量 + PageSize int `json:"pageSize"` // 每页数量 + PageNum int `json:"pageNum"` // 当前页码 + List interface{} `json:"list"` // 当前页数据 +} diff --git a/pub/common/slugx/slugx.go b/pub/common/slugx/slugx.go new file mode 100644 index 0000000..53c1dcf --- /dev/null +++ b/pub/common/slugx/slugx.go @@ -0,0 +1,70 @@ +package slugx + +import ( + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + + "go.mongodb.org/mongo-driver/bson" +) + +// Exists 判断 slug 是否已被占用 +type Exists func(slug string) bool + +// Ensure 新增时保证 slug:有值则校验唯一且不能全数字,无值则按 source 自动生成 +func Ensure(slug *string, source string, exists Exists) *srvdef.SrvErr { + if slug == nil { + return srvdef.New(srvdef.ErrParamError, "Slug不能为空", "Slug不能为空") + } + *slug = utils.SanitizeSlug(*slug) + if *slug != "" { + if utils.IsSlugAllDigits(*slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + if exists(*slug) { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + return nil + } + *slug = utils.MakeUniqueSlug(source, exists) + return nil +} + +// PrepareOnUpdate 更新时处理 slug:手动传入则校验,历史为空则自动生成 +func PrepareOnUpdate(current string, reqSlug *string, source string, exists Exists) (string, bool, *srvdef.SrvErr) { + slug := current + if reqSlug != nil { + slug = utils.SanitizeSlug(*reqSlug) + } + if slug != "" { + if reqSlug != nil { + if utils.IsSlugAllDigits(slug) { + return "", false, srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + if exists(slug) { + return "", false, srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + return slug, true, nil + } + return "", false, nil + } + return utils.MakeUniqueSlug(source, exists), true, nil +} + +// Filter 构造按 slug 查重条件,excludeId>0 时排除自身 +func Filter(slugField, idField, slug string, excludeId int64) bson.M { + filter := bson.M{slugField: slug} + if excludeId > 0 { + filter[idField] = bson.M{"$ne": excludeId} + } + return filter +} + +// EmptyFilter 查询 slug 为空或不存在的历史数据 +func EmptyFilter() bson.M { + return bson.M{ + "$or": []bson.M{ + {"slug": ""}, + {"slug": bson.M{"$exists": false}}, + }, + } +} diff --git a/pub/common/sms/fengniao/fengniao.go b/pub/common/sms/fengniao/fengniao.go new file mode 100644 index 0000000..5d11c13 --- /dev/null +++ b/pub/common/sms/fengniao/fengniao.go @@ -0,0 +1,76 @@ +package fengniao + +import ( + "crypto/sha1" + "errors" + "fmt" + "net/http" + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" +) + +type requestBody struct { + MercId string `json:"mercId"` + Secret string `json:"secret"` + TemplateId string `json:"templateId"` + Code string `json:"smsCode"` + Mobile string `json:"mobile"` + SignType string `json:"signType"` // 固定sha1 + Sign string `json:"sign"` // 签名 +} + +type msg struct { + Status string `json:"status"` + TaskId string `json:"taskId"` +} + +type responseBody struct { + Code int `json:"code"` + Msg msg `json:"msg"` + Err string `json:"err"` +} + +type FengNiao struct { + Url string + MercId string + Secret string + Tmpl string +} + +// Send 发送, 目前蜂鸟貌似只支持数字 +func (f *FengNiao) Send(mobile string, code string) error { + content := fmt.Sprintf(f.Tmpl, code) + data := fmt.Sprintf("mercId=%s&templateId=%s&smsCode=%s&mobile=%s&secret=%s", f.MercId, f.Tmpl, code, mobile, f.Secret) + + sha := sha1.New() + _, err := sha.Write([]byte(data)) + if err != nil { + log.Error("fengniao send error", log.Any("mobile", mobile), log.Any("content", content), log.E(err)) + return err + } + + body := &requestBody{ + MercId: f.MercId, + Secret: f.Secret, + TemplateId: f.Tmpl, + Code: code, + Mobile: mobile, + SignType: "sha1", + Sign: fmt.Sprintf("%x", sha.Sum(nil)), + } + rp := responseBody{} + statusCode, err := httputil.Client().POSTJsonWithJResp(&rp, f.Url, nil, body) + if err != nil { + log.Error("fengniao POSTJsonWithResp err", log.E(err)) + return err + } + if statusCode != http.StatusOK { + log.Error("fengniao status code not 200") + return errors.New("fengniao statusCode not 200") + } + if rp.Code != 200 { + log.Error("fengniao code not 200", log.Any("errdesc", rp.Err)) + err = errors.New(rp.Err) + } + return err +} diff --git a/pub/common/sms/sms.go b/pub/common/sms/sms.go new file mode 100644 index 0000000..e0c5514 --- /dev/null +++ b/pub/common/sms/sms.go @@ -0,0 +1,45 @@ +package sms + +import ( + "strings" + "videoweb/pub/common/sms/fengniao" +) + +type Provider = int + +const ( + tmplID1 = "6029f19a99fd8b404062e4d2" // 大陆 + tmplID2 = "5f8412c39396d16f787e561b" // 非大陆 +) + +const ( + FengNiao Provider = 1 //蜂鸟 + YunPian Provider = 2 //云片 +) + +func sendByFengNiao(mobile string, code string) error { + var tmplID string + if strings.HasPrefix(mobile, "+86") { + tmplID = tmplID1 + return (&fengniao.FengNiao{ + Url: "https://t9x6k.cc/api/sms/sendSms", + MercId: "10177", + Secret: "2dfb7ec44ed59ee3c1fe8dc533384e16", + Tmpl: tmplID, + }).Send(mobile, code) + } else { + tmplID = tmplID2 + return (&fengniao.FengNiao{ + Url: "https://t9x6k.cc/api/sms/sendSms", + MercId: "10228", + Secret: "3ea58a27a162dad4ed3e18c076648742", + Tmpl: tmplID, + }).Send(mobile, code) + } + // return +} + +// Send 发送短信 +func Send(mobile string, code string) error { + return sendByFengNiao(mobile, code) +} diff --git a/pub/common/sms/sms_test.go b/pub/common/sms/sms_test.go new file mode 100644 index 0000000..d0a80ba --- /dev/null +++ b/pub/common/sms/sms_test.go @@ -0,0 +1,8 @@ +package sms + +import "testing" + +func TestSend(t *testing.T) { + err := Send("15008279990", "654321") + t.Log(err) +} diff --git a/pub/common/srvdef/codedef.go b/pub/common/srvdef/codedef.go new file mode 100644 index 0000000..e60f643 --- /dev/null +++ b/pub/common/srvdef/codedef.go @@ -0,0 +1,331 @@ +package srvdef + +import "fmt" + +type CodeType int + +const NotFind = "mongo: no documents in result" + +// 错误级别 +const ( + ParamLevel = 1000 // 参数级 + StoreLevel = 3000 // 存储级 + DBLevel = 5000 // 数据库 + ServerLevel = 4000 // 服务级 + BusinessLevel = 6000 // 业务逻辑级 + NewErrorLevelV2 = 7000 // 新版错误 + NewErrorLevelV3 = 8000 // 新版错误 +) + +// 通用code +const ( + Success CodeType = 200 //Success 通用成功 + Failure CodeType = 400 //Failure 通用失败 + NotFound CodeType = 404 +) + +func (c CodeType) Msg() string { + msg, ok := codeAndMsg[c] + if ok { + return msg + } + return "" +} + +func (c CodeType) Tip() string { + tip, ok := codeAndTip[c] + if ok { + return tip + } + return "" +} + +func (c CodeType) Error() string { + return fmt.Sprintf("code=%d,msg=%v,tip=%v", c, c.Msg(), c.Tip()) +} + +type ErrInfoer interface { + GetMsg() string + GetTip() string + GetCode() CodeType +} + +// GetMsg alias of Msg() +func (c CodeType) GetMsg() string { return c.Msg() } + +// GetTip alias of Tip() +func (c CodeType) GetTip() string { return c.Tip() } +func (c CodeType) GetCode() CodeType { return c } + +const ( + InvalidToken CodeType = ParamLevel + iota // 框架错误 + ErrParamError // 参数错误 + NoToken // 无token + RoleErr // 角色错误 + ReLogin // 重复登陆 + UploadToFileSrvFailed // 文件上传失败 + ErrCaptcha // 验证码为空 + ErrCaptchaInvalid // 验证码非法 + ErrCaptchaGet // 验证码获取失败 + ErrCaptchaResend // 验证码重复发送 + ErrCaptchaSendFail // 验证码发送失败 + InvalidateToken // 非法token + NoUA // 没有UA信息 + UploadFile // 文件上传失败 + AlreadyRegisered // 已经组册 + TokenRemoved // 你已经挤下线,请重新登陆 + PostCommentNotUse //发送失败,服务器错误,请稍后再试 + DBTransaction // 事务错误 + ErrCrawlerUrl // 爬虫URL错误 + ErrCrawler // 爬取数据错误 +) + +const ( + DBQuery CodeType = StoreLevel + iota + DBInsert + DBUpdate + DBDelete + RedisSet // 设置redis错误 + RedisGet // 获取redis值错误 + RedisDel // 删除redis值错误 + EtcdGet // 获取etcd错误 + MongoDBQueryErr // 数据库查询失败 +) + +const ( + ServerError CodeType = ServerLevel + iota // 用户相关错误 +) + +const ( + _ CodeType = DBLevel + iota + ErrDbQueryError //ErrDbQueryError 数据库查询错误 5001 + ErrDbInsertError //ErrDbInsertError 数据库插入错误 5002 + ErrDbUpdateError //ErrDbUpdateError 数据库修改错误 5003 + ErrDbDeleteError //ErrDbDeleteError 数据库删除错误 5004 + ErrDbInputExist //ErrDbInputExist 数据库输入值已经存在 5005 + ErrRdsOperError //ErrRdsOperError redis操作错误 5006 + ErrElsOperError //ErrElsOperError redis操作错误 5007 + ErrTransactionsError //ErrTransactionsError 数据库事务操作错误 + ErrNoData //无数据 +) +const ( + ErrUserPermission CodeType = BusinessLevel + iota // 用户权限 + ErrUserNotExsit // 用户不存在 + ErrUserTypeNotUP // 用户不是UP主 + AccountIsNotExists // 账户不存在 + AccountErrAccessForbid // 账户禁止登陆 + AccountErrPasswordError // 账户密码错误 + GenerateTokenFailed // 生成二维码错误 + ErrCardNotExists // 没有会员卡信息 + ErrMadouCoinNotExists // 没有钻石信息 + ErrMediaNotExsit // 视频不存在 + ErrTopicInfoNotExsit // 视频专题不存在 + ErrData // 数据错误 + Err // 一般错误 + ErrRchgOrderNotExist // 充值订单不存在 + ErrSubmitRchgOrder // 发起充值订单错误 + InvalidRequestReplayAttack // 非法请求 + ErrBuyVIPCardFail // 购买会员卡失败 + ErrOperationTooFrequently // 用户操作太频繁 + ErrMediaSyncServer // 视频同步服务器错误 + ErrUserType // 用户无改权限 + DownloadM3u8File // 文件服务器访问失败 + MediaSyncing // 视频同步中 + MediaConnectServerFaild // 媒资库链接失败 + AtressNotExist // 演员不存在 + ErrAnnouncementNotExsit // 公告不存在 + ErrContactDetailsNotExsit // 联系方式不存在 + ErrPostCommentNotExsit // 评论不存在 + HistoryNotExist // 记录不存在 + ErrHotsearchNotExsit // 热搜不存在 + ActivityDoesNotExist // 活动不存在 + UserWatchNumNotEnough // 免费观看次数不足 + MediaIsVip // 用户不是会员 + MediaIsNeedPay // 视频需要付费 + RepeatBuy // 重复购买 + MovieTicketsNotExits // 观影券不足 + MediaIsNotNeedPay // 视频不需要支付 + _ // 已移除帖子不需要支付错误码,保留原数值 + SystemMsgNotExist // 消息不存在 + ForbidlogNotExist // 封禁消息不存在 + AttachmentDoesNotExist // 导出记录 + UserDownloadNotEnough // 下载次数不足 + UserDownloadNeedPay // 付费视频需要先购买 + RedeemCodeNotExists // 兑换码不存在 + RedeemCodeTimesUsedUp // 兑换码次数使用完了 + RedeemCodeAlreadyUsed // 兑换码你已经使用过兑换码了 + RedeemCodeRecordNotExists // 兑换记录不存在 + MediaIsNotNeedPreSale // 视频不支持预售 + MediaIsPreSale // 预售视频 + QRcodeError // 二维码错误 + ModifyUserNameError // 修改 + ErrSkdRecordNotExsit // 任务不存在 + ErrContainIllegalWord // 包含非法字符 + DatingOrderNotExist // 约会购买记录不存在 + DatingHasBuy // 重复购买 + DatingCollectNotExist // 收藏记录不存在 + DatingBossNotExist // 经纪人不存在 + DatingReportNotExist // 验证报告不存在 + DatingReportDouble // 重复验证 + DatingAlreadyComplaint // 已经提交举报 + SortNotExist // 视频排序不存在 + MessageDialogNotExist // 私信对话框不存在 + VideoUploadFaild // 文件上传失败 + _ // 已移除帖子状态错误码,保留原数值 + WtdrFrequencyLimits // 提现频率限制 + WtdrErroTip1 // 提现不是100整数倍 + WtdrErroTip2 // 赠送3元金额要满500才能提现 + WtdrErroTip3 // 请填写银行名字或升级App版本 + GameWtdrErroTip7 // 银行卡必须与您以前的提现卡一致,如需更改请联系客服 + ProxyUserNotExist // 代理不存在 + ErrPostReportNotExsit // 举报信息不存在 + FileUploadDouble // 文件重复上传 + UserRepeatSubmitRchgOrder // 未支付订单太多,有问题请联系客服 + Limiting // 限流 + ErrIpForbid // Ip禁止 + DeviceIdInvalid // 设备id无效 + UserScoreNotEnough // 用户积分不足 + UserSocreLVLimit // 用户积分等级限制 + UploaderNeedActive // Up主没有激活 + UploaderNeedBind // 后台用户没有up主绑定 + PostCollectionNotExists // 图文专辑不存在 + MediaCollectionNotExist // 该合集或选集不存在 + ContestNotExists // 该比赛不存在 + CannotJoinContest // 该比赛无法参加 + ContestIsVip // 该比赛只限VIP参加 + AppInfoNotExist // 该App不存在 + MediaNotVerified // 视频未上架 + PCCategoryUnmatch // 专辑的分类和作品的分类不一致 + ErrMediaRecommendNotExsit // 推荐不存在 + DatingBuyHistoryNotExist // 购买记录不存在 + DatingWatchHistoryNotExist // 观看记录不存在 + CollectIsExist // 收藏已经存在 + CollectNotExist // 收藏不存在 + DiscussRankDoesNotExist // 话题视频排名 + DiscussActivityDoesNotExist // 话题活动不存在 + UpDayStatDoesNotExist // 创作者收益 + DiscussObjectRankDoesNotExist // 话题视频 + DiscussCategoryDoesNotExist // 话题分类不存在 + ErrCareNotExist // 取消关注对象不存在 + ErrUpMasterTaskNotCompleted // up主申请任务未完成 + HGameNotExist // 游戏不存在 + HGameIsVip // 游戏需要会员 + HGameIsNeedPay // 游戏需要付费 + ErrApplyUpIsVip // UP主申请需要vip + UserLevelCardNotExist // 用户等级卡不存在 + UserCheckinSetupNotExist // 签到配置不存在 + PriceMediaNotCancel // 钻石视频不能下架 + PriceMediaHasBuy // 钻石视频已经被购买 + GiftWeekIsExist // 礼包已领取 + UserLevelPointTooLow // 用户等级积分太低 + UserGiftHaveReceive // 任务奖励已经领取 + UserGiftExistNoReceive // 没有领取资格 + DatingUpMasterNotExist // up主不存在 + UserIsUpMaster // 用户已经是up主 + UserUpCerting // up主认证中 + MissionSetupNotExist // 任务配置不存在 + UserIsUpMasterBind // 用户已被up主绑定 + ErrCareIsExist // 不可重复关注 + ObjectDayStatDoesNotExist // 作品统计记录不存在 + ObjectDayViewIncStatDoesNotExist // 作品增量记录不存在 + UserNotEnoughFreeLotteryCount // 免费抽奖次数不足 + PrizeOutOfStore // 奖品库存不足 + MediaSubscribedNotNeedPay // 订阅的视频不需要支付 + DatingComplaintNotExist // 举报帖子不存在 + DatingUserNotExist // 楼凤不存在 + UserDauInfoNotExist // 搜寻日期没有资料 + ParseJsonError // 数据解析错误 + AlreadyRefund // 已退款 + AiCountTypeNotSupport // ai内容不支持 + TodayPontNotExist // 积分记录不存在 + RedeemPrizeHistoryNotExist // 奖品兑换记录不存在 + PrizeNotExist // 奖品不存在 + ErrUserPointsNotExist // 积分不足 + AIFreeCountNotEnough // ai免费次使用数不足 + AIFreeCountNOtSupportRefund // AI免费使用次数不支持退款 + CrowdfundStatusFail // 众筹女神的状态不在可修改的范围内 + CrowdfundNotOpenPrizeFail // 众筹女神的订单开奖失败 + CrowdfundPrizeNumberError // 众筹女神的开奖号码不对 + RedeemFail // 积分兑换失败 + CrowdfundNotBuy // 当前女神状态不能购买 + CrowdfundNumberHaveBuy // 这个号码已经被其他人购买了 + UserCheckinDisabled // 用户签到功能已关闭 + PrizeTypeUnsupported // 奖品类型不支持自动发放 + CouponExpired // 卡券已过期 + CouponUsed // 卡券已被使用 + CouponNotFound // 没有卡券信息 + JumpTabNotExist // 首页跳转tab不存在 + CouponCanNotUse // 卡券无法使用 + SysConfigError //系统配置错误 + _ // 已移除漫画分类错误码,保留原数值 + _ // 已移除漫画标签错误码,保留原数值 + _ // 已移除漫画主题错误码,保留原数值 + _ // 已移除漫画章节不存在错误码,保留原数值 + _ // 已移除漫画不存在错误码,保留原数值 + _ // 已移除漫画CV错误码,保留原数值 + _ // 已移除漫画视频错误码,保留原数值 + _ // 已移除漫画排行榜错误码,保留原数值 + VomicsTopicNotExist // 有声漫不存在 + _ // 已移除漫画黑名单错误码,保留原数值 + CommentTextIsEmpty // 评论内容为空 + _ // 已移除漫画会员错误码,保留原数值 + _ // 已移除漫画付费错误码,保留原数值 + _ // 已移除漫画章节已存在错误码,保留原数值 + ObjectNotExist // 数据不存在 + GameWtdrErroTip3 // 请填写银行名字或升级App版本 + UserDoTaskErr // 用户任务出错 + GetPrizeFail // 获取奖品失败 + _ // 已移除小说付费错误码,保留原数值 + _ // 已移除小说会员错误码,保留原数值 + LotteryActivityNotEnable // 抽奖活动没有启用 + CurrTimeNotActivity // 当前时间没有活动 + AIOrderStripsCountLimit // 今天AI脱衣提交次数达到上限 + NotWtdrChannel // 没有匹配到提现通过 + ExcelExportErr // 文件导出错误 + _ // 已移除漫画活动错误码,保留原数值 + NoGiftToReceive // 没有奖品可领取 + UserRepeatDrawDiscountCard // 用户重复翻牌 + NoUseDiscountCard // 不可使用折扣卡 + AIOrderNotDel // 未完成的订单不能删除 + AIWaitOrderTooMuch // 排队中的AI订单太多,请稍后再试 + ActorNotExist // 演员不存在 + MediaFSServerHandleIng // 文件服务器处理中 + VideoRepeat // 视频重复 + ErrReqForbidden // 请求被拒 + APPIDError // APPID异常 + RepeatActress // 重复的女优 + CreateNotExist // 作者不存在 +) +const ( + ErrRecharge CodeType = iota + 18000 // 充值相关错误 + ErrGetRechargeType // 充值方式获取错误 + UserPhoneNotBind // 手机号码未注册 + PostCommentPermissionDenied // 禁止评论 + UserLoginPasswordErr // 密码错误 + ErrMediaRecordNotExsit // 视频记录不存在 + ErrThressServerHttpErr // 第三方服务错误 + UserPhoneBeenBound // 手机号码已被绑定 + WeekCommentNumNotEnough // 评论条数不足 + ErrAccessForbid //ErrAccessForbid 没有访问权限 1000 + MediaAlreadyInEpisode // 该视频已经有选集 + MediaNotInEpisode // 选集中不包含该视频 + MediaCollectNotNull // 合集视频不为空 +) + +const ( + // + CircleErrCode CodeType = iota + 20000 // 圈子错误码 20000 + PublistNotVIPPermissionDenied // 不是会员禁止发布 + PublistContentErrDenied // 包含敏感词汇 + _ // 已移除帖子重复购买错误码,保留原数值 + UserNotEnoughMoney // 用户的钻石数量不足 + PostStatusError // 订单状态错误 + PostNotAllowComplaint // 不允许举报 + UserNotEnoughScore // 用户的钻石数量不足 + ErrUserAccExist // 账号已存在 + UserAccountNotBind // 账号未注册 + _ // 已移除帖子上架数量限制错误码,保留原数值 + ErrUserBeBanned //ErrUserBeBanned 用户已经禁言 1004 + ErrCustomerBanned //ErrCustomerBanned 客服已休息 1005 + ErrVersionUpdate //ErrVersionUpdate 版本强制更新 1006 +) diff --git a/pub/common/srvdef/msgdef.go b/pub/common/srvdef/msgdef.go new file mode 100644 index 0000000..98bcf53 --- /dev/null +++ b/pub/common/srvdef/msgdef.go @@ -0,0 +1,58 @@ +package srvdef + +// error tips +const ( + Ok = "成功" + //基础错误提示 + ET_Store = "存储错误" + ET_DBOp = "DB操作错误" + ET_DBUpdate = "DB更新失败" + ET_RdsOp = "Rds操作错误" + ET_ESOp = "ES操作错误" + ET_Param = "参数错误" + ET_NoDocment = "无数据不存在" + ET_NoCache = "缓存数据不存在" + ET_MoneyNotEnough = "余额不足" + //---------------------------------------------- + ET_UserNoExist = "用户不存在" + ET_IsntBlogger = "用户不是博主" + ET_BloggerApply = "博主申请条件不满足" + ET_BloggerAlreadyIs = "用户已经是博主" + ET_VipLevel2Low = "用户的VIP等级不够" + //----------------------care------------------------ + ET_StatusEqual = "已经是请求状态" + ET_AlreadyCare = "已关注" + ET_AlreadyJoinFG = "您已加入了该粉丝团" +) + +var codeAndMsg = map[CodeType]string{ + Success: "success", + Failure: "Failed", + ErrDbQueryError: "database query error", + ErrDbInsertError: "database insert error", + ErrDbUpdateError: "database update error", + ErrDbDeleteError: "database delete error", + ErrDbInputExist: "current input has already exists", + ErrRdsOperError: "redis operate error", + ErrElsOperError: "elastic operate error", + BusinessLevel: "business error", + ErrCardNotExists: "card does not exist", + ErrMadouCoinNotExists: "madou coin does not exist", + ErrGetRechargeType: "get recharge type error", + ErrRchgOrderNotExist: "recharge order does not exist", + ErrBuyVIPCardFail: "buy vip card failed", + ErrOperationTooFrequently: "user operation is too frequent", + UserNotEnoughMoney: "user's coin is not enough", + RedeemCodeNotExists: "code does not exist", + RedeemCodeTimesUsedUp: "code times used up", + RedeemCodeAlreadyUsed: "you have used this code", + RedeemCodeRecordNotExists: "code use record does not exist", + ErrNoData: "no data", + UserPhoneBeenBound: "phone number has been bound", + WtdrFrequencyLimits: "wtdr frequency limits,please try again later", + MediaIsNeedPay: "You have to buy the video.", + UserWatchNumNotEnough: "More than three times", + UserScoreNotEnough: "user score is not enough", + UserSocreLVLimit: "user score level limit", + AlreadyRefund: "already refund", +} diff --git a/pub/common/srvdef/srvdef.go b/pub/common/srvdef/srvdef.go new file mode 100644 index 0000000..70382d4 --- /dev/null +++ b/pub/common/srvdef/srvdef.go @@ -0,0 +1,67 @@ +package srvdef + +import ( + "fmt" +) + +// SrvErr 代替/common/error中的定义,逐渐在代码中弃掉(common/error模块) +type SrvErr struct { + Code CodeType `json:"code"` + Msg string `json:"msg"` + Tips string `json:"tips"` +} + +// New create an error +func New(code CodeType, msgAndTips ...string) *SrvErr { + msg := code.Msg() + tips := code.Tip() + if len(msgAndTips) == 1 { + msg = msgAndTips[0] + } else if len(msgAndTips) == 2 { + msg, tips = msgAndTips[0], msgAndTips[1] + } + return &SrvErr{ + Code: code, + Msg: msg, + Tips: tips, + } +} + +// SrvErr 返回字符串 +func (s *SrvErr) Error() string { + if s == nil { + return "" + } + return fmt.Sprintf("code = %d, msg = %s ,tips = %v", s.Code, s.Msg, s.Tips) +} + +// SrvErr 返回error +func (s *SrvErr) ToError() error { + if s == nil { + return nil + } + return fmt.Errorf("code = %d, msg = %s ,tips = %v", s.Code, s.Msg, s.Tips) +} + +func (c *SrvErr) GetMsg() string { + // 有自定义的msg就用自定义的msg + if c.Msg != "" { + return c.Msg + } + return c.Code.Msg() +} +func (c *SrvErr) GetTip() string { + // 有自定义的tip就用自定义的tip + if c.Tips != "" { + return c.Tips + } + return c.Code.Tip() +} +func (c *SrvErr) GetCode() CodeType { return c.Code } + +var ( + ErrDbInsert = New(ErrDbInsertError, "", "") + ErrDbQuery = New(ErrDbQueryError, "", "") + ErrDbUpdate = New(ErrDbUpdateError, "", "") + ErrDbDelete = New(ErrDbDeleteError, "", "") +) diff --git a/pub/common/srvdef/tipdef.go b/pub/common/srvdef/tipdef.go new file mode 100644 index 0000000..49daeee --- /dev/null +++ b/pub/common/srvdef/tipdef.go @@ -0,0 +1,160 @@ +package srvdef + +var codeAndTip = map[CodeType]string{ + Success: "成功", + Failure: "失敗", + ErrCardNotExists: "沒有會員卡信息", + ErrMadouCoinNotExists: "沒有金幣信息", + ErrGetRechargeType: "充值方式獲取錯誤", + ErrRchgOrderNotExist: "充值訂單不存在", + ErrBuyVIPCardFail: "購買會員卡失敗", + ErrOperationTooFrequently: "用戶操作太頻繁", + UserNotEnoughMoney: "用户余额不足", + UserPhoneNotBind: "手机号码未注册", + PostCommentPermissionDenied: "非会员禁止发贴和评论", + UserLoginPasswordErr: "密码错误", + ErrUserTypeNotUP: "用户不是UP主", + AccountIsNotExists: "账户不存在", + AccountErrAccessForbid: "账户禁止登陆", + AccountErrPasswordError: "账户密码错误", + GenerateTokenFailed: "生成二维码错误", + ErrMediaNotExsit: "视频不存在", + ErrTopicInfoNotExsit: "视频专题不存在", + ErrData: "数据错误", + Err: "一般错误", + ErrSubmitRchgOrder: "发起充值订单错误", + InvalidRequestReplayAttack: "非法请求", + ErrMediaSyncServer: "视频同步服务器错误", + ErrUserType: "用户无改权限", + DownloadM3u8File: "文件服务器访问失败", + MediaSyncing: "视频同步中", + MediaConnectServerFaild: "媒资库链接失败", + AtressNotExist: "演员不存在", + ErrAnnouncementNotExsit: "公告不存在", + ErrContactDetailsNotExsit: "联系方式不存在", + ErrPostCommentNotExsit: "评论不存在", + HistoryNotExist: "记录不存在", + ErrHotsearchNotExsit: "热搜不存在", + ActivityDoesNotExist: "活动不存在", + UserWatchNumNotEnough: "免费观看次数不足", + MediaIsVip: "用户不是会员", + MediaIsNeedPay: "视频需要付费", + RepeatBuy: "重复购买", + MovieTicketsNotExits: "观影券不足", + MediaIsNotNeedPay: "视频不需要支付", + SystemMsgNotExist: "消息不存在", + ForbidlogNotExist: "封禁消息不存在", + AttachmentDoesNotExist: "导出记录", + UserDownloadNotEnough: "下载次数不足", + UserDownloadNeedPay: "付费视频需要先购买", + ErrNoData: "无数据", + ModifyUserNameError: "昵称!每月只能修改一次", + RedeemCodeTimesUsedUp: "該兌換碼已使用", + RedeemCodeNotExists: "兌換碼已使用不存在", + RedeemCodeAlreadyUsed: "該兌換碼已使用", + InvalidToken: "框架错误", + ErrParamError: "参数错误", + NoToken: "无token", + RoleErr: "角色错误", + ReLogin: "重复登陆", + UploadToFileSrvFailed: "文件上传失败", + ErrCaptcha: "验证码为空", + ErrCaptchaInvalid: "验证码非法", + ErrCaptchaGet: "验证码获取失败", + ErrCaptchaResend: "验证码重复发送", + ErrCaptchaSendFail: "验证码发送失败", + InvalidateToken: "非法token", + NoUA: "没有UA信息", + UploadFile: "文件上传失败", + RedisSet: "设置redis错误", + RedisGet: "获取redis值错误", + RedisDel: "删除redis值错误", + EtcdGet: "获取etcd错误", + MongoDBQueryErr: "数据库查询失败", + ErrDbQueryError: "数据库查询错误", + ErrDbInsertError: "数据库插入错误", + ErrDbUpdateError: "数据库修改错误", + ErrDbDeleteError: "数据库删除错误", + ErrDbInputExist: "数据库输入值已经存在", + ErrRdsOperError: "redis操作错误", + ErrElsOperError: "redis操作错误", + ErrTransactionsError: "数据库事务操作错误", + ErrUserPermission: "用户权限", + ErrUserNotExsit: "用户不存在", + RedeemCodeRecordNotExists: "兑换记录不存在", + MediaIsNotNeedPreSale: "视频不支持预售", + MediaIsPreSale: "预售视频", + QRcodeError: "二维码错误", + ErrRecharge: "充值相关错误", + ErrMediaRecordNotExsit: "视频记录不存在", + UserPhoneBeenBound: "手机号码已被绑定", + DatingAlreadyComplaint: "已经举报过", + WtdrFrequencyLimits: "提现操作频率限制,请稍后再试", + WtdrErroTip1: "提现数额必须是100的整数倍", + WtdrErroTip2: "赠送6元体验金,满500可兑换钻石", + WtdrErroTip3: "请填写银行名字或升级App版本", + GameWtdrErroTip7: "银行卡必须与您以前的提现卡一致,如需更改请联系客服", + FileUploadDouble: "文件重复上传", + UserRepeatSubmitRchgOrder: "未支付订单太多,有问题请联系客服", + ErrIpForbid: "IP被禁止", + UserScoreNotEnough: "用户积分不足", + UserSocreLVLimit: "用户积分等级限制", + UploaderNeedBind: "后台用户没有up主绑定", + UploaderNeedActive: "Up主没有激活", + PostCollectionNotExists: "图文专辑不存在", + MediaCollectionNotExist: "该合集或选集不存在", + ContestNotExists: "该比赛不存在", + CannotJoinContest: "该比赛无法参加", + ContestIsVip: "该比赛只限VIP参加", + MediaNotVerified: "视频未上架", + PCCategoryUnmatch: "专辑的分类和作品的分类不一致", + ErrCareNotExist: "取消关注对象不存在", + ErrUpMasterTaskNotCompleted: "up主申请任务未完成", + HGameIsVip: "游戏需要会员", + HGameIsNeedPay: "游戏需要付费", + HGameNotExist: "游戏不存在", + ErrApplyUpIsVip: "UP主申请需要vip", + UserLevelCardNotExist: "用户等级卡不存在", + UserCheckinSetupNotExist: "签到配置不存在", + GiftWeekIsExist: "礼包已领取", + UserLevelPointTooLow: "用户等级积分太低", + UserGiftHaveReceive: "任务奖励已领取", + UserGiftExistNoReceive: "没有领取资格", + DatingUpMasterNotExist: "up主不存在", + UserIsUpMaster: "用户已经是up主", + UserUpCerting: "up主认证中", + MissionSetupNotExist: "任务配置不存在", + UserIsUpMasterBind: "用户已被up主绑定", + ErrCareIsExist: "不可重复关注", + ObjectDayStatDoesNotExist: "作品统计记录不存在", + ObjectDayViewIncStatDoesNotExist: "作品增量记录不存在", + UserNotEnoughFreeLotteryCount: "免费抽奖次数不足", + PrizeOutOfStore: "奖品库存不足", + MediaSubscribedNotNeedPay: "订阅的视频无需支付", + DatingComplaintNotExist: "举报帖子不存在", + DatingUserNotExist: "楼凤不存在", + WeekCommentNumNotEnough: "评论条数不足", + UserDauInfoNotExist: "搜寻日期没有资料", + PostCommentNotUse: "发送失败,服务器错误,请稍后再试", + AlreadyRefund: "已退款", + ErrUserPointsNotExist: "积分不足", + RedeemFail: "积分兑换失败", + CrowdfundNumberHaveBuy: "购买的号码已经出售", + CouponExpired: "卡券已过期", + CouponUsed: "卡券已使用", + CouponNotFound: "没有卡券信息", + CouponCanNotUse: "卡券无法使用", + LotteryActivityNotEnable: "抽奖活动没有启用", + CurrTimeNotActivity: "当前时间没有活动", + AIOrderStripsCountLimit: "今天AI脱衣提交次数达到上限", + NotWtdrChannel: "没有匹配到提现通过", + UserRepeatDrawDiscountCard: "重复翻牌", + NoUseDiscountCard: "不可使用折扣卡", + ErrUserAccExist: "账号已存在", + AIOrderNotDel: "未完成的订单不能删除", + AIWaitOrderTooMuch: "排队中的AI订单太多,请稍后再试", + ActorNotExist: "演员不存在", + VideoRepeat: "视频重复", + ErrReqForbidden: "请求被拒", + RepeatActress: "重复的女优", +} diff --git a/pub/common/srvversion/version.go b/pub/common/srvversion/version.go new file mode 100644 index 0000000..3cafe3a --- /dev/null +++ b/pub/common/srvversion/version.go @@ -0,0 +1,46 @@ +package srvversion + +import ( + "fmt" + "os" + //"os" +) + +var ( + //每次编译时需要手动修改本字段,否则每次编译得到的服务版本号都会是默认值 + SERVICE_VERSION = "V1.001.T" + + //以下几个字段编译期间自动替换,无需手动操作 + GIT_BRANCH = "GIT_BRANCH_FLAG" + GIT_VERSION = "GIT_VERSION_FLAG" + BUILD_DATE = "BUILD_DATE_FLAG" + BUILD_IP = "BUILD_IP_FLAG" +) + +func Info() { + fmt.Printf("version:%s\n", SERVICE_VERSION) + fmt.Printf("git branch:%s\n", GIT_BRANCH) + fmt.Printf("git version:%s\n", GIT_VERSION) + fmt.Printf("build date:%s\n", BUILD_DATE) + fmt.Printf("build ip:%s\n", BUILD_IP) + + os.Exit(0) //退出进程 +} + +func GetVersion() *Version { + return &Version{ + ServiceVer: SERVICE_VERSION, + GitVer: GIT_VERSION, + BuildDate: BUILD_DATE, + BuildIp: BUILD_IP, + Branch: GIT_BRANCH, + } +} + +type Version struct { + ServiceVer string `json:"serviceVer"` + GitVer string `json:"gitVer"` + BuildDate string `json:"buildDate"` + BuildIp string `json:"buildIp"` + Branch string `json:"branch"` +} diff --git a/pub/common/swaggerutil/swagger.go b/pub/common/swaggerutil/swagger.go new file mode 100644 index 0000000..741f3bd --- /dev/null +++ b/pub/common/swaggerutil/swagger.go @@ -0,0 +1,27 @@ +package swaggerutil + +import ( + "fmt" + "videoweb/pub/common/netutil" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +func isDevHost(runMode string, svcListenIp string) bool { + localIp := netutil.GetLocalIP() + return (runMode == "dev" || runMode == "debug") && localIp != svcListenIp +} + +func SetupGinSwagger(runMode, svcListenIp, svcListenPort string) func(r *gin.Engine) { + return func(r *gin.Engine) { + if isDevHost(runMode, svcListenIp) { + url := ginSwagger.URL(fmt.Sprintf("http://%s:%s/swagger/doc.json", netutil.GetLocalIP(), svcListenPort)) + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) + } else { + url := ginSwagger.URL(fmt.Sprintf("http://%s:%s/swagger/doc.json", svcListenIp, svcListenPort)) + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) + } + } +} diff --git a/pub/common/synclock/lock.go b/pub/common/synclock/lock.go new file mode 100644 index 0000000..9245e70 --- /dev/null +++ b/pub/common/synclock/lock.go @@ -0,0 +1,102 @@ +package synclock + +import ( + "fmt" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/redis" +) + +const ( + UserExtSyncLockFmt = "userExtSyncLock:%d" // 用户账户扩展信息 + UserExtSyncLockExpire = 5 * time.Minute // 用户账户扩展信息 过期时间 + + UserAccountLockFmt = "UserAccountLockFmt:%d" // 用户账户 + UserAccountLockExpire = 5 * time.Minute // 用户账户 过期时间 + TraderAccountLockFmt = "TraderAccountLockFmt:%s" //商人账户 + SpinLockExpire = 3 * time.Second +) + +// UserExtSyncLock 通过redis按照用户ID加锁 +func UserExtcLock(userId int64) bool { + v, e := redis.Setnx_NewOK(fmt.Sprintf(UserExtSyncLockFmt, userId), true, UserExtSyncLockExpire) + if !v || e != nil { + return false + } + + return true +} + +// UserExtSpinLock 通过redis按照用户ID加锁 +func UserExtSpinLock(userId int64, lockTimeOut time.Duration) (bool, error) { + endTime := time.Now().Add(lockTimeOut) + for time.Now().Before(endTime) { + value, err := redis.Setnx_NewOK(fmt.Sprintf(UserExtSyncLockFmt, userId), true, UserExtSyncLockExpire) + if err != nil { + return false, errors.MakeWithMsg(errors.RedisError, "redis未启动") + } + if value { + return true, nil + } + time.Sleep(time.Millisecond) + } + + return false, errors.MakeWithMsg(errors.RedisError, "用户操作繁忙") +} + +// UserExtUnlock 按照用户ID释放锁 +func UserExtUnlock(userId int64) { + redis.Del(fmt.Sprintf(UserExtSyncLockFmt, userId)) +} + +// UserAccountLock 通过redis按照用户ID加锁 +func UserAccountLock(userId int64) bool { + v, e := redis.Setnx_NewOK(fmt.Sprintf(UserAccountLockFmt, userId), true, UserExtSyncLockExpire) + if !v || e != nil { + return false + } + + return true +} + +// UserAccountSpinLock 通过redis按照用户ID加锁 +func UserAccountSpinLock(userId int64, lockTimeOut time.Duration) (bool, error) { + endTime := time.Now().Add(lockTimeOut) + for time.Now().Before(endTime) { + value, err := redis.Setnx_NewOK(fmt.Sprintf(UserAccountLockFmt, userId), true, UserExtSyncLockExpire) + if err != nil { + return false, errors.MakeWithMsg(errors.RedisError, "redis未启动") + } + if value { + return true, nil + } + time.Sleep(time.Millisecond) + } + return false, errors.MakeWithMsg(errors.RedisError, "用户操作繁忙") +} + +// 通过redis按照商人ID加锁 +func TraderAccountSpinLock(userId string, lockTimeOut time.Duration) (bool, error) { + endTime := time.Now().Add(lockTimeOut) + for time.Now().Before(endTime) { + value, err := redis.Setnx_NewOK(fmt.Sprintf(TraderAccountLockFmt, userId), true, SpinLockExpire) + if err != nil { + return false, errors.MakeWithMsg(errors.RedisError, "redis未启动") + } + if value { + return true, nil + } + time.Sleep(time.Millisecond) + } + return false, errors.MakeWithMsg(errors.RedisError, "用户操作繁忙") +} + +// 按照商人ID释放锁 +func TraderAccountUnlock(userId string) { + redis.Del(fmt.Sprintf(TraderAccountLockFmt, userId)) +} + +// UserAccountUnlock 按照用户ID释放锁 +func UserAccountUnlock(userId int64) { + redis.Del(fmt.Sprintf(UserAccountLockFmt, userId)) +} diff --git a/pub/common/tgbot/helper.go b/pub/common/tgbot/helper.go new file mode 100644 index 0000000..58ce1de --- /dev/null +++ b/pub/common/tgbot/helper.go @@ -0,0 +1,94 @@ +package tgbot + +import ( + "videoweb/pub/common/srvdef" +) + +//获取群聊id +//https://api.telegram.org/bot1803190372:AAGmrrN6xUoh7IKiv4qCVJYdCs8wLt1ffCw/getUpdates + +var ( + //配置 + //zhihu app机器人 2048234434:AAF3XoW4iE6vAOmttTT_bofgan_PleKuaEk + //zhihu web机器人 1973289619:AAHjCZ8KQOewVfr20foHKlKVwJoulrf_OFc + tgToken = "2048234434:AAF3XoW4iE6vAOmttTT_bofgan_PleKuaEk" + //程序支付监控群tgids + programTgGroupIds = []int64{-778146822} + //天行监控组tgids + monitorTgGroupIds = []int64{-1001373963166} + //管理人员tgid(yl:1652126945) + adminTgIds = []int64{1652126945} + prefix = "[汁乎App]" + tgBot *TgBot = nil +) + +func InitTgBot(token string, prefixStr string) *srvdef.SrvErr { + if tgBot != nil { + return nil + } + if prefixStr != "" { + prefix = prefixStr + } + tgBot = NewTelegramBot(token) + if serr := tgBot.Init(); nil != serr { + return serr + } + + //SendToAll("支付监控测试", "Test") + return nil +} + +func ReleaseTgBot() { + if tgBot == nil { + return + } + tgBot.Release() +} + +// SendToAdmins 发到管理人员 +func SendToAdmins(title string, info string) { + sendTo(adminTgIds, title, info) +} + +// SendToProgram 发到程序 +func SendToProgram(title string, info string) { + sendTo(programTgGroupIds, title, info) +} + +// SendToMonitor 发到监控群组 +func SendToMonitor(title string, info string) { + sendTo(monitorTgGroupIds, title, info) +} + +// SendToProgram 发到所有 +func SendToAll(title string, info string) { + groupIds := programTgGroupIds + groupIds = append(groupIds, monitorTgGroupIds...) + //groupIds = append(groupIds, adminTgIds...) + sendTo(groupIds, title, info) +} + +func sendTo(ids []int64, title string, info string) { + if tgBot == nil { + return + } + //异步发送 + go func() { + msg := prefix + "\n" + title + "\n" + info + tgBot.Send(ids, msg) + }() +} + +func RegisterCommands(commands []*BotCmd) { + if tgBot == nil { + return + } + tgBot.RegisterCommands(commands) +} + +func Receive() { + if tgBot == nil { + return + } + tgBot.Receive() +} diff --git a/pub/common/tgbot/tgbot.go b/pub/common/tgbot/tgbot.go new file mode 100644 index 0000000..de5989c --- /dev/null +++ b/pub/common/tgbot/tgbot.go @@ -0,0 +1,169 @@ +package tgbot + +import ( + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" +) + +type RSP_TYPE int + +const ( + TextType RSP_TYPE = 0 + FileType RSP_TYPE = 1 +) + +type TgCmdRsp struct { + Buf []byte + BufType RSP_TYPE +} + +// TgBot Telegram机器人. +type TgBot struct { + // 机器人标示; + token string + // // 管理员分组; + // admins []int64 + // // 管理群分组; + // groups []int64 + // 机器人引擎; + engine *tgbotapi.BotAPI + // 命令存储集; + command map[string]func(string, int64) TgCmdRsp + help string + exitRcv chan interface{} +} + +// BotCmd 命令. +type BotCmd struct { + // 指令; + Key string + // 摘要; + Summary string + // 处理; + Handle func(string, int64) TgCmdRsp +} + +type BotCmdList []*BotCmd + +// NewBotCmd 新的命令. +func NewBotCmd(key, summary string, handle func(string, int64) TgCmdRsp) *BotCmd { + return &BotCmd{ + Key: key, + Summary: summary, + Handle: handle, + } +} + +// NewTelegramBot 新的Telegram机器人. +func NewTelegramBot(token string) *TgBot { + return &TgBot{ + token: token, + exitRcv: make(chan interface{}), + } +} + +// Init 机器人初始化. +func (tb *TgBot) Init() *srvdef.SrvErr { + var err error + tb.engine, err = tgbotapi.NewBotAPI(tb.token) + if err != nil { + log.Warn("(tgbot:Init) tgbotapi.NewBotAPI failed", log.E(err)) + return srvdef.New(srvdef.BusinessLevel, "", "") + } + tb.command = make(map[string]func(string, int64) TgCmdRsp) + tb.command["help"] = tb.menu + return nil +} + +// Release 机器人退出释放 +func (tb *TgBot) Release() *srvdef.SrvErr { + tb.engine.StopReceivingUpdates() + close(tb.exitRcv) + return nil +} + +func (tb *TgBot) menu(string, int64) TgCmdRsp { + return TgCmdRsp{Buf: []byte(tb.help), BufType: TextType} +} + +// RegisterCommands 注册命令并且自动生成help. +func (tb *TgBot) RegisterCommands(commands []*BotCmd) { + for _, cmd := range commands { + tb.command[cmd.Key] = cmd.Handle + tb.help += fmt.Sprintln("/" + cmd.Key + ": " + cmd.Summary) + } +} + +// Receive 接收消息(阻塞). +func (tb *TgBot) Receive() error { + log.Info("(tgbot:Receive) start") + // 设置更新频率; + cfg := tgbotapi.NewUpdate(0) + //cfg.Timeout = 1 + + // 得到更新管道; + updates, err := tb.engine.GetUpdatesChan(cfg) + if err != nil { + log.Warn("(tgbot:Receive) tb.engine.GetUpdatesChan failed", log.E(err)) + return srvdef.New(srvdef.BusinessLevel, "", "") + } + for { + select { + case update := <-updates: + tb.ProcessMessage(&update) + case <-tb.exitRcv: + log.Info("(tgbot:Receive) exist") + break + default: + } + time.Sleep(time.Second * 1) + } + return nil +} + +func (tb *TgBot) ProcessMessage(update *tgbotapi.Update) { + // 忽略非消息更新; + if update.Message == nil { + log.Info("(tgbot:ProcessMessage) message is nil") + return + } + info := fmt.Sprintf("[Name: %s%s][ID: %d][Text: %s]\n", + update.Message.From.FirstName, update.Message.From.LastName, + update.Message.Chat.ID, update.Message.Text) + log.Info(info) + if update.Message.IsCommand() { + message := tgbotapi.NewMessage(update.Message.Chat.ID, "") + handle, ok := tb.command[update.Message.Command()] + if !ok { + message.Text = "unknown command" + } else { + param := update.Message.CommandArguments() + rsp := handle(param, update.Message.Chat.ID) + switch rsp.BufType { + case FileType: + + default: + message.Text = string(rsp.Buf) + } + //message.Text = handle(param) + } + tb.engine.Send(message) + } +} + +func (tb *TgBot) StopReceive() { + tb.engine.StopReceivingUpdates() + close(tb.exitRcv) +} + +// Send 发送消息. +func (tb *TgBot) Send(ids []int64, text string) { + for _, id := range ids { + message := tgbotapi.NewMessage(id, text) + tb.engine.Send(message) + } +} diff --git a/pub/common/tgbot/tgbot_test.go b/pub/common/tgbot/tgbot_test.go new file mode 100644 index 0000000..9f1e8a6 --- /dev/null +++ b/pub/common/tgbot/tgbot_test.go @@ -0,0 +1,35 @@ +package tgbot + +import ( + "testing" +) + +func create(t *testing.T) *TgBot { + token := "1014745091:AAFkw5j3Fw4ytvWgIll6iRH1q5WooS6JO74" + robot := NewTelegramBot(token) + if err := robot.Init(); err != nil { + t.Error(err) + t.Fail() + } + // 注册命令. + cmds := BotCmdList{NewBotCmd("test", "return testfunc result", testfunc)} + robot.RegisterCommands(cmds) + return robot +} + +func testfunc(string, int64) TgCmdRsp { + return TgCmdRsp{Buf: []byte("help"), BufType: TextType} +} + +func TestReceive(t *testing.T) { + robot := create(t) + if err := robot.Receive(); err != nil { + t.Error(err) + t.Fail() + } +} + +func TestSend(t *testing.T) { + robot := create(t) + robot.Send([]int64{790603940}, "test send") +} diff --git a/pub/common/timehelper.go b/pub/common/timehelper.go new file mode 100644 index 0000000..2d6b21b --- /dev/null +++ b/pub/common/timehelper.go @@ -0,0 +1,55 @@ +package common + +import ( + "path/filepath" + "strings" + "time" +) + +func BindUrl(hostname string, path ...string) string { + if hostname == "" { + return strings.TrimLeft(filepath.Join(path...), "/") + } + if len(path) == 0 { + return hostname + } + su := strings.TrimLeft(filepath.Join(path...), "/") + pr := strings.TrimRight(hostname, "/") + return pr + "/" + su +} + +// 将time.Time转换成int64 +func Time2Stamp(t time.Time) int64 { + return t.UnixNano() / 1e9 +} + +/**时间对象->字符串*/ +func Time2Str() string { + const shortForm = "2006-01-01 15:04:05" + t := time.Now() + temp := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.Local) + str := temp.Format(shortForm) + return str +} + +/*时间戳->字符串*/ +func Stamp2Str(stamp int64) string { + timeLayout := "2006-01-02 15:04:05" + str := time.Unix(stamp/1000, 0).Format(timeLayout) + return str +} + +/*时间戳->时间对象*/ +func Stamp2Time(stamp int64) time.Time { + stampStr := Stamp2Str(stamp) + timer := Str2Time(stampStr) + return timer +} + +/**字符串->时间对象*/ +func Str2Time(formatTimeStr string) time.Time { + timeLayout := "2006-01-02 15:04:05" + loc, _ := time.LoadLocation("Local") + theTime, _ := time.ParseInLocation(timeLayout, formatTimeStr, loc) //使用模板在对应时区转化为time.time类型 + return theTime +} diff --git a/pub/common/topicmessage/topicMessage.go b/pub/common/topicmessage/topicMessage.go new file mode 100644 index 0000000..b4a4876 --- /dev/null +++ b/pub/common/topicmessage/topicMessage.go @@ -0,0 +1,79 @@ +package topicmessage + +import ( + "time" +) + +/* 通信协议,第一版*/ + +// 用户用户注册消息 +type UserRegisterMsg struct { + UserId int64 `json:"userId" bson:"userId"` // 用户ID + AppId int32 `json:"appId" bson:"appId"` // appID + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台流水Id + DevType string `json:"devType" bson:"devType"` // 设备类型 + Name string `json:"name" bson:"name"` // 名称 + IsDirect bool `json:"isDirect" bson:"isDirect"` // true:是直推用户 + DeviceId string `json:"deviceId" bson:"deviceId"` // 设备Id + IP string `json:"ip,omitempty" bson:"ip"` // IP + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 + InviteCode string `json:"inviteCode" bson:"inviteCode"` // 被邀请码 + RegisterAt time.Time `json:"registerAt,omitempty" bson:"registerAt"` // 注册时间 + UserAgent string `json:"userAgent" bson:"userAgent"` // User-Agent识别 +} + +// 用户第一次访问 +type UserVisitMsg struct { + UserId int64 `bson:"userId" json:"userId"` // 用户Id + SumDate time.Time `bson:"sumDate" json:"sumDate"` // 记录时间 + AppId int32 `json:"appId" bson:"appId"` // appID + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + DevType string `json:"devType" bson:"devType"` // 设备类型 + IP string `json:"ip,omitempty" bson:"ip"` // IP + Version string `json:"version,omitempty" bson:"version"` // APP版本号 + VisitAt time.Time `json:"visitAt,omitempty" bson:"visitAt"` // 访问时间 + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 +} + +type UserOrderMsg struct { + UserId int64 `json:"userId" bson:"userId"` // 用户ID + AppId int32 `json:"appId" bson:"appId"` // appID + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台流水Id + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + DevType string `json:"devType" bson:"devType"` // 设备类型 + ChannelName string `json:"channelName" bson:"channelName"` // 支付渠道名字 + CID string `json:"cid" bson:"cid"` // 渠道id + Type string `bson:"type" json:"type"` // 充值方式 + RchgUse int64 `json:"rchgUse" bson:"rchgUse"` // 充值用途 1:金币 2:VIP + Repeat bool `bson:"repeat" json:"repeat"` // 复冲: true 重复充值 false 第一次 + OrderId string `json:"orderId" bson:"orderId"` // 流水id + Money int64 `json:"money" bson:"money"` // 充值金额 订单金额 + PayMoney int64 `json:"payMoney" bson:"payMoney"` // 实际到账金额 用户实际支付金额 + Status int `json:"status" bson:"status"` // 2付款失败 3付款成功(目前只有成功才发送) + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 + Rate string `json:"rate" bson:"rate"` // 渠道费率 + SuccessAt time.Time `json:"successAt" bson:"successAt"` // 成功时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type UserChannelEvent struct { + UserId int64 `bson:"userId" json:"userId"` // 用户Id + AppId int32 `json:"appId" bson:"appId"` // appID + DistrictCode string `json:"districtCode" bson:"districtCode"` // 渠道码 + WatchCount int64 `json:"watchCount,omitempty" bson:"watchCount"` // 观看次数 + WatchTime int64 `json:"watchTime,omitempty" bson:"watchTime"` // 观看时长 + AdClick int64 `json:"adClick,omitempty" bson:"adClick"` // 广告点击 + AppClick int64 `json:"appClick,omitempty" bson:"appClick"` // APP点击 + RequestCount int64 `json:"requestCount,omitempty" bson:"requestCount"` // 请求次数 +} + +type MediaPlayEvent struct { + MediaId int64 `json:"mediaId" bson:"mediaId"` // 视频Id + Title string `json:"title" bson:"title"` // 标题 + FileId string `json:"fileId" bson:"fileId"` // 媒资库ID + ActionType int64 `json:"actionType" bson:"actionType"` // 类型:0:播放 1:点赞 2:购买 + UserId int64 `json:"userId" bson:"userId"` // 用户Id + AppId int32 `json:"appId" bson:"appId"` // appID + WatchAt time.Time `json:"watchAt" bson:"watchAt"` // 时间 +} diff --git a/pub/common/typeConv.go b/pub/common/typeConv.go new file mode 100644 index 0000000..7f51bb4 --- /dev/null +++ b/pub/common/typeConv.go @@ -0,0 +1,64 @@ +package common + +import ( + "reflect" + "unicode" +) + +// StructToMap 对象转map,去掉无效值 +func StructToMap(u interface{}) (m map[string]interface{}) { + t := reflect.TypeOf(u) + v := reflect.ValueOf(u) + p := make(map[string]interface{}) + for i := 0; i < t.NumField(); i++ { + fv := v.Field(i) + ft := t.Field(i) + jsonTag, _ := ft.Tag.Lookup("json") + switch fv.Kind() { + case reflect.String: + if fv.String() != "" { + p[jsonTag] = fv.Interface() + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if fv.Int() != 0 { + p[jsonTag] = fv.Interface() + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if fv.Uint() != 0 { + p[jsonTag] = fv.Interface() + } + case reflect.Float32, reflect.Float64: + if fv.Float() != 0 { + p[jsonTag] = fv.Interface() + } + default: + p[jsonTag] = fv.Interface() + + } + + } + return p +} + +// Ucfirst () 字符串首字母大写 +func Ucfirst(str string) string { + for i, v := range str { + return string(unicode.ToUpper(v)) + str[i+1:] + } + return "" +} + +// Lcfirst () 字符串首字母小写 +func Lcfirst(str string) string { + for i, v := range str { + return string(unicode.ToLower(v)) + str[i+1:] + } + return "" +} + +// MobileBlurry () +func MobileBlurry(str string) string { + + return str[0:3] + "****" + str[7:] + +} diff --git a/pub/common/useragent/sdk_common.go b/pub/common/useragent/sdk_common.go new file mode 100644 index 0000000..19b62cb --- /dev/null +++ b/pub/common/useragent/sdk_common.go @@ -0,0 +1,83 @@ +package useragent + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +// SDKCommonHeader 由 H5/Flutter 将 SDK 完整公共参数转为 UTF-8 JSON,再用标准 Base64 编码后传入。 +const SDKCommonHeader = "X-SDK-Common" + +// 限制解析开销;正常 SDK 公共参数远小于此大小。 +const maxSDKCommonHeaderSize = 16 * 1024 + +// SDKCommonFields 接收五项 SDK 公共字段及客户端 app_version,完整 fields 中的其他字段自动忽略。 +// 业务 DevID、用户 ID、渠道等继续走原有业务参数,不能被 SDK 对象覆盖。 +type SDKCommonFields struct { + SID string `json:"sid" bson:"sid"` + DeviceID string `json:"device_id" bson:"device_id"` + Device string `json:"device" bson:"device"` + DeviceFingerprint string `json:"device_fingerprint" bson:"device_fingerprint"` + FPVersion string `json:"fp_version" bson:"fp_version"` + AppVersion string `json:"app_version" bson:"app_version"` // 客户端版本随下单快照保存,支付回调不使用当前版本补值。 +} + +// ParseSDKCommonHeader 缺少请求头返回 nil;格式错误交给调用方记录,不阻断业务请求。 +// 不修剪、截断或推算字段值,SDK 指纹生成失败时也保留其空值。 +func ParseSDKCommonHeader(header string) (*SDKCommonFields, error) { + header = strings.TrimSpace(header) + if header == "" { + return nil, nil + } + if len(header) > maxSDKCommonHeaderSize { + return nil, fmt.Errorf("SDK 公共参数请求头超过 %d 字节", maxSDKCommonHeaderSize) + } + data, err := base64.StdEncoding.DecodeString(header) + if err != nil { + return nil, fmt.Errorf("SDK 公共参数 Base64 解码失败: %w", err) + } + if !utf8.Valid(data) { + return nil, fmt.Errorf("SDK 公共参数不是有效的 UTF-8") + } + var fields *SDKCommonFields + if err := json.Unmarshal(data, &fields); err != nil { + return nil, fmt.Errorf("SDK 公共参数 JSON 解析失败: %w", err) + } + if fields == nil { + return nil, fmt.Errorf("SDK 公共参数必须是 JSON 对象,不能为 null") + } + return fields, nil +} + +// Clone 下单时保存独立快照,避免请求对象后续变化影响支付事件。 +func (fields *SDKCommonFields) Clone() *SDKCommonFields { + if fields == nil { + return nil + } + snapshot := *fields + return &snapshot +} + +// MissingFields 仅用于缺失观测;合法空值仍原样上报,不作为业务失败。 +func (fields *SDKCommonFields) MissingFields() []string { + if fields == nil { + fields = &SDKCommonFields{} + } + var missing []string + for _, field := range []struct{ name, value string }{ + {"sid", fields.SID}, + {"device_id", fields.DeviceID}, + {"device", fields.Device}, + {"device_fingerprint", fields.DeviceFingerprint}, + {"fp_version", fields.FPVersion}, + {"app_version", fields.AppVersion}, + } { + if strings.TrimSpace(field.value) == "" { + missing = append(missing, field.name) + } + } + return missing +} diff --git a/pub/common/useragent/ua.go b/pub/common/useragent/ua.go new file mode 100644 index 0000000..8c9a5f7 --- /dev/null +++ b/pub/common/useragent/ua.go @@ -0,0 +1,109 @@ +package useragent + +import ( + "net/url" + "strings" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" +) + +type UA struct { + DevID string + DevType string + SysType pubdef.SysType + Ver string + BuildID string + UserAgent string + SDKCommon *SDKCommonFields // SDK 公共参数独立保存,不能与原业务 UA 字段混用。 + RequestPath string // 仅用于定位埋点参数缺失的业务接口。 +} + +func IsH5(sysType pubdef.SysType) bool { + return sysType.IsH5() +} + +func (s UA) IsH5() bool { + return s.SysType.IsH5() +} + +func UserAgent(c *gin.Context) { + var uastr string + ua := UA{RequestPath: c.FullPath()} + var sdkErr error + ua.SDKCommon, sdkErr = ParseSDKCommonHeader(c.GetHeader(SDKCommonHeader)) + if sdkErr != nil { + log.Error("【SDK公共参数解析失败】UserAgent", log.Any("接口", ua.RequestPath), log.E(sdkErr)) + } + userAgent := c.GetHeader("X-User-Agent") //兼容H5 UserAgent + if userAgent == "" { + userAgent = c.Request.UserAgent() + } + ua.UserAgent = userAgent + if userAgent != "" { + // "BuildID=com.abc.Butterfly;SysType=h5;DevID=B9A00938-FE68-4686-9461-A365C7E5110D;Ver=0.0.1;DevType=iPhone;" + decodeUa, err := url.QueryUnescape(userAgent) + if err != nil { + log.Warn("URLDecode user-agent error", log.Any("ua", userAgent)) + uastr = userAgent + } else { + uastr = decodeUa + } + uas := strings.Split(uastr, ";") + for _, v := range uas { + vss := strings.Split(strings.TrimSpace(v), "=") + if len(vss) < 2 { + c.Set(consts.CtxUAH5, vss[0]) + // log.Warn("user-agent miss", log.Any("ua", userAgent), log.Any("v", v)) + continue + } + switch strings.TrimSpace(vss[0]) { + case "DevID": + ua.DevID = strings.TrimSpace(vss[1]) + c.Set(consts.CtxDevId, ua.DevID) + case "DevType": + ua.DevType = strings.TrimSpace(vss[1]) + c.Set(consts.CtxDevType, ua.DevType) + case "SysType": + input := strings.ToLower(strings.TrimSpace(vss[1])) + switch { + case strings.Contains(input, "h5_android"): + ua.SysType = pubdef.ST_H5Android + case strings.Contains(input, "h5_ios"): + ua.SysType = pubdef.ST_H5IOS + case strings.Contains(input, "h5_pc"): + ua.SysType = pubdef.ST_H5PC + case strings.Contains(input, "android"): + ua.SysType = pubdef.ST_Android + case strings.Contains(input, "ios"): + ua.SysType = pubdef.ST_IOS + case strings.Contains(input, "h5"): + ua.SysType = pubdef.ST_H5 + case strings.Contains(input, "pc"): + ua.SysType = pubdef.ST_PC + case strings.Contains(input, "light"): + ua.SysType = pubdef.ST_Light + case strings.Contains(input, "tg"): + ua.SysType = pubdef.ST_TG + case strings.Contains(input, "web"): + ua.SysType = pubdef.ST_Web + } + // 存入 context 时用 string,保持 ctx.GetString 兼容 + c.Set(consts.CtxSysType, string(ua.SysType)) + case "Ver": + ua.Ver = strings.TrimSpace(vss[1]) + c.Set(consts.CtxVersion, ua.Ver) + case "BuildID": + ua.BuildID = strings.TrimSpace(vss[1]) + c.Set(consts.CtxBuildId, ua.BuildID) + default: + log.Warn("user-agent unknown", log.Any("ua", userAgent)) + } + } + } else { + log.Warn("user-agent empty") + } + c.Set(consts.CtxUA, ua) +} diff --git a/pub/common/utils/array.go b/pub/common/utils/array.go new file mode 100644 index 0000000..e4b06d4 --- /dev/null +++ b/pub/common/utils/array.go @@ -0,0 +1,292 @@ +package utils + +import ( + "math/rand" + "reflect" +) + +//数组求交集,差集,并集 +/*求并集**/ +func Union(slice1, slice2 []string) []string { + m := make(map[string]int) + for _, v := range slice1 { + m[v]++ + } + + for _, v := range slice2 { + times, _ := m[v] + if times == 0 { + slice1 = append(slice1, v) + } + } + return slice1 +} + +// 求交集 +func Intersect(slice1, slice2 []string) []string { + m := make(map[string]int) + nn := make([]string, 0) + for _, v := range slice1 { + m[v]++ + } + + for _, v := range slice2 { + times, _ := m[v] + if times == 1 { + nn = append(nn, v) + } + } + return nn +} + +// 求交集 +func IntersectInt64(slice1, slice2 []int64) []int64 { + m := make(map[int64]int) + nn := make([]int64, 0) + for _, v := range slice1 { + m[v]++ + } + + for _, v := range slice2 { + times, _ := m[v] + if times == 1 { + nn = append(nn, v) + } + } + return nn +} + +// 求差集 slice1-并集 +func Difference(slice1, slice2 []string) []string { + m := make(map[string]int) + nn := make([]string, 0) + inter := Intersect(slice1, slice2) + for _, v := range inter { + m[v]++ + } + + for _, value := range slice1 { + times, _ := m[value] + if times == 0 { + nn = append(nn, value) + } + } + return nn +} + +// 求差集 slice1-并集 +func DifferenceInt64(slice1, slice2 []int64) []int64 { + m := make(map[int64]int) + nn := make([]int64, 0) + inter := IntersectInt64(slice1, slice2) + for _, v := range inter { + m[v]++ + } + + for _, value := range slice1 { + times, _ := m[value] + if times == 0 { + nn = append(nn, value) + } + } + return nn +} + +// 去重 +func RemoveRepeat(slc []int64) []int64 { + result := []int64{} // 存放结果 + for i := range slc { + flag := true + for j := range result { + if slc[i] == result[j] { + flag = false // 存在重复元素,标识为false + break + } + } + if flag { // 标识为false,不添加进结果 + result = append(result, slc[i]) + } + } + return result +} + +func IncludeInt32(slc []int32, n int32) bool { + for _, v := range slc { + if v == n { + return true + } + } + return false +} + +func IncludeInt64(slc []int64, n int64) bool { + for _, v := range slc { + if v == n { + return true + } + } + return false +} + +func RemoveInt64(slc []int64, n int64) []int64 { + for i := 0; i < len(slc); { + if slc[i] == n { + slc = append(slc[:i], slc[i+1:]...) + } else { + i++ + } + } + return slc +} + +func RemoveString(slc []string, n string) []string { + for i := 0; i < len(slc); { + if slc[i] == n { + slc = append(slc[:i], slc[i+1:]...) + } else { + i++ + } + } + return slc +} + +// RemoveRepeatString 去重string +func RemoveRepeatString(slc []string) []string { + result := []string{} // 存放结果 + for i := range slc { + flag := true + for j := range result { + if slc[i] == result[j] { + flag = false // 存在重复元素,标识为false + break + } + } + if flag { // 标识为false,不添加进结果 + result = append(result, slc[i]) + } + } + return result +} + +func IncludeString(slc []string, s string) bool { + for _, v := range slc { + if v == s { + return true + } + } + return false +} + +func EqualInt64s(slice1, slice2 []int64) bool { + if len(slice1) != len(slice2) { + return false + } + for _, v1 := range slice1 { + find := false + for _, v2 := range slice2 { + if v1 == v2 { + find = true + break + } + } + if !find { + return false + } + } + return true +} + +func EqualInt64WithOrder(slice1, slice2 []int64) bool { + if len(slice1) != len(slice2) { + return false + } + for k, v1 := range slice1 { + if slice2[k] != v1 { + return false + } + } + return true +} + +func EqualString(slice1, slice2 []string) bool { + if len(slice1) != len(slice2) { + return false + } + for _, v1 := range slice1 { + find := false + for _, v2 := range slice2 { + if v1 == v2 { + find = true + break + } + } + if find == false { + return false + } + } + return true +} + +// 切片乱序 +func ShuffleSlice(slice interface{}) { + rv := reflect.ValueOf(slice) + if rv.Type().Kind() != reflect.Slice { + return + } + + length := rv.Len() + if length < 2 { + return + } + swap := reflect.Swapper(slice) + //rand.Seed(time.Now().Unix()) + for i := length - 1; i >= 0; i-- { + j := rand.Intn(length) + swap(i, j) + } + return +} + +func RemoveOneInt64(slc []int64, n int64) []int64 { + for i := 0; i < len(slc); { + if slc[i] == n { + slc = append(slc[:i], slc[i+1:]...) + break + } else { + i++ + } + } + return slc +} + +// 求最大值 +func SliceMaxInt64(slc []int64) int64 { + maxVal := slc[0] + for i := 1; i < len(slc); i++ { + //从第二个 元素开始循环比较,如果发现有更大的,则交换 + if maxVal < slc[i] { + maxVal = slc[i] + } + } + return maxVal +} + +// 求最大值 +func SliceMinInt64(slc []int64) int64 { + minVal := slc[0] + for i := 1; i < len(slc); i++ { + //从第二个 元素开始循环比较,如果发现有更小的,则交换 + if minVal > slc[i] { + minVal = slc[i] + } + } + return minVal +} + +func ReverseAny(s interface{}) { + n := reflect.ValueOf(s).Len() + swap := reflect.Swapper(s) + for i, j := 0, n-1; i < j; i, j = i+1, j-1 { + swap(i, j) + } +} diff --git a/pub/common/utils/convUtil.go b/pub/common/utils/convUtil.go new file mode 100644 index 0000000..dda3ea9 --- /dev/null +++ b/pub/common/utils/convUtil.go @@ -0,0 +1,184 @@ +package utils + +import ( + "encoding/json" + "path/filepath" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ID = primitive.ObjectID + +// ToJsonM 将struct转换为 Json Map +func ToJsonM(obj interface{}) (map[string]interface{}, error) { + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + m := make(map[string]interface{}) + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +// ToBsonM 将struct转换为 Bson Map +func ToBsonM(s interface{}) (bson.M, error) { + data, err := bson.Marshal(s) + if err != nil { + return nil, err + } + m := make(bson.M) + if err := bson.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func MapToJsonString(m map[string]string) (string, error) { + if data, err := json.Marshal(m); err != nil { + return "", err + } else { + return string(data), nil + } +} + +func StructToJsonString(s interface{}) (string, error) { + j, err := json.Marshal(s) + if err != nil { + return "", err + } + m := make(map[string]interface{}) + if err := json.Unmarshal(j, &m); err != nil { + return "", err + } + if data, err := json.Marshal(m); err != nil { + return "", err + } else { + return string(data), nil + } +} + +// BMapToStruct s is struct ptr +func BMapToStruct(s interface{}, m map[string]interface{}) error { + bsonBytes, err := bson.Marshal(m) + if err != nil { + return err + } + return bson.Unmarshal(bsonBytes, s) +} + +// JMapToStruct s is struct ptr +func JMapToStruct(s interface{}, m map[string]interface{}) error { + bsonBytes, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(bsonBytes, s) +} + +// JSONStruct2Map 将struct转换为Map +func JSONStruct2Map(obj interface{}) (map[string]interface{}, error) { + m := make(map[string]interface{}) + j, err := json.Marshal(obj) + if err != nil { + return nil, err + } + if err := json.Unmarshal(j, &m); err != nil { + return nil, err + } + return m, err +} + +// Map2JSONStruct 将map转换为Json struct +func Map2JSONStruct(v interface{}, m map[string]interface{}) error { + j, err := json.Marshal(m) + if err != nil { + return err + } + if err := json.Unmarshal(j, v); err != nil { + return err + } + return err +} + +func IDArray(ids []string) ([]ID, error) { + var idArray []ID + for _, id := range ids { + _id, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + idArray = append(idArray, _id) + } + + return idArray, nil +} + +// 拼接https:// +func BindHttpSch(path string) string { + if strings.HasPrefix(path, "http") { + return path + } else { + return "https://" + path + } +} + +func BindUrl(hostname string, path ...string) string { + if hostname == "" { + return strings.TrimLeft(filepath.Join(path...), "/") + } + if len(path) == 0 { + return hostname + } + su := strings.TrimLeft(filepath.Join(path...), "/") + pr := strings.TrimRight(hostname, "/") + return pr + "/" + su +} + +func ObjectIDs2String(obj []primitive.ObjectID) []string { + arr := []string{} + if len(obj) == 0 { + return arr + } + for _, o := range obj { + arr = append(arr, o.Hex()) + } + return arr +} + +func String2ObjectID(obj []string) []primitive.ObjectID { + arr := []primitive.ObjectID{} + for _, o := range obj { + oid, err := primitive.ObjectIDFromHex(o) + if err != nil { + continue + } + arr = append(arr, oid) + } + return arr +} + +// PercentOf - calculate what percent [number1] is of [number2]. +// ex. 300 is 12.5% of 2400 +func PercentOf(part int, total int) float64 { + return (float64(part) * float64(100)) / float64(total) +} + +func MergeMap(src, dest map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return dest + } + if len(dest) == 0 { + return src + } + if len(src) == 0 && len(dest) == 0 { + return make(map[string]interface{}) + } + for k, v := range dest { + src[k] = v + } + return src +} diff --git a/pub/common/utils/copy.go b/pub/common/utils/copy.go new file mode 100644 index 0000000..51e0b2c --- /dev/null +++ b/pub/common/utils/copy.go @@ -0,0 +1,130 @@ +package utils + +import ( + "bytes" + "encoding/gob" + "reflect" + "time" +) + +// DeepCopy 深copy +func DeepCopy(dst, src interface{}) error { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(src); err != nil { + return err + } + return gob.NewDecoder(bytes.NewBuffer(buf.Bytes())).Decode(dst) +} + +// Interface for delegating copy process to type +type Interface interface { + DeepCopy() interface{} +} + +// Iface is an alias to Copy; this exists for backwards compatibility reasons. +func Iface(iface interface{}) interface{} { + return Copy(iface) +} + +// Copy creates a deep copy of whatever is passed to it and returns the copy +// in an interface{}. The returned value will need to be asserted to the +// correct type. +func Copy(src interface{}) interface{} { + if src == nil { + return nil + } + + // Make the interface a reflect.Value + original := reflect.ValueOf(src) + + // Make a copy of the same type as the original. + cpy := reflect.New(original.Type()).Elem() + + // Recursively copy the original. + copyRecursive(original, cpy) + + // Return the copy as an interface. + return cpy.Interface() +} + +// copyRecursive does the actual copying of the interface. It currently has +// limited support for what it can handle. Add as needed. +func copyRecursive(original, cpy reflect.Value) { + // check for implement deepcopy.Interface + if original.CanInterface() { + if copier, ok := original.Interface().(Interface); ok { + cpy.Set(reflect.ValueOf(copier.DeepCopy())) + return + } + } + + // handle according to original's Kind + switch original.Kind() { + case reflect.Ptr: + // Get the actual value being pointed to. + originalValue := original.Elem() + + // if it isn't valid, return. + if !originalValue.IsValid() { + return + } + cpy.Set(reflect.New(originalValue.Type())) + copyRecursive(originalValue, cpy.Elem()) + + case reflect.Interface: + // If this is a nil, don't do anything + if original.IsNil() { + return + } + // Get the value for the interface, not the pointer. + originalValue := original.Elem() + + // Get the value by calling Elem(). + copyValue := reflect.New(originalValue.Type()).Elem() + copyRecursive(originalValue, copyValue) + cpy.Set(copyValue) + + case reflect.Struct: + t, ok := original.Interface().(time.Time) + if ok { + cpy.Set(reflect.ValueOf(t)) + return + } + // Go through each field of the struct and copy it. + for i := 0; i < original.NumField(); i++ { + // The Type's StructField for a given field is checked to see if StructField.PkgPath + // is set to determine if the field is exported or not because CanSet() returns false + // for settable fields. I'm not sure why. -mohae + if original.Type().Field(i).PkgPath != "" { + continue + } + copyRecursive(original.Field(i), cpy.Field(i)) + } + + case reflect.Slice: + if original.IsNil() { + return + } + // Make a new slice and copy each element. + cpy.Set(reflect.MakeSlice(original.Type(), original.Len(), original.Cap())) + for i := 0; i < original.Len(); i++ { + copyRecursive(original.Index(i), cpy.Index(i)) + } + + case reflect.Map: + if original.IsNil() { + return + } + cpy.Set(reflect.MakeMap(original.Type())) + for _, key := range original.MapKeys() { + originalValue := original.MapIndex(key) + copyValue := reflect.New(originalValue.Type()).Elem() + copyRecursive(originalValue, copyValue) + copyKey := Copy(key.Interface()) + cpy.SetMapIndex(reflect.ValueOf(copyKey), copyValue) + } + + default: + cpy.Set(original) + } +} diff --git a/pub/common/utils/file.go b/pub/common/utils/file.go new file mode 100644 index 0000000..c595e6f --- /dev/null +++ b/pub/common/utils/file.go @@ -0,0 +1,56 @@ +package utils + +import "os" + +// CreateEmptyFile 创建一个固定size大小的空文件 +// 如果seek文件失败,则删除文件,并返回错误信息 +func CreateEmptyFile(name string, size int64) (f *os.File, err error) { + defer func() { + if err != nil && f != nil { + f.Close() + os.Remove(name) + } + }() + f, err = os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return + } + if _, err = f.Seek(size-1, 0); err != nil { + return + } + _, err = f.Write([]byte{0}) + return +} + +func WriteFile(data []byte, filename string) (err error) { + defer func() { + if err != nil { + os.Remove(filename) + } + }() + + pi := PathForInfo(filename) + if pi.Dir != "" { + if err = os.MkdirAll(pi.Dir, 0755); err != nil { + return + } + } + + f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return + } + _, err = f.Write(data) + f.Close() + return +} +func PathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/pub/common/utils/hash.go b/pub/common/utils/hash.go new file mode 100644 index 0000000..568035c --- /dev/null +++ b/pub/common/utils/hash.go @@ -0,0 +1,12 @@ +package utils + +// // MakeCrc +// func MakeCrc(s string) uint32 { +// return crc32.ChecksumIEEE([]byte(s)) +// } + +// func Md5V(str string) string { +// h := md5.New() +// h.Write([]byte(str)) +// return hex.EncodeToString(h.Sum(nil)) +// } \ No newline at end of file diff --git a/pub/common/utils/http.go b/pub/common/utils/http.go new file mode 100644 index 0000000..1467d0e --- /dev/null +++ b/pub/common/utils/http.go @@ -0,0 +1,283 @@ +package utils + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +var once sync.Once +var _client *http.Client + +func GetHttpClient() *http.Client { + once.Do(func() { + _client = &http.Client{ + Timeout: time.Minute * 1, //设置超时时间 + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, //限制 TLS握手的时间 + ResponseHeaderTimeout: 60 * time.Second, //限制读取response header的时间,默认 timeout + 5*time.Second + ExpectContinueTimeout: 1 * time.Second, //限制client在发送包含 Expect: 100-continue的header到收到继续发送body的response之间的时间等待。 + MaxIdleConns: 100, //所有host的连接池最大连接数量,默认无穷大 + MaxIdleConnsPerHost: 300, //每个host的连接池最大空闲连接数,默认2 + MaxConnsPerHost: 100, //每个host的最大连接数量 + IdleConnTimeout: 3 * time.Minute, //how long an idle connection is kept in the connection pool. + }, + } + }) + return _client +} + +func getBytes(request interface{}) ([]byte, error) { + var body []byte + switch t := request.(type) { + case []byte: + body = t + case string: + body = []byte(t) + default: + var err error + body, err = json.Marshal(request) + if err != nil { + return nil, err + } + } + return body, nil +} + +type OnHttpResponse func(res *http.Response) error + +func HttpRequest(req *http.Request, onResponse OnHttpResponse) error { + client := GetHttpClient() + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if onResponse != nil { + err = onResponse(res) + } + return err +} + +func HttpMethod(url, method string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + req, err := http.NewRequest(method, url, body) + if err != nil { + return err + } + if header != nil { + req.Header = header + } + return HttpRequest(req, onResponse) +} + +func HttpGet(url string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + return HttpMethod(url, http.MethodGet, header, body, onResponse) +} + +func HttpPost(url string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + return HttpMethod(url, http.MethodPost, header, body, onResponse) +} + +func HttpDelete(url string, header http.Header, body io.Reader, onResponse OnHttpResponse) error { + return HttpMethod(url, http.MethodDelete, header, body, onResponse) +} + +func HttpPostRequestJson(url string, request interface{}, resp interface{}) error { + header := make(http.Header) + header.Set("Content-Type", "application/json") + body, err := getBytes(request) + if err != nil { + return err + } + return HttpPost(url, header, bytes.NewReader(body), func(res *http.Response) error { + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, resp) + }) +} + +func POSTJson(url string, headers map[string]string, data interface{}) (*http.Response, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + body, err := getBytes(data) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header = header + client := GetHttpClient() + return client.Do(req) +} + +func GetWithParam(url string, headers map[string]string, params ...interface{}) (*http.Response, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + for _, p := range params { + url = addParams(url, toUrlValues(p)) + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header = header + client := GetHttpClient() + return client.Do(req) +} + +func GetWithJResp(bind interface{}, url string, headers map[string]string, params ...interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + for _, p := range params { + url = addParams(url, toUrlValues(p)) + } + code := http.StatusBadRequest + err := HttpGet(url, header, nil, func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +func POSTWithJResp(bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + body, err := getBytes(params) + if err != nil { + return http.StatusBadRequest, err + } + code := http.StatusBadRequest + err = HttpPost(url, header, bytes.NewReader(body), func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +func POSTJsonWithJResp(bind interface{}, url string, headers map[string]string, data interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + header.Set("Content-Type", "application/json") + body, err := getBytes(data) + if err != nil { + return http.StatusBadRequest, err + } + code := http.StatusBadRequest + err = HttpPost(url, header, bytes.NewReader(body), func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +func DeleteWithJResp(bind interface{}, url string, headers map[string]string, params interface{}) (int, error) { + header := make(http.Header) + for k, v := range headers { + header.Set(k, v) + } + body, err := getBytes(params) + if err != nil { + return http.StatusBadRequest, err + } + code := http.StatusBadRequest + err = HttpDelete(url, header, bytes.NewReader(body), func(res *http.Response) error { + code = res.StatusCode + if res.StatusCode != http.StatusOK { + return fmt.Errorf("http request (%s) error %s", url, res.Status) + } + content, err := io.ReadAll(res.Body) + if err != nil { + return err + } + return json.Unmarshal(content, bind) + }) + return code, err +} + +// Add params to a url string. +func addParams(url_ string, params url.Values) string { + if len(params) == 0 { + return url_ + } + + if !strings.Contains(url_, "?") { + url_ += "?" + } + + if strings.HasSuffix(url_, "?") || strings.HasSuffix(url_, "&") { + url_ += params.Encode() + } else { + url_ += "&" + params.Encode() + } + + return url_ +} + +func toUrlValues(v interface{}) url.Values { + switch t := v.(type) { + case url.Values: + return t + case map[string][]string: + return url.Values(t) + case map[string]string: + rst := make(url.Values) + for k, v := range t { + rst.Add(k, v) + } + return rst + case nil: + return make(url.Values) + default: + panic("Invalid value") + } +} diff --git a/pub/common/utils/jwt.go b/pub/common/utils/jwt.go new file mode 100644 index 0000000..5810a0b --- /dev/null +++ b/pub/common/utils/jwt.go @@ -0,0 +1,127 @@ +package utils + +import ( + "errors" + "strings" + "time" + + "github.com/dgrijalva/jwt-go" +) + +const ( + userIDKey = "userId" + ipKey = "ip" + uaKey = "ua" +) + +type IPClaims struct { + Ip string + Expire time.Duration + UA string +} + +func GenerateIpToken(claims IPClaims, secret string) (string, error) { + return generateToken(jwt.MapClaims{ + ipKey: claims.Ip, + uaKey: claims.UA, + "exp": Now().Add(claims.Expire).Unix(), + "issuer": "com.butterfly", + "sub": "asign", + "aud": "public", + }, []byte(secret)) +} + +func ParseIpToken(tokenString string, secret string) (pass bool, ip, ua string, err error) { + t, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + var isExpire *bool + if vErr, ok := err.(*jwt.ValidationError); ok { + e := vErr.Errors&jwt.ValidationErrorExpired > 0 + isExpire = &e + } + if err != nil || isExpire == nil { + return + } + var ok bool + var jc jwt.MapClaims + jc, ok = t.Claims.(jwt.MapClaims) + if !ok { + err = errors.New("parse token fail") + return + } + ip, ok = jc[ipKey].(string) + if !ok { + err = errors.New("parse token fail") + return + } + ua, ok = jc[uaKey].(string) + if !ok { + err = errors.New("parse token fail") + return + } + pass = true + return +} + +type UserClaims struct { + UserID int64 + Expire time.Duration +} + +func generateToken(claims jwt.MapClaims, secret []byte) (string, error) { + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) +} + +func GenerateUserToken(claims UserClaims, secret string) (string, error) { + return generateToken(jwt.MapClaims{ + userIDKey: claims.UserID, + "exp": Now().Add(claims.Expire).Unix(), + "issuer": "com.butterfly", + "sub": "asign", + "aud": "public", + }, []byte(secret)) +} + +// ParseToken 解析Token +func ParseUserToken(token string, secret string) (userId int64, newToken string, err error) { + t, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + var isExpire *bool + if vErr, ok := err.(jwt.ValidationError); ok { + e := vErr.Errors&jwt.ValidationErrorExpired > 0 + isExpire = &e + } + if err != nil && isExpire == nil { + return + } + + jc, ok := t.Claims.(jwt.MapClaims) + if !ok { + err = errors.New("unknown token error1") + return + } + + id, ok := jc[userIDKey].(float64) + if !ok { + err = errors.New("unknown token error1") + return + } + userId = int64(id) + if isExpire != nil && *isExpire { + newToken, err = GenerateUserToken(UserClaims{UserID: userId, Expire: 30 * 24 * time.Hour}, secret) + return + } + err = nil + return +} + +// Strips 'Bearer ' prefix from bearer token string +func StripBearerPrefixFromTokenString(tok string) string { + if len(tok) > 6 && strings.ToUpper(tok[0:7]) == "BEARER " { + return tok[7:] + } else { + return tok + } +} \ No newline at end of file diff --git a/pub/common/utils/num.go b/pub/common/utils/num.go new file mode 100644 index 0000000..8d0c572 --- /dev/null +++ b/pub/common/utils/num.go @@ -0,0 +1,20 @@ +package utils + +import ( + "math/rand" + "time" +) + +// CountDigits 有几位数字 +func CountDigits(i int) (count int) { + for i != 0 { + i /= 10 + count++ + } + return +} + +func RandInt(min int, max int) int { + rand.Seed(time.Now().UnixNano()) + return min + rand.Intn(max-min) +} diff --git a/pub/common/utils/page.go b/pub/common/utils/page.go new file mode 100644 index 0000000..796db6e --- /dev/null +++ b/pub/common/utils/page.go @@ -0,0 +1,19 @@ +package utils + +func GetPageRange(totalLen, pageNum, pageSize int) (start, end int) { + if pageNum < 1 { + pageNum = 1 + } + if pageSize < 1 { + pageSize = 10 + } + start = (pageNum - 1) * pageSize + if start > totalLen { + start = totalLen + } + end = start + pageSize + if end > totalLen { + end = totalLen + } + return +} diff --git a/pub/common/utils/path.go b/pub/common/utils/path.go new file mode 100644 index 0000000..c1cd232 --- /dev/null +++ b/pub/common/utils/path.go @@ -0,0 +1,109 @@ +package utils + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/PuerkitoBio/purell" +) + +// PathSegment slug 优先,空则回退数字 id。 +func PathSegment(slug string, id int64) string { + if s := strings.TrimSpace(slug); s != "" { + return s + } + if id > 0 { + return strconv.FormatInt(id, 10) + } + return "" +} + +// PathInfo 路径信息 +type PathInfo struct { + Dir string // 目录 + Ext string // 扩展名 + Filename string // 没有扩展名的文件名名称 + FullFileName string // 文件全名 +} + +// PathForInfo 解析路径 +// 返回文件,路径,文件名等信息 +func PathForInfo(path string) (pi PathInfo) { + if path == "" { + return + } + ext := filepath.Ext(path) + dir, name := filepath.Split(path) + + pi.Dir = dir + pi.Ext = ext + pi.Filename = strings.TrimRight(name, ext) + pi.FullFileName = name + return +} + +// JoinUrlPath 域名与uri拼接(走 purell 标准化,会剥掉尾部斜杠)。 +// 适用于 CDN 文件链接(m3u8 分段、vtt、静态图等)这类"不需要尾部斜杠"的场景。 +// 需要保留尾部斜杠的页面 URL(sitemap/rss/indexnow)请使用 JoinUrlPathKeepSlash。 +func JoinUrlPath(host string, uri string) (urlpath string) { + urlpath = joinRawUrlPath(host, uri) + if urlpath == "" { + return + } + // 标准化 URL + normalized, err := purell.NormalizeURLString(urlpath, purell.FlagsUsuallySafeGreedy) + if err != nil { + return urlpath + } + return normalized +} + +// JoinUrlPathKeepSlash 域名与uri拼接,保留 uri 原始的尾部斜杠,不做任何标准化。 +// sitemap/rss/indexnow 的页面 URL 必须与路由注册的 /archives/:id/ 等完全一致, +// 否则爬虫每次访问都会被 gin 的 RedirectTrailingSlash 301 到带 / 的地址, +// 浪费抓取预算并造成重定向链。 +func JoinUrlPathKeepSlash(host string, uri string) string { + return joinRawUrlPath(host, uri) +} + +// JoinUrlPathAppendEnd 域名与uri拼接 追加/结尾 +func JoinUrlPathAppendEnd(host string, uri string) (urlpath string) { + if uri == "" { + return + } + switch { + case strings.HasSuffix(host, "/") && strings.HasPrefix(uri, "/"): + urlpath = host[0:len(host)-1] + uri + case !strings.HasSuffix(host, "/") && !strings.HasPrefix(uri, "/"): + urlpath = host + "/" + uri + default: + urlpath = host + uri + } + // 标准化 URL + normalized, err := purell.NormalizeURLString(urlpath, purell.FlagsUsuallySafeGreedy) + if err != nil { + return urlpath + } + if !strings.HasSuffix(normalized, "/") { + normalized = fmt.Sprintf("%s/", normalized) + } + return normalized +} + +// joinRawUrlPath 纯拼接,不做任何标准化。 +func joinRawUrlPath(host string, uri string) (urlpath string) { + if uri == "" { + return + } + switch { + case strings.HasSuffix(host, "/") && strings.HasPrefix(uri, "/"): + urlpath = host[0:len(host)-1] + uri + case !strings.HasSuffix(host, "/") && !strings.HasPrefix(uri, "/"): + urlpath = host + "/" + uri + default: + urlpath = host + uri + } + return +} diff --git a/pub/common/utils/redis.go b/pub/common/utils/redis.go new file mode 100644 index 0000000..1064e73 --- /dev/null +++ b/pub/common/utils/redis.go @@ -0,0 +1,56 @@ +package utils + +import ( + "bytes" + "time" + "videoweb/pub/common/enum/redisconst" + sErr "videoweb/pub/common/errors" + "videoweb/pub/redis" +) + +const ( + expireTime = 30 * 24 * time.Hour +) + +type TokenRedis struct { +} + +func NewTokenRedis() *TokenRedis { + return &TokenRedis{} +} + +func (tr *TokenRedis) Save(userId int64, token string) (err error) { + if err = tr.Delete(userId); err != nil { + return + } + err = redis.Set(redisconst.AppUserTokenKey(userId), token, expireTime) + if err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +func (tr *TokenRedis) Exists(userId int64, token string) (has bool, err error) { + tokenbytes, err := tr.Get(userId) + if err != nil { + return + } + has = bytes.Compare(tokenbytes, []byte(token)) == 0 + return +} + +func (tr *TokenRedis) Get(userId int64) (token []byte, err error) { + token, err = redis.GetBytes(redisconst.AppUserTokenKey(userId)) + if err != nil { + err = sErr.MakeWithErr(sErr.RedisGet, err) + } + return +} + +func (tr *TokenRedis) Delete(userId int64) (err error) { + err = redis.Del(redisconst.AppUserTokenKey(userId)) + if err != nil { + err = sErr.MakeWithErr(sErr.RedisDel, err) + } + return +} diff --git a/pub/common/utils/shellcmd.go b/pub/common/utils/shellcmd.go new file mode 100644 index 0000000..7e51287 --- /dev/null +++ b/pub/common/utils/shellcmd.go @@ -0,0 +1,15 @@ +package utils + +import ( + "bytes" + "os/exec" +) + +func ShellCmd(cmdStr string) (string, error) { + cmd := exec.Command("/bin/bash", "-c", cmdStr) + var out bytes.Buffer + cmd.Stdout = &out + ec := cmd.Run() + + return out.String(), ec +} diff --git a/pub/common/utils/slices.go b/pub/common/utils/slices.go new file mode 100644 index 0000000..aa5d237 --- /dev/null +++ b/pub/common/utils/slices.go @@ -0,0 +1,116 @@ +package utils + +import ( + "golang.org/x/exp/constraints" + "sort" +) + +func SliceConstruct[T any](fn func() T) T { + return fn() +} + +type SliceWalkCallback func(index int) bool // true 继续 +func SliceWalk(length int, cb SliceWalkCallback) { + for i := 0; i < length; i++ { + if ok := cb(i); ok { + continue + } else { + break + } + } +} + +type SliceIncludeFilter func(index int) bool // 返回 true 继续, false 中断 +func SliceInclude(length int, filter SliceIncludeFilter) (int, bool) { + for i := 0; i < length; i++ { + if ok := filter(i); ok { + return i, true + } + } + return -1, false +} + +type SliceConstructNFilter[T any] func(index int) (T, bool) // 返回 是否保留,项值 +func SliceConstructN[T any](length int, filter SliceConstructNFilter[T]) []T { + ret := make([]T, 0) + for i := 0; i < length; i++ { + if item, keep := filter(i); keep { + ret = append(ret, item) + } + } + return ret +} + +// SliceUnique 使用泛型实现切片去重 +func SliceUnique[T comparable](s []T) []T { + seen := make(map[T]bool) + j := 0 + for _, v := range s { + if _, ok := seen[v]; !ok { + seen[v] = true + s[j] = v + j++ + } + } + return s[:j] +} + +// SlicesDifference 求两个集合的差集 +func SlicesDifference[T comparable](a, b []T) []T { + // 使用 map 存储集合 b 的元素,方便快速判断元素是否存在 + seen := make(map[T]struct{}, len(b)) + for _, v := range b { + seen[v] = struct{}{} + } + + // 遍历集合 a,将不在集合 b 中的元素放入新的切片 + diff := make([]T, 0) + for _, v := range a { + if _, ok := seen[v]; !ok { + diff = append(diff, v) + } + } + + return diff +} + +func RemoveSlicesElement[T comparable](slice []T, element T) []T { + // 使用两个指针,一个遍历,一个用来记录有效元素的位置 + j := 0 + for i := range slice { + if slice[i] != element { + slice[j] = slice[i] + j++ + } + } + // 返回截取后的切片 + return slice[:j] +} + +func SlicesIntersect[T constraints.Ordered | ~string](slice1, slice2 []T) []T { + dict := make(map[T]int) + ret := make([]T, 0) + for _, v1 := range slice1 { + dict[v1]++ + } + for _, v2 := range slice2 { + if _, exists := dict[v2]; exists { + ret = append(ret, v2) + } + } + return ret +} + +func IsSliceEqual[T constraints.Ordered](src []T, dst []T) bool { + sort.Slice(src, func(i, j int) bool { return src[i] < src[j] }) + sort.Slice(dst, func(i, j int) bool { return dst[i] < dst[j] }) + if len(src) != len(dst) { + return false + } + for i := range src { + if src[i] != dst[i] { + return false + } + } + return true +} diff --git a/pub/common/utils/slug_source.go b/pub/common/utils/slug_source.go new file mode 100644 index 0000000..0668024 --- /dev/null +++ b/pub/common/utils/slug_source.go @@ -0,0 +1,270 @@ +package utils + +import ( + "regexp" + "strings" + "unicode" +) + +const slugTitleRuneLimit = 10 + +var ( + reSlugFC2Code = regexp.MustCompile(`(?i)fc2[-_ ]?ppv[-_ ]?\d{5,12}`) + reSlugNumLetterCode = regexp.MustCompile(`(?i)\d{3}[a-z]{2,6}[-_ ]?\d{2,7}`) + reSlugLetterNumCode = regexp.MustCompile(`(?i)[a-z]{2,8}[-_ ]?\d{2,7}`) + reSlugLeadingYear = regexp.MustCompile(`^20\d{2}`) + reSlugLeadingBracket = regexp.MustCompile(`^[【\[]([^】\]]+)[】\]]|^[((]([^))]+)[))]|^「([^」]+)」`) +) + +// 标题开头常见的无意义前缀,长词优先。 +var slugTitlePrefixNoise = []string{ + "中文字幕", "中文翻譯", "中文翻译", "无码流出", "無碼流出", + "无码破解", "無碼破解", "无修正", "無修正", "完整版", "抢先版", + "高清版", "字幕版", +} + +// 技术规格、版本标记,单独出现在标题开头时去掉。 +var slugTitlePrefixTokens = []string{ + "1080P", "2160P", "720P", "360P", "FHD", "UHD", "4K", "8K", "HD", + "FANZA", "MGS", "最新", "高清", "流出", "破解", "字幕", "中字", +} + +// 关键词里过于通用、几乎每条都有的词,不单独拿来做 slug。 +var slugGenericKeywords = map[string]struct{}{ + "中文字幕": {}, "中文翻译": {}, "中文翻譯": {}, "无码流出": {}, "無碼流出": {}, + "无码": {}, "無碼": {}, "有码": {}, "有碼": {}, "无修正": {}, "無修正": {}, + "日本": {}, "国产": {}, "國產": {}, "素人": {}, "人妻": {}, "熟女": {}, + "中文": {}, "字幕": {}, "高清": {}, "最新": {}, "流出": {}, "破解": {}, + "hd": {}, "4k": {}, "fhd": {}, "fanza": {}, +} + +// SlugSourceFromKeywordsOrTitle 从标题和关键词提取适合转拼音的 slug 源文本。 +// 优先用标题里的番号或去掉无意义前缀后的有效片段,不再固定取第一个关键词。 +func SlugSourceFromKeywordsOrTitle(keywords []string, title string) string { + title = strings.TrimSpace(title) + if code := firstContentCode(title, keywords); code != "" { + return code + } + if slice := meaningfulTitleSlice(cleanTitleForSlug(title)); slice != "" { + return slice + } + if kw := pickDistinctKeyword(keywords); kw != "" { + return kw + } + if title != "" { + return GetPrefix(title, slugTitleRuneLimit) + } + return firstNonEmptyKeyword(keywords) +} + +func firstContentCode(title string, keywords []string) string { + if code := extractContentCode(title); code != "" { + return code + } + for _, kw := range keywords { + if code := extractContentCode(kw); code != "" { + return code + } + } + return "" +} + +func extractContentCode(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if m := reSlugFC2Code.FindString(s); m != "" { + return m + } + if m := reSlugNumLetterCode.FindString(s); m != "" { + return m + } + if m := reSlugLetterNumCode.FindString(s); m != "" && !isQualityToken(m) { + return m + } + return "" +} + +func isQualityToken(s string) bool { + s = strings.ToUpper(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, " ", "") + switch s { + case "1080P", "2160P", "720P", "360P", "4K", "8K", "HD", "FHD", "UHD": + return true + default: + return false + } +} + +func cleanTitleForSlug(title string) string { + s := strings.TrimSpace(title) + for i := 0; i < 8 && s != ""; i++ { + next := stripOnceTitlePrefix(s) + if next == s { + break + } + s = next + } + return strings.TrimSpace(s) +} + +func stripOnceTitlePrefix(s string) string { + s = strings.TrimLeftFunc(s, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune(`-_~|/.,;::、·•*#@`, r) + }) + if s == "" { + return "" + } + if loc := reSlugLeadingBracket.FindStringSubmatch(s); len(loc) > 0 { + inner := firstSubmatch(loc) + whole := loc[0] + if extractContentCode(inner) != "" { + return strings.TrimSpace(inner + s[len(whole):]) + } + if isNoiseText(inner) || runeLen(inner) <= 4 { + return strings.TrimSpace(s[len(whole):]) + } + } + lower := strings.ToLower(s) + for _, prefix := range slugTitlePrefixNoise { + if strings.HasPrefix(s, prefix) || strings.HasPrefix(lower, strings.ToLower(prefix)) { + return strings.TrimSpace(s[len([]byte(prefixMatch(s, prefix))):]) + } + } + for _, token := range slugTitlePrefixTokens { + if consumed, ok := consumeLeadingToken(s, token); ok { + return consumed + } + } + if loc := reSlugLeadingYear.FindStringIndex(s); loc != nil && loc[0] == 0 { + rest := strings.TrimSpace(s[loc[1]:]) + if rest != "" { + return rest + } + } + return s +} + +func prefixMatch(s, prefix string) string { + if strings.HasPrefix(s, prefix) { + return prefix + } + lower := strings.ToLower(s) + p := strings.ToLower(prefix) + if strings.HasPrefix(lower, p) { + return s[:len(prefix)] + } + return prefix +} + +func consumeLeadingToken(s, token string) (string, bool) { + if len(s) < len(token) { + return s, false + } + if !strings.HasPrefix(strings.ToLower(s), strings.ToLower(token)) { + return s, false + } + rest := s[len(token):] + if rest == "" { + return "", true + } + r := []rune(rest)[0] + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return s, false + } + return strings.TrimSpace(rest), true +} + +func firstSubmatch(m []string) string { + for i := 1; i < len(m); i++ { + if strings.TrimSpace(m[i]) != "" { + return strings.TrimSpace(m[i]) + } + } + return "" +} + +func meaningfulTitleSlice(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "" + } + return GetPrefix(title, slugTitleRuneLimit) +} + +func pickDistinctKeyword(keywords []string) string { + best := "" + bestScore := 0 + for _, kw := range keywords { + kw = strings.TrimSpace(kw) + if kw == "" || isGenericKeyword(kw) { + continue + } + score := runeLen(kw) + if score < 3 { + continue + } + if score > bestScore { + best = kw + bestScore = score + } + } + return best +} + +func firstNonEmptyKeyword(keywords []string) string { + for _, kw := range keywords { + if s := strings.TrimSpace(kw); s != "" { + return s + } + } + return "" +} + +func isGenericKeyword(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + return isGenericKeywordExact(s) || isNoiseText(s) +} + +func isNoiseText(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + if isGenericKeywordExact(s) || isQualityToken(s) { + return true + } + for _, prefix := range slugTitlePrefixNoise { + if s == prefix { + return true + } + } + parts := strings.FieldsFunc(s, func(r rune) bool { + return strings.ContainsRune(` /|、,,-_+`, r) + }) + if len(parts) > 1 { + for _, p := range parts { + if !isNoiseText(p) { + return false + } + } + return true + } + return false +} + +func isGenericKeywordExact(s string) bool { + if _, ok := slugGenericKeywords[s]; ok { + return true + } + _, ok := slugGenericKeywords[strings.ToLower(s)] + return ok +} + +func runeLen(s string) int { + return len([]rune(s)) +} diff --git a/pub/common/utils/strings.go b/pub/common/utils/strings.go new file mode 100644 index 0000000..eb50807 --- /dev/null +++ b/pub/common/utils/strings.go @@ -0,0 +1,100 @@ +package utils + +import ( + "math/rand/v2" + "strings" + + "github.com/gosimple/slug" +) + +// BinarySearch BinarySearch +func BinarySearch(s []string, t string, n int) (idx int) { + idx = -1 + if n == 0 { + return + } + start, end := 0, n-1 + for start <= end { + mid := (start + end) >> 1 + if s[mid] < t { + start = mid + 1 + } else if s[mid] > t { + end = mid - 1 + } else { + idx = mid + break + } + } + return +} + +var YesOrNo = map[bool]string{ + true: "是", + false: "否", +} + +func GetRandomLetters(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.IntN(len(letters))] + } + return string(b) +} + +func GetPrefix(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} + +func GetSlugInfo(text string) string { + return SanitizeSlug(slug.Make(text)) +} + +// SanitizeSlug 去掉 slug 中的横杠等特殊符号,只保留字母和数字 +func SanitizeSlug(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +// IsSlugAllDigits 判断 slug 是否全为数字 +func IsSlugAllDigits(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// MakeUniqueSlug 生成唯一 slug;exists 返回 true 表示已被占用。生成结果不能全是数字。 +func MakeUniqueSlug(source string, exists func(string) bool) string { + pinyin := GetSlugInfo(source) + if pinyin == "" { + pinyin = GetRandomLetters(5) + } else if IsSlugAllDigits(pinyin) { + pinyin = pinyin + GetRandomLetters(3) + } + finalSlug := pinyin + for exists(finalSlug) { + finalSlug = pinyin + GetRandomLetters(3) + } + return finalSlug +} diff --git a/pub/common/utils/structToMap.go b/pub/common/utils/structToMap.go new file mode 100644 index 0000000..f16d5ce --- /dev/null +++ b/pub/common/utils/structToMap.go @@ -0,0 +1,52 @@ +package utils + +import ( + "fmt" + "github.com/mitchellh/mapstructure" + "reflect" +) + +func Struct2Map(obj interface{}) map[string]interface{} { + t := reflect.TypeOf(obj) + v := reflect.ValueOf(obj) + if t.Kind() == reflect.Ptr { + t = t.Elem() + v = reflect.Indirect(v) + } + var data = make(map[string]interface{}) + //func (v Value) NumField() int, 返回v持有的结构体类型值的字段数,如果v的Kind不是Struct会panic + for i := 0; i < t.NumField(); i++ { + n := t.Field(i).Name + vv := v.Field(i).Interface() + data[Capitalize(n)] = vv + } + return data +} + +func Map2Struct(obj interface{}, mapInfo map[string]interface{}) interface{} { + err := mapstructure.Decode(mapInfo, obj) + if err != nil { + fmt.Println(err) + } + return obj +} + +// Capitalize 字符首字母大写 +func Capitalize(str string) string { + var upperStr string + vv := []rune(str) // 后文有介绍 + for i := 0; i < len(vv); i++ { + if i == 0 { + if vv[i] >= 65 && vv[i] <= 96 { // 后文有介绍 + vv[i] += 32 // string的码表相差32位 + upperStr += string(vv[i]) + } else { + fmt.Println("Not begins with uppercase letter,") + return str + } + } else { + upperStr += string(vv[i]) + } + } + return upperStr +} diff --git a/pub/common/utils/time.go b/pub/common/utils/time.go new file mode 100644 index 0000000..27e9f2d --- /dev/null +++ b/pub/common/utils/time.go @@ -0,0 +1,303 @@ +package utils + +import ( + "fmt" + "time" + "videoweb/pub/pubdef" +) + +var ( + LocBJ = mustBeijingLocation() +) + +func mustBeijingLocation() *time.Location { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil || loc == nil { + return time.FixedZone("CST", 8*3600) + } + return loc +} + +func init() { + time.Local = LocBJ +} + +func Now() time.Time { + return time.Now().In(LocBJ) +} + +func NowMillisecond() int64 { + return Now().UnixNano() / int64(time.Millisecond) +} + +func BeginOfToday() time.Time { + t := Now() + return BeginOfTime(t) +} + +func BeginOfTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func GetNextMonthTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month+1, day, 0, 0, 0, 0, t.Location()) +} +func GetNextQuarterTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month+3, day, 0, 0, 0, 0, t.Location()) +} + +func GetNextYearTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year+1, month, day, 0, 0, 0, 0, t.Location()) +} + +func GetForeverTime() time.Time { + t := time.Now() + year, month, day := t.Date() + return time.Date(year+999, month, day, 0, 0, 0, 0, t.Location()) +} + +func IsoWeek() string { + year, week := time.Now().ISOWeek() + return fmt.Sprintf("%d%02d", year, week) +} + +// Deprecated +func EndOfToday() time.Time { + t := Now() + return EndOfTime(t) +} + +func EndOfTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 23, 59, 59, 0, t.Location()) +} + +func GetDateString(t time.Time) string { + return t.In(LocBJ).Format(pubdef.DateISO) +} + +func GetTodayDate(now time.Time) string { + return GetDateString(now.In(time.Local)) +} + +func GetYesterdayDate(now time.Time) string { + return GetDateString(now.Local().AddDate(0, 0, -1)) +} + +func GetDayBeforeYesterdayDate(now time.Time) string { + return GetDateString(now.Local().AddDate(0, 0, -2)) +} + +func GetDayRange(t time.Time) (dayStart time.Time, dayEnd time.Time) { + dayStart = BeginOfTime(t) + dayEnd = EndOfTime(t) + return +} + +// GetDayRangeByDate 返回一天的开始和结束时间, 00:00:00 - 23:59:59 +func GetDayRangeByDate(date string) (dayStart time.Time, dayEnd time.Time, err error) { + var d time.Time + d, err = time.ParseInLocation(pubdef.DateISO, date, LocBJ) + if err != nil { + return + } + dayStart, dayEnd = GetDayRange(d) + return +} + +func GetDayEnd(date string) time.Time { + d, _ := time.ParseInLocation(pubdef.DateISO, date, LocBJ) + return EndOfTime(d) +} + +func GetDayBegin(date string) time.Time { + d, _ := time.ParseInLocation(pubdef.DateISO, date, LocBJ) + return BeginOfTime(d) +} + +func GetDayEndByTime(date string) time.Time { + d, _ := time.ParseInLocation(pubdef.TimeISO, date, LocBJ) + return EndOfTime(d) +} + +func GetDayBeginByTime(date string) time.Time { + d, _ := time.ParseInLocation(pubdef.TimeISO, date, LocBJ) + return BeginOfTime(d) +} + +func BeginWithThisMonth() time.Time { + t := Now() + year, month, _ := t.Date() + return time.Date(year, month, 1, 0, 0, 0, 0, t.Location()) +} + +func BeginOfMonth(t time.Time) time.Time { + year, month, _ := t.In(LocBJ).Date() + return time.Date(year, month, 1, 0, 0, 0, 0, LocBJ) +} + +func BeginOfYear(t time.Time) time.Time { + year, _, _ := t.In(LocBJ).Date() + return time.Date(year, 1, 1, 0, 0, 0, 0, LocBJ) +} + +func EndOfMonth(t time.Time) time.Time { + return BeginOfMonth(t).AddDate(0, 1, 0).Add(-time.Nanosecond) +} + +func EndOfLastMonth(t time.Time) time.Time { + return BeginOfMonth(t).Add(-time.Nanosecond) +} + +func EndOfLastMonthDate(t time.Time) string { + return GetDateString(EndOfLastMonth(t)) +} + +func EndOfLastWeek(t time.Time) time.Time { + return BeginOfWeekByTime(t).Add(-time.Nanosecond) +} + +func EndOfLastWeekDate(t time.Time) string { + return GetDateString(EndOfLastWeek(t)) +} + +// Deprecated +func BeginOfWeek() time.Time { + return BeginOfWeekByTime(Now()) +} + +func WeekDateRange(t time.Time) (string, string) { + b := BeginOfWeekByTime(t) + e := EndOfWeekByTime(t) + return GetDateString(b), GetDateString(e) +} + +func MonthDateRange(t time.Time) (string, string) { + b := BeginOfMonth(t) + e := EndOfMonth(t) + return GetDateString(b), GetDateString(e) +} + +func BeginOfWeekByTime(t time.Time) time.Time { + t = t.In(LocBJ) + bt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, LocBJ) + weekday := int(t.Weekday()) + weekStartDayInt := int(time.Monday) + if weekday < weekStartDayInt { + weekday = weekday + 7 - weekStartDayInt + } else { + weekday = weekday - weekStartDayInt + } + return bt.AddDate(0, 0, -weekday) +} + +func EndOfWeekByTime(t time.Time) time.Time { + return BeginOfWeekByTime(t).AddDate(0, 0, 7).Add(-time.Nanosecond) +} + +func BeginTodayMillsecond() int64 { + return BeginOfToday().UnixNano() / int64(time.Millisecond) +} + +// ServerCurrMillisecond 获得时间毫秒数 13位 +func ServerCurrMillisecond(t time.Time) int64 { + return t.UnixNano() / 1000000 +} + +func BeginWithTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func EndWithTime(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 23, 59, 59, 0, t.Location()) +} + +func IsToday(t time.Time) bool { + if t.IsZero() { + return false + } + today := Now() + + return t.Year() == today.Year() && + t.Month() == today.Month() && + t.Day() == today.Day() +} + +func IsSameYearAndMonth(t1, t2 time.Time) bool { + return t1.Year() == t2.Year() && + t1.Month() == t2.Month() +} + +// 获取月的第一天 +func GetFirstDateOfMonth(d time.Time) time.Time { + d = d.AddDate(0, 0, -d.Day()+1) + return GetZeroTime(d) +} + +// 获取月的最后一天 +func GetLastDateOfMonth(d time.Time) time.Time { + return GetFirstDateOfMonth(d).AddDate(0, 1, -1) +} + +// 获取周的星期一(星期一~星期日) +func GetMondayOfWeek(d time.Time) time.Time { + offset := (int(d.Weekday()) + 6) % 7 + d = d.AddDate(0, 0, -offset) + return GetZeroTime(d) +} + +// 获取周的星期日(星期一~星期日) +func GetSundayOfWeek(d time.Time) time.Time { + offset := 6 - (int(d.Weekday())+6)%7 + d = d.AddDate(0, 0, offset) + return GetZeroTime(d) +} + +// 获取某一天的0点时间 +func GetZeroTime(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) +} + +// 获取当前整点时间 +func GetWholeTime(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), d.Hour(), 0, 0, 0, d.Location()) +} + +func MustParseTime(s string) time.Time { + layouts := []string{ + time.RFC3339, + pubdef.DateISO, + pubdef.TimeISO, + } + for _, layout := range layouts { + t, err := time.ParseInLocation(layout, s, time.Local) + if err != nil { + continue + } + return t + } + panic(fmt.Errorf("cannot parse %s as time.Time", s)) +} + +// 查看两个时间差了多少天,差一秒都算一天 +func TimeSub(t1, t2 time.Time) int64 { + t1 = time.Date(t1.Year(), t1.Month(), t1.Day(), 0, 0, 0, 0, time.Local) + t2 = time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, time.Local) + + return int64(t1.Sub(t2).Hours() / 24) +} +func RecentMinute(tim time.Time, scaleMinute int64) time.Time { + minute := int64(tim.Minute()) + lave := minute % scaleMinute + formatMinute := minute - lave + return time.Date(tim.Year(), tim.Month(), tim.Day(), tim.Hour(), int(formatMinute), 0, 0, tim.Location()) +} +func GetTimeString(t time.Time) string { + return t.In(LocBJ).Format(pubdef.TimeISO) +} diff --git a/pub/common/utils/time_test.go b/pub/common/utils/time_test.go new file mode 100644 index 0000000..14bbd78 --- /dev/null +++ b/pub/common/utils/time_test.go @@ -0,0 +1,169 @@ +package utils + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +func Test_GetDayRangeByDate(t *testing.T) { + begin, end, _ := GetDayRangeByDate("2022-04-08") + fmt.Println(begin, end) +} + +func Test_GetDayRange(t *testing.T) { + now := time.Now() + fmt.Println(GetDayRange(now)) +} + +func Test_GetDateString(t *testing.T) { + fmt.Println(GetDateString(time.Now())) +} + +func Test_GetTodayDate(t *testing.T) { + fmt.Println(GetTodayDate(time.Now())) +} + +func Test_GetYesterdayDate(t *testing.T) { + fmt.Println(GetYesterdayDate(time.Now())) +} + +func Test_GetDayBeforeYesterdayDate(t *testing.T) { + fmt.Println(GetDayBeforeYesterdayDate(time.Now())) +} + +func Test_BeginOfYear(t *testing.T) { + got := BeginOfYear(time.Date(2026, 9, 12, 15, 4, 5, 0, LocBJ)) + want := time.Date(2026, 1, 1, 0, 0, 0, 0, LocBJ) + if !got.Equal(want) { + t.Fatalf("BeginOfYear = %v, want %v", got, want) + } + + // UTC 2025-12-31 16:30 已是北京时间 2026-01-01 00:30,应按 2026 年切。 + utc := time.Date(2025, 12, 31, 16, 30, 0, 0, time.UTC) + if got = BeginOfYear(utc); !got.Equal(want) { + t.Fatalf("BeginOfYear(UTC year boundary) = %v, want %v", got, want) + } +} + +func Test_BeginOfMonthBeijing(t *testing.T) { + // UTC 2026-08-31 16:30 已是北京时间 2026-09-01 00:30,应按 9 月切。 + utc := time.Date(2026, 8, 31, 16, 30, 0, 0, time.UTC) + want := time.Date(2026, 9, 1, 0, 0, 0, 0, LocBJ) + if got := BeginOfMonth(utc); !got.Equal(want) { + t.Fatalf("BeginOfMonth(UTC month boundary) = %v, want %v", got, want) + } +} + +func Test_BeginOfWeekByTime(t *testing.T) { + { + // 如果当前是周日,获取到的结果应该是这周周一而不是下周周一 + now := time.Date(2022, 4, 3, 1, 0, 0, 0, time.Local) + actual := BeginOfWeekByTime(now) + expect := time.Date(2022, 3, 28, 0, 0, 0, 0, time.Local) + if !reflect.DeepEqual(expect, actual) { + t.Fatalf("fail") + } + } + { + now := time.Date(2022, 3, 28, 0, 0, 0, 0, time.Local) + actual := BeginOfWeekByTime(now) + expect := time.Date(2022, 3, 28, 0, 0, 0, 0, time.Local) + if !reflect.DeepEqual(expect, actual) { + t.Fatalf("fail") + } + } + { + // UTC 2026-09-06 16:30 已是北京时间周一 00:30,应按本周一切。 + utc := time.Date(2026, 9, 6, 16, 30, 0, 0, time.UTC) + want := time.Date(2026, 9, 7, 0, 0, 0, 0, LocBJ) + if got := BeginOfWeekByTime(utc); !got.Equal(want) { + t.Fatalf("BeginOfWeekByTime(UTC week boundary) = %v, want %v", got, want) + } + } + { + // UTC 2026-09-06 15:30 仍是北京时间周日 23:30,应落在上一周周一。 + utc := time.Date(2026, 9, 6, 15, 30, 0, 0, time.UTC) + want := time.Date(2026, 8, 31, 0, 0, 0, 0, LocBJ) + if got := BeginOfWeekByTime(utc); !got.Equal(want) { + t.Fatalf("BeginOfWeekByTime(UTC sunday) = %v, want %v", got, want) + } + } +} + +func Test_GetDayEnd(t *testing.T) { + fmt.Println(GetDayEnd("2020-04-09")) +} + +func Test_GetDayBegin(t *testing.T) { + fmt.Println(GetDayBegin("2020-04-09")) +} + +func Test_EndOfWeekByTime(t *testing.T) { + now := time.Now() + fmt.Println(EndOfWeekByTime(now)) +} + +func Test_EndOfMonth(t *testing.T) { + now := time.Now() + fmt.Println(BeginOfMonth(now)) + fmt.Println(EndOfMonth(now)) +} + +func Test_WeekDateRange(t *testing.T) { + fmt.Println(WeekDateRange(time.Now())) +} + +func Test_MonthDateRange(t *testing.T) { + fmt.Println(MonthDateRange(time.Now())) +} + +func Test_EndOfLastWeek(t *testing.T) { + fmt.Println(EndOfLastWeek(time.Now())) +} + +func Test_EndOfLastMonth(t *testing.T) { + fmt.Println(EndOfLastMonth(time.Now())) +} + +func Test_EndOfLastWeekDate(t *testing.T) { + fmt.Println(EndOfLastWeekDate(time.Now())) +} + +func Test_EndOfLastMonthDate(t *testing.T) { + fmt.Println(EndOfLastMonthDate(time.Now())) +} + +func TestMustParseTime(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + want time.Time + }{ + { + name: "case1", + args: args{ + s: "2022-04-25", + }, + want: time.Date(2022, 4, 25, 0, 0, 0, 0, time.Local), + }, + { + name: "case2", + args: args{ + s: "2022-04-25 11:12:13", + }, + want: time.Date(2022, 4, 25, 11, 12, 13, 0, time.Local), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MustParseTime(tt.args.s); !reflect.DeepEqual(got, tt.want) { + t.Errorf("MustParseTime() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pub/common/utils/uasplit.go b/pub/common/utils/uasplit.go new file mode 100644 index 0000000..3d033eb --- /dev/null +++ b/pub/common/utils/uasplit.go @@ -0,0 +1,30 @@ +package utils + +import ( + "strings" + "videoweb/pub/common/enum/consts" +) + +func UASplit(useragent string) (phoneModel string, phoneSys string, appVer string) { + if useragent == "" { + return + } + infos := strings.Split(useragent, ";") + if len(infos) != 3 { + return + } + phoneinfos := strings.Split(strings.Trim(infos[1], " "), " ") + if len(phoneinfos) == 2 { + phoneModel = strings.Trim(phoneinfos[0], " ") + phoneSys = strings.Trim(phoneinfos[1], " ") + } + appVer = strings.Trim(infos[2], " ") + return +} + +func UADeviceType(ua string) string { + if strings.Contains(ua, "iPhone") { + return consts.IOS + } + return consts.Android +} diff --git a/pub/common/utils/utils.go b/pub/common/utils/utils.go new file mode 100644 index 0000000..8c64d51 --- /dev/null +++ b/pub/common/utils/utils.go @@ -0,0 +1,63 @@ +package utils + +import ( + "fmt" + "math/rand" + "runtime/debug" + "strings" + "time" + "videoweb/pub/common/log" + + "golang.org/x/exp/constraints" +) + +func RecoverError(onError func(err error)) { + if r := recover(); r != nil { + err := fmt.Errorf("internal error: %v", r) + if onError != nil { + onError(err) + } + log.Error(fmt.Sprintf("[Panic] RecoverError err: %+v\n", r) + string(debug.Stack())) + } +} + +func GetRandom(min, max int) int64 { + rand.Seed(time.Now().UnixNano()) + return int64(rand.Intn(max-min+1) + min) +} +func EnsureRange[T constraints.Ordered](value, min, max T) T { + if value < min { + value = min + } + if value > max { + value = max + } + return value +} + +func InRange[T constraints.Ordered](value, min, max T) bool { + if value < min { + return false + } + if value > max { + return false + } + return true +} + +// Shuffle 使用 Fisher-Yates 算法对切片进行随机排序 +func Shuffle[T any](slice []T) { + rand.Seed(time.Now().UnixNano()) + n := len(slice) + for i := n - 1; i > 0; i-- { + j := rand.Intn(i + 1) + slice[i], slice[j] = slice[j], slice[i] + } +} +func GetRandomMacAddress() string { + buf := make([]byte, 6) + rand.Read(buf) + buf[0] |= 2 + mac := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]) + return strings.ToUpper(mac) +} diff --git a/pub/common/utils/verify.go b/pub/common/utils/verify.go new file mode 100644 index 0000000..fb20ae5 --- /dev/null +++ b/pub/common/utils/verify.go @@ -0,0 +1,10 @@ +package utils + +import "regexp" + +// mobile verify +func VerifyMobileFormat(mobileNum string) bool { + regular := "^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-8])|(18[0-9])|166|198|199|(147))\\d{8}$" + reg := regexp.MustCompile(regular) + return reg.MatchString(mobileNum) +} diff --git a/pub/common/utils/version.go b/pub/common/utils/version.go new file mode 100644 index 0000000..e2769d8 --- /dev/null +++ b/pub/common/utils/version.go @@ -0,0 +1,43 @@ +package utils + +import ( + "strconv" + "strings" +) + +func getSubVersions(ver string) []int { + var vers [4]int + vv := strings.Split(ver, ".") + for i, v := range vv { + vers[i], _ = strconv.Atoi(v) + } + return vers[:] +} + +// VersionCompare +func VersionCompare(a, b string) int { + + aa := getSubVersions(a) + bb := getSubVersions(b) + + for i := 0; i < len(aa) && i < len(bb); i++ { + + if aa[i] == bb[i] { + continue + } + + if aa[i] < bb[i] { + return -1 + } + + return 1 + } + + if len(aa) == len(bb) { + return 0 + } + if len(aa) < len(bb) { + return -1 + } + return 1 +} diff --git a/pub/common/utils/version_test.go b/pub/common/utils/version_test.go new file mode 100644 index 0000000..b1adcd7 --- /dev/null +++ b/pub/common/utils/version_test.go @@ -0,0 +1,19 @@ +package utils + +import ( + "fmt" + "testing" +) + +func TestVersionCompare(t *testing.T) { + + fmt.Printf("2.10.9 vs 2.11 = %d\n", VersionCompare("2.10.9", "2.11")) + fmt.Printf("2.11.9 vs 2.11 = %d\n", VersionCompare("2.11.9", "2.11")) + fmt.Printf("1.10.9 vs 2.11 = %d\n", VersionCompare("1.10.9", "2.11")) + fmt.Printf("2.10.9 vs 2.10.1 = %d\n", VersionCompare("2.10.9", "2.11.1")) + fmt.Printf("2.10.9 vs 2.10.10 = %d\n", VersionCompare("2.10.9", "2.10.10")) + fmt.Printf("2.10.9 vs 2.10.9.0 = %d\n", VersionCompare("2.10.9", "2.10.9.0")) + fmt.Printf("1 vs 1.0.0.0 = %d\n", VersionCompare("2.10.9", "2.10.9.0")) + fmt.Printf("2.10.9 vs 2.10.9.-1 = %d\n", VersionCompare("2.10.9", "2.10.9.-1")) + +} diff --git a/pub/common/v10/upgradev10.go b/pub/common/v10/upgradev10.go new file mode 100644 index 0000000..8908c46 --- /dev/null +++ b/pub/common/v10/upgradev10.go @@ -0,0 +1,55 @@ +package v10 + +import ( + "github.com/go-playground/validator/v10" + "reflect" + "sync" + + "github.com/gin-gonic/gin/binding" +) + +type DefaultValidator struct { + once sync.Once + validate *validator.Validate +} + +var _ binding.StructValidator = &DefaultValidator{} + +func (v *DefaultValidator) ValidateStruct(obj interface{}) error { + + if kindOfData(obj) == reflect.Struct { + + v.lazyinit() + + if err := v.validate.Struct(obj); err != nil { + return error(err) + } + } + + return nil +} + +func (v *DefaultValidator) Engine() interface{} { + v.lazyinit() + return v.validate +} + +func (v *DefaultValidator) lazyinit() { + v.once.Do(func() { + v.validate = validator.New() + v.validate.SetTagName("binding") + + // add any custom validations etc. here + }) +} + +func kindOfData(data interface{}) reflect.Kind { + + value := reflect.ValueOf(data) + valueType := value.Kind() + + if valueType == reflect.Ptr { + valueType = value.Elem().Kind() + } + return valueType +} diff --git a/pub/common/v10/validate.go b/pub/common/v10/validate.go new file mode 100644 index 0000000..ef5ca53 --- /dev/null +++ b/pub/common/v10/validate.go @@ -0,0 +1,171 @@ +package v10 + +import ( + "github.com/nyaruka/phonenumbers" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/go-playground/validator/v10" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// IsPureChar 匹配中文、字母、数字 +func IsPureChar(src string) bool { + return regexp.MustCompile("^[a-z0-9A-Z\u4e00-\u9fa5]+$").MatchString(src) +} + +// IsPureChineseChar 匹配中文 +func IsPureChineseChar(src string) bool { + return regexp.MustCompile("[\u4e00-\u9fa5]").MatchString(src) +} + +// 提取文字 包括数字 英文 不包含符号 +func ExtractPureEngChar(src string) string { + pureArray := regexp.MustCompile("[a-z0-9A-Z\u005f]").FindAllString(src, -1) + return strings.Join(pureArray, "") +} + +// 提取文字 包括中文 数字 英文 不包含符号 +func ExtractPureChar(src string) string { + src = strings.TrimSpace(src) + pureArray := regexp.MustCompile("[a-z0-9A-Z\u4e00-\u9fa5]+").FindAllString(src, -1) + return strings.Join(pureArray, "") +} + +// IsPhoneNumber 匹配所有国际化的手机号码(手机卡 + 上网卡) +func IsGlobalizationPhoneNumber(phoneNumber string) bool { + num, _ := phonenumbers.Parse(phoneNumber, "CN") + return phonenumbers.IsValidNumber(num) +} + +// IsEn 是否是英文的正则表达式验证 +func IsEn(data string) bool { + return regexp.MustCompile(`^[a-zA-Z]+$`).MatchString(data) +} + +// IsEnZh 是否是中英文的正则表达式验证 +func IsEnZh(data string) bool { + return regexp.MustCompile("^[a-zA-Z|\u4e00-\u9fa5]+$").MatchString(data) +} + +// IsEnNum 是否是英文数字的正则表达式验证 +func IsEnNum(data string) bool { + return regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString(data) +} + +// IsEnNumSpe 是否是英文数字特殊符号的验证 +func IsEnNumSpe(data string) bool { + return regexp.MustCompile(`^[a-zA-Z0-9|~!@#$%^&*()/\|,.<>?"'();:_+-=\[\]{}]+$`).MatchString(data) +} + +// IsObjectID 是否objectID的验证 +func IsObjectID(data string) bool { + _, err := primitive.ObjectIDFromHex(data) + if err != nil { + return false + } + return true +} + +// IsLengthBetween 判断data字符串长度是否在范围内 +func IsLengthBetween(data string, min int, max int) bool { + return len(data) >= min && len(data) <= max +} + +// IsNil nil判断 +func IsNil(i interface{}) bool { + vi := reflect.ValueOf(i) + if vi.Kind() == reflect.Ptr { + return vi.IsNil() + } + return false +} + +// Phone 验证手机号码 +func Phone( + v *validator.Validate, topStruct reflect.Value, currentStructOrField reflect.Value, + field reflect.Value, fieldType reflect.Type, fieldKind reflect.Kind, param string, +) bool { + if data, ok := field.Interface().(string); ok { + if len(data) <= 10 || len(data) >= 15 { + return false + } + if !IsGlobalizationPhoneNumber(data) { + return false + } + return true + } + return false +} + +// Time_Utc 转换成UTC时间 +func Time_Utc(fl validator.FieldLevel) bool { + timeParse, err := time.Parse(time.RFC3339, fl.Field().String()) + if err != nil { + return false + } + timevalue := reflect.ValueOf(timeParse) + fl.Field().Set(timevalue) + return true +} + +// StrLength 验证长度 +func StrLength( + v *validator.Validate, topStruct reflect.Value, currentStructOrField reflect.Value, + field reflect.Value, fieldType reflect.Type, fieldKind reflect.Kind, param string, +) bool { + num, err := strconv.Atoi(param) + if err != nil { + return false + } + if data, ok := field.Interface().(string); ok { + if len(data) == num { + return true + } + } + return false +} + +// NeedType 需要类型 +func NeedType( + v *validator.Validate, topStruct reflect.Value, currentStructOrField reflect.Value, + field reflect.Value, fieldType reflect.Type, fieldKind reflect.Kind, param string, +) bool { + if data, ok := field.Interface().(string); ok { + switch param { + case "en": // 英文 + return IsEn(data) + case "enZh": // 中英文 + return IsEnZh(data) + case "enNum": // 英文数字 + return IsEnNum(data) + case "enNumSpe": // 英文数字特殊符号 + return IsEnNumSpe(data) + case "objectID": // 是否为mongo的id + return IsObjectID(data) + } + } + return false +} + +// RegisterValidator 注解自定义参数验证 +func RegisterValidator() gin.HandlerFunc { + return func(c *gin.Context) { + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + //v.RegisterValidation("phone", Phone) + // v.RegisterValidation("len", StrLength) + // v.RegisterValidation("min", validator.IsGte) + // v.RegisterValidation("max", validator.IsLte) + // v.RegisterValidation("eq", validator.IsEq) + // v.RegisterValidation("type", NeedType) + v.RegisterValidation("time_utc", Time_Utc) + } + c.Next() + } +} diff --git a/pub/common/webpath/paths.go b/pub/common/webpath/paths.go new file mode 100644 index 0000000..37202b6 --- /dev/null +++ b/pub/common/webpath/paths.go @@ -0,0 +1,105 @@ +package webpath + +import ( + "net/url" + "strings" +) + +// LegacyPathRemap 旧版 app 导航路径 → 当前 h5 SSR 实际路由。 +var LegacyPathRemap = map[string]string{ + "/avtopic/": "/video/all", + "/avtopic": "/video/all", + "/dh/": "/nav", + "/dh": "/nav", +} + +// SkipPublicPaths 当前 web 无对应页面、或不应对外暴露的路径。 +var SkipPublicPaths = map[string]struct{}{ + "/topic/": {}, + "/topic": {}, + "/history/": {}, + "/history": {}, + "/tag/all/": {}, + "/tag/all": {}, + "/useragreement/": {}, + "/useragreement": {}, + "/privacypolicy/": {}, + "/privacypolicy": {}, + "/dmca/": {}, + "/dmca": {}, +} + +// NormalizePublicURL 将 URL 规范为 web 可访问地址;第二个返回值 false 表示应跳过。 +func NormalizePublicURL(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + + parsed, err := url.Parse(raw) + if err != nil { + return "", false + } + + path := parsed.Path + if path == "" { + path = "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + normalizedPath := normalizePathKey(path) + if _, skip := SkipPublicPaths[normalizedPath]; skip { + return "", false + } + if mapped, ok := LegacyPathRemap[normalizedPath]; ok { + path = mapped + parsed.Path = path + } + + if parsed.Path != "/" { + parsed.Path = strings.TrimRight(parsed.Path, "/") + if parsed.Path == "" { + parsed.Path = "/" + } + } + + return parsed.String(), true +} + +// NormalizePublicPath 规范单条站内 path(不含域名)。 +func NormalizePublicPath(path string) (string, bool) { + path = strings.TrimSpace(path) + if path == "" { + return "", false + } + if strings.Contains(path, "://") { + return NormalizePublicURL(path) + } + base := "https://example.com" + normalized, ok := NormalizePublicURL(base + path) + if !ok { + return "", false + } + parsed, err := url.Parse(normalized) + if err != nil { + return "", false + } + return parsed.Path, true +} + +func normalizePathKey(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + lower := strings.ToLower(path) + if lower != "/" && !strings.HasSuffix(lower, "/") { + lower += "/" + } + return lower +} diff --git a/pub/common/webpath/paths_test.go b/pub/common/webpath/paths_test.go new file mode 100644 index 0000000..f728da9 --- /dev/null +++ b/pub/common/webpath/paths_test.go @@ -0,0 +1,42 @@ +package webpath + +import "testing" + +func TestNormalizePublicURL(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + want string + allow bool + }{ + {"https://91jxporn.com/avtopic/", "https://91jxporn.com/video/all", true}, + {"https://91jxporn.com/dh/", "https://91jxporn.com/nav", true}, + {"https://91jxporn.com/richtext?title=test", "https://91jxporn.com/richtext?title=test", true}, + {"https://91jxporn.com/topic/", "", false}, + {"https://91jxporn.com/tag/all/", "", false}, + {"https://91jxporn.com/useragreement/", "", false}, + } + + for _, tt := range tests { + got, ok := NormalizePublicURL(tt.raw) + if ok != tt.allow { + t.Fatalf("NormalizePublicURL(%q) allow = %v, want %v", tt.raw, ok, tt.allow) + } + if !tt.allow { + continue + } + if got != tt.want { + t.Fatalf("NormalizePublicURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + } +} + +func TestNormalizePublicPath(t *testing.T) { + t.Parallel() + + got, ok := NormalizePublicPath("/avtopic/") + if !ok || got != "/video/all" { + t.Fatalf("NormalizePublicPath(/avtopic/) = %q, %v", got, ok) + } +} diff --git a/pub/common/wordsfilter/filter.go b/pub/common/wordsfilter/filter.go new file mode 100644 index 0000000..fa03ce9 --- /dev/null +++ b/pub/common/wordsfilter/filter.go @@ -0,0 +1,113 @@ +package wordsfilter + +import ( + "bufio" + "io" + "net/http" + "os" + "regexp" + "time" +) + +// Filter 敏感词过滤器 +type Filter struct { + trie *Trie + noise *regexp.Regexp +} + +// New 返回一个敏感词过滤器 +func New() *Filter { + return &Filter{ + trie: NewTrie(), + noise: regexp.MustCompile(`[\|\s&%$@*]+`), + } +} + +// UpdateNoisePattern 更新去噪模式 +func (filter *Filter) UpdateNoisePattern(pattern string) { + filter.noise = regexp.MustCompile(pattern) +} + +// LoadWordDict 加载敏感词字典 +func (filter *Filter) LoadWordDict(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + return filter.Load(f) +} + +// LoadNetWordDict 加载网络敏感词字典 +func (filter *Filter) LoadNetWordDict(url string) error { + c := http.Client{ + Timeout: 5 * time.Second, + } + rsp, err := c.Get(url) + if err != nil { + return err + } + defer rsp.Body.Close() + + return filter.Load(rsp.Body) +} + +// Load common method to add words +func (filter *Filter) Load(rd io.Reader) error { + buf := bufio.NewReader(rd) + for { + line, _, err := buf.ReadLine() + if err != nil { + if err != io.EOF { + return err + } + break + } + filter.trie.Add(string(line)) + } + + return nil +} + +// AddWord 添加敏感词 +func (filter *Filter) AddWord(words ...string) { + filter.trie.Add(words...) +} + +// DelWord 删除敏感词 +func (filter *Filter) DelWord(words ...string) { + filter.trie.Del(words...) +} + +// Filter 过滤敏感词 +func (filter *Filter) Filter(text string) string { + return filter.trie.Filter(text) +} + +// Replace 和谐敏感词 +func (filter *Filter) Replace(text string, repl rune) string { + return filter.trie.Replace(text, repl) +} + +// FindIn 检测敏感词 +func (filter *Filter) FindIn(text string) (bool, string) { + text = filter.RemoveNoise(text) + return filter.trie.FindIn(text) +} + +// FindAll 找到所有匹配词 +func (filter *Filter) FindAll(text string) []string { + return filter.trie.FindAll(text) +} + +// Validate 检测字符串是否合法 +func (filter *Filter) Validate(text string) (bool, string) { + text = filter.RemoveNoise(text) + return filter.trie.Validate(text) +} + +// RemoveNoise 去除空格等噪音 +func (filter *Filter) RemoveNoise(text string) string { + return filter.noise.ReplaceAllString(text, "") +} diff --git a/pub/common/wordsfilter/filter_test.go b/pub/common/wordsfilter/filter_test.go new file mode 100644 index 0000000..49f7662 --- /dev/null +++ b/pub/common/wordsfilter/filter_test.go @@ -0,0 +1,396 @@ +package wordsfilter + +import ( + "io" + "reflect" + "regexp" + "strings" + "testing" +) + +func TestLoadDict(t *testing.T) { + filter := New() + err := filter.LoadWordDict("./dict/dict.txt") + if err != nil { + t.Errorf("fail to load dict %v", err) + } +} + +func TestLoadNetWordDict(t *testing.T) { + filter := New() + err := filter.LoadNetWordDict("https://raw.githubusercontent.com/importcjj/sensitive/master/dict/dict.txt") + if err != nil { + t.Errorf("fail to load dict %v", err) + } + if len(filter.trie.Root.Children) == 0 { + t.Errorf("load dict empty") + } +} + +func TestLoad(t *testing.T) { + filter := New() + var r io.Reader + r = strings.NewReader("read") + err := filter.Load(r) + if err != nil { + t.Errorf("fail to load dict %v", err) + } + if len(filter.trie.Root.Children) == 0 { + t.Errorf("load dict empty") + } +} + +func TestSensitiveFilter(t *testing.T) { + filter := New() + filter.AddWord("有一个东西") + filter.AddWord("一个东西") + filter.AddWord("一个") + filter.AddWord("东西") + filter.AddWord("个东") + + testcases := []struct { + Text string + Expect string + DelWords []string + ThenExpect string + }{ + {"我有一个东东西", "我有东", []string{"一个"}, "我有一"}, + {"我有一个东西", "我", []string{"有一个东西"}, "我有"}, + {"一个东西", "", []string{"一个", "东西"}, ""}, + {"两个东西", "两西", []string{"个东"}, "两个"}, + {"一个物体", "物体", []string{"一个"}, "一个物体"}, + } + + for _, tc := range testcases { + if got := filter.Filter(tc.Text); got != tc.Expect { + t.Errorf("filter %s, got %s, expect %s", tc.Text, got, tc.Expect) + } + + filter.DelWord(tc.DelWords...) + + if got := filter.Filter(tc.Text); got != tc.ThenExpect { + t.Errorf("after del, filter %s, got %s, expect %s", tc.Text, got, tc.ThenExpect) + } + + filter.AddWord(tc.DelWords...) + } + +} +func TestSensitiveValidateSingleword(t *testing.T) { + filter := New() + filter.AddWord("东") + + testcases := []struct { + Text string + ExpectPass bool + ExpectFirst string + }{ + {"两个东西", false, "东"}, + } + + for _, tc := range testcases { + if pass, first := filter.Validate(tc.Text); pass != tc.ExpectPass || first != tc.ExpectFirst { + t.Errorf("validate %s, got %v, %s, expect %v, %s", tc.Text, pass, first, tc.ExpectPass, tc.ExpectFirst) + } + } + +} + +func TestSensitiveValidate(t *testing.T) { + filter := New() + filter.AddWord("有一个东西") + filter.AddWord("一个东西") + filter.AddWord("一个") + filter.AddWord("东西") + filter.AddWord("个东") + filter.AddWord("有一个东西") + filter.AddWord("一个东西") + filter.AddWord("一个") + filter.AddWord("东西") + + testcases := []struct { + Text string + ExpectPass bool + ExpectFirst string + DelWords []string + ThenExpectPass bool + ThenExpectFirst string + }{ + {"我有一@ |个东东西", false, "一个", []string{"一个"}, false, "个东"}, + {"我有一个东东西", false, "一个", []string{"一个"}, false, "个东"}, + {"我有一个东西", false, "有一个东西", []string{"有一个东西", "一个"}, false, "一个东西"}, + {"一个东西", false, "一个", []string{"个东", "一个"}, false, "一个东西"}, + {"两个东西", false, "个东", []string{"个东", "东西"}, true, ""}, + {"一样东西", false, "东西", []string{"东西"}, true, ""}, + } + + for _, tc := range testcases { + if pass, first := filter.Validate(tc.Text); pass != tc.ExpectPass || first != tc.ExpectFirst { + t.Errorf("validate %s, got %v, %s, expect %v, %s", tc.Text, pass, first, tc.ExpectPass, tc.ExpectFirst) + } + + filter.DelWord(tc.DelWords...) + + if pass, first := filter.Validate(tc.Text); pass != tc.ThenExpectPass || first != tc.ThenExpectFirst { + t.Errorf("after del, validate %s, got %v, %s, expect %v, %s", tc.Text, pass, first, tc.ThenExpectPass, tc.ThenExpectFirst) + } + + filter.AddWord(tc.DelWords...) + } + +} + +func TestSensitiveReplace(t *testing.T) { + filter := New() + filter.AddWord("有一个东西") + filter.AddWord("一个东西") + filter.AddWord("一个") + filter.AddWord("东西") + filter.AddWord("个东") + + testcases := []struct { + Text string + Expect string + DelWords []string + ThenExpect string + }{ + {"我有一个东东西", "我有**东**", []string{"一个"}, "我有一****"}, + {"我有一个东西", "我*****", []string{"有一个东西"}, "我有****"}, + {"一个东西", "****", []string{"一个东西", "一个", "东西"}, "一**西"}, + {"两个东西", "两**西", []string{"个东"}, "两个**"}, + {"一个物体", "**物体", []string{"一个"}, "一个物体"}, + } + + for _, tc := range testcases { + if got := filter.Replace(tc.Text, '*'); got != tc.Expect { + t.Errorf("replace %s, got %s, expect %s", tc.Text, got, tc.Expect) + } + + filter.DelWord(tc.DelWords...) + + if got := filter.Replace(tc.Text, '*'); got != tc.ThenExpect { + t.Errorf("after del word, replace %s, got %s, expect %s", tc.Text, got, tc.ThenExpect) + } + + filter.AddWord(tc.DelWords...) + } + +} + +func TestSensitiveFindAll(t *testing.T) { + filter := New() + filter.AddWord("有一个东西") + filter.AddWord("一个东西") + filter.AddWord("一个") + filter.AddWord("东西") + filter.AddWord("个东") + + testcases := []struct { + Text string + Expect []string + }{ + {"我有一个东东西", []string{"一个", "个东", "东西"}}, + {"我有一个东西", []string{"有一个东西", "一个", "一个东西", "个东", "东西"}}, + {"一个东西", []string{"一个", "一个东西", "个东", "东西"}}, + {"两个东西", []string{"个东", "东西"}}, + {"一个物体", []string{"一个"}}, + } + + for _, tc := range testcases { + if got := filter.FindAll(tc.Text); !reflect.DeepEqual(tc.Expect, got) { + t.Errorf("findall %s, got %s, expect %s", tc.Text, got, tc.Expect) + } + } +} + +func TestSensitiveFindallSingleword(t *testing.T) { + filter := New() + filter.AddWord("东") + + testcases := []struct { + Text string + Expect []string + }{ + {"两个东西", []string{"东"}}, + } + + for _, tc := range testcases { + if got := filter.FindAll(tc.Text); !reflect.DeepEqual(tc.Expect, got) { + t.Errorf("findall %s, got %s, expect %s", tc.Text, got, tc.Expect) + } + } + +} + +func TestFindAllAfterDeleteSingleWord(t *testing.T) { + filter := New() + filter.AddWord("有一个东西") + filter.AddWord("一个东西") + filter.AddWord("一个") + filter.AddWord("东西") + filter.AddWord("个东") + + testcases := []struct { + Text string + Expect []string + DelWord string + ThenExpect []string + }{ + {"我有一个东东西", []string{"一个", "个东", "东西"}, "一个", []string{"个东", "东西"}}, + {"我有一个东西", []string{"有一个东西", "一个", "一个东西", "个东", "东西"}, "有一个东西", []string{"一个", "一个东西", "个东", "东西"}}, + {"一个东西", []string{"一个", "一个东西", "个东", "东西"}, "一个东西", []string{"一个", "个东", "东西"}}, + {"两个东西", []string{"个东", "东西"}, "东西", []string{"个东"}}, + {"一个物体", []string{"一个"}, "一个", nil}, + } + + for _, tc := range testcases { + if got := filter.FindAll(tc.Text); !reflect.DeepEqual(tc.Expect, got) { + t.Errorf("findall %s, got %s, expect %s", tc.Text, got, tc.Expect) + } + + filter.DelWord(tc.DelWord) + + if got := filter.FindAll(tc.Text); !reflect.DeepEqual(tc.ThenExpect, got) { + t.Errorf("after del a word, findall %s, got %s, expect %s", tc.Text, got, tc.ThenExpect) + } + + filter.AddWord(tc.DelWord) + } +} + +func TestDeleteWordNotExists(t *testing.T) { + filter := New() + filter.DelWord("Any") + filter.DelWord("Go", "Bad") +} + +func TestDeleteWord2(t *testing.T) { + filter := New() + filter.AddWord("有一个东西") + filter.AddWord("有一个东") + filter.AddWord("有一个") + + testcases := []struct { + Text string + ExpectPass bool + ExpectFirst string + DelWord string + ThenExpectFirst string + }{ + {"我有一个东西", false, "有一个", "有一个", "有一个东"}, + } + + for _, tc := range testcases { + if pass, first := filter.Validate(tc.Text); pass != tc.ExpectPass || first != tc.ExpectFirst { + t.Errorf("validate %s, got %v, %s, expect %v, %s", tc.Text, pass, first, tc.ExpectPass, tc.ExpectFirst) + } + + filter.DelWord(tc.DelWord) + + if pass, first := filter.Validate(tc.Text); pass != tc.ExpectPass || first != tc.ThenExpectFirst { + t.Errorf("validate %s, got %v, %s, expect %v, %s", tc.Text, pass, first, tc.ExpectPass, tc.ThenExpectFirst) + } + + filter.AddWord(tc.DelWord) + } +} + +func TestFilter_LoadWordDict(t *testing.T) { + type fields struct { + trie *Trie + noise *regexp.Regexp + } + type args struct { + path string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &Filter{ + trie: tt.fields.trie, + noise: tt.fields.noise, + } + if err := filter.LoadWordDict(tt.args.path); (err != nil) != tt.wantErr { + t.Errorf("Filter.LoadWordDict() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFilter_LoadNetWordDict(t *testing.T) { + type fields struct { + trie *Trie + noise *regexp.Regexp + } + type args struct { + url string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &Filter{ + trie: tt.fields.trie, + noise: tt.fields.noise, + } + if err := filter.LoadNetWordDict(tt.args.url); (err != nil) != tt.wantErr { + t.Errorf("Filter.LoadNetWordDict() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFilter_Load(t *testing.T) { + type fields struct { + trie *Trie + noise *regexp.Regexp + } + type args struct { + rd io.Reader + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &Filter{ + trie: tt.fields.trie, + noise: tt.fields.noise, + } + if err := filter.Load(tt.args.rd); (err != nil) != tt.wantErr { + t.Errorf("Filter.Load() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestIssue13(t *testing.T) { + filter := New() + filter.AddWord("words") + filter.AddWord("警察局") + + r := filter.FindAll("words words words kill myself myself suicide警察局kill my self") + expected := []string{"words", "警察局"} + + if !reflect.DeepEqual(r, expected) { + t.Errorf("%v expected, got %v", expected, r) + } +} diff --git a/pub/common/wordsfilter/trie_tree.go b/pub/common/wordsfilter/trie_tree.go new file mode 100644 index 0000000..d06bcc4 --- /dev/null +++ b/pub/common/wordsfilter/trie_tree.go @@ -0,0 +1,272 @@ +package wordsfilter + +// Trie 短语组成的Trie树. +type Trie struct { + Root *Node +} + +// Node Trie树上的一个节点. +type Node struct { + isRootNode bool + isPathEnd bool + Character rune + Children map[rune]*Node +} + +// NewTrie 新建一棵Trie +func NewTrie() *Trie { + return &Trie{ + Root: NewRootNode(0), + } +} + +// Add 添加若干个词 +func (tree *Trie) Add(words ...string) { + for _, word := range words { + tree.add(word) + } +} + +func (tree *Trie) add(word string) { + var current = tree.Root + var runes = []rune(word) + for position := 0; position < len(runes); position++ { + r := runes[position] + if next, ok := current.Children[r]; ok { + current = next + } else { + newNode := NewNode(r) + current.Children[r] = newNode + current = newNode + } + if position == len(runes)-1 { + current.isPathEnd = true + } + } +} + +func (tree *Trie) Del(words ...string) { + for _, word := range words { + tree.del(word) + } +} + +func (tree *Trie) del(word string) { + var current = tree.Root + var runes = []rune(word) + for position := 0; position < len(runes); position++ { + r := runes[position] + if next, ok := current.Children[r]; !ok { + return + } else { + current = next + } + + if position == len(runes)-1 { + current.SoftDel() + } + } +} + +// Replace 词语替换 +func (tree *Trie) Replace(text string, character rune) string { + var ( + parent = tree.Root + current *Node + runes = []rune(text) + length = len(runes) + left = 0 + found bool + ) + + for position := 0; position < len(runes); position++ { + current, found = parent.Children[runes[position]] + + if !found || (!current.IsPathEnd() && position == length-1) { + parent = tree.Root + position = left + left++ + continue + } + + // println(string(current.Character), current.IsPathEnd(), left) + if current.IsPathEnd() && left <= position { + for i := left; i <= position; i++ { + runes[i] = character + } + } + + parent = current + } + + return string(runes) +} + +// Filter 直接过滤掉字符串中的敏感词 +func (tree *Trie) Filter(text string) string { + var ( + parent = tree.Root + current *Node + left = 0 + found bool + runes = []rune(text) + length = len(runes) + resultRunes = make([]rune, 0, length) + ) + + for position := 0; position < length; position++ { + current, found = parent.Children[runes[position]] + + if !found || (!current.IsPathEnd() && position == length-1) { + resultRunes = append(resultRunes, runes[left]) + parent = tree.Root + position = left + left++ + continue + } + + if current.IsPathEnd() { + left = position + 1 + parent = tree.Root + } else { + parent = current + } + + } + + resultRunes = append(resultRunes, runes[left:]...) + return string(resultRunes) +} + +// Validate 验证字符串是否合法,如不合法则返回false和检测到 +// 的第一个敏感词 +func (tree *Trie) Validate(text string) (bool, string) { + const ( + Empty = "" + ) + var ( + parent = tree.Root + current *Node + runes = []rune(text) + length = len(runes) + left = 0 + found bool + ) + + for position := 0; position < len(runes); position++ { + current, found = parent.Children[runes[position]] + + if !found || (!current.IsPathEnd() && position == length-1) { + parent = tree.Root + position = left + left++ + continue + } + + if current.IsPathEnd() && left <= position { + return false, string(runes[left : position+1]) + } + + parent = current + } + + return true, Empty +} + +// FindIn 判断text中是否含有词库中的词 +func (tree *Trie) FindIn(text string) (bool, string) { + validated, first := tree.Validate(text) + return !validated, first +} + +// FindAll 找有所有包含在词库中的词 +func (tree *Trie) FindAll(text string) []string { + var matches []string + var ( + parent = tree.Root + current *Node + runes = []rune(text) + length = len(runes) + left = 0 + found bool + ) + + for position := 0; position < length; position++ { + current, found = parent.Children[runes[position]] + + if !found { + parent = tree.Root + position = left + left++ + continue + } + + if current.IsPathEnd() && left <= position { + matches = append(matches, string(runes[left:position+1])) + } + + if position == length-1 { + parent = tree.Root + position = left + left++ + continue + } + + parent = current + } + + var i = 0 + if count := len(matches); count > 0 { + set := make(map[string]struct{}) + for i < count { + _, ok := set[matches[i]] + if !ok { + set[matches[i]] = struct{}{} + i++ + continue + } + count-- + copy(matches[i:], matches[i+1:]) + } + return matches[:count] + } + + return nil +} + +// NewNode 新建子节点 +func NewNode(character rune) *Node { + return &Node{ + Character: character, + Children: make(map[rune]*Node, 0), + } +} + +// NewRootNode 新建根节点 +func NewRootNode(character rune) *Node { + return &Node{ + isRootNode: true, + Character: character, + Children: make(map[rune]*Node, 0), + } +} + +// IsLeafNode 判断是否叶子节点 +func (node *Node) IsLeafNode() bool { + return len(node.Children) == 0 +} + +// IsRootNode 判断是否为根节点 +func (node *Node) IsRootNode() bool { + return node.isRootNode +} + +// IsPathEnd 判断是否为某个路径的结束 +func (node *Node) IsPathEnd() bool { + return node.isPathEnd +} + +// SoftDel 置软删除状态 +func (node *Node) SoftDel() { + node.isPathEnd = false +} diff --git a/pub/common/wordsfilter/trie_tree_test.go b/pub/common/wordsfilter/trie_tree_test.go new file mode 100644 index 0000000..46311f3 --- /dev/null +++ b/pub/common/wordsfilter/trie_tree_test.go @@ -0,0 +1,14 @@ +package wordsfilter + +import ( + "fmt" + "testing" +) + +func TestTrieTree(t *testing.T) { + tree := NewTrie() + + tree.Add("习近平", "习大大") + fmt.Println(tree.Replace("你好吗 我支持习大大, 他的名字叫做习近平", '*')) + fmt.Println(tree.Filter("你好吗 我支持习大大, 他的名字叫做习近平")) +} diff --git a/pub/dataReport/handle.go b/pub/dataReport/handle.go new file mode 100644 index 0000000..e85d13c --- /dev/null +++ b/pub/dataReport/handle.go @@ -0,0 +1,235 @@ +package dataReport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "math" + "math/rand" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "videoweb/pub/common/godef" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + "videoweb/pub/pubdef" + + "github.com/google/uuid" + "github.com/moul/http2curl" +) + +var config = Config{} + +func Init(c Config) { + config = c + // 根据运行模式设置SDK配置API地址 + switch c.RunMod { + case "prod", "release": + sdkConfigAPIURL = sdkConfigAPIProd + default: + sdkConfigAPIURL = sdkConfigAPIDev + } +} + +// GenerateGlobalEventID 生成全局唯一的 event_id +func GenerateGlobalEventID() string { + // 生成一个新的 UUID + return strings.ReplaceAll(uuid.New().String(), "-", "") +} + +func AsyncReport(eventData interface{}, retryCount int) { + // TODO 后续可改成kafka异步处理 + godef.Go(func() { + // 自动重试 + baseDelay := 1 * time.Second + for i := 0; i < retryCount; i++ { + err := report(eventData) + if err != nil { + log.Error("Event.report failed", log.Any("retryCount", retryCount), log.Any("eventData", eventData), log.E(err)) + // 指数退避 + 随机抖动 + sleepTime := time.Duration(math.Pow(2, float64(i)))*baseDelay + time.Duration(rand.Intn(1000))*time.Millisecond + time.Sleep(sleepTime) + continue + } + break + } + }) + +} + +// 上报事件 +func report(eventData interface{}) error { + // 将结构体转换为表单数据 + formData, err := structToFormData(eventData) + if err != nil { + return err + } + apiUrl := config.ApiUrl + "/api/eventTracking/report.json" + req, err := http.NewRequest("POST", apiUrl, bytes.NewBufferString(formData)) + if err != nil { + log.Error("Event report NewRequest fail", log.E(err)) + return fmt.Errorf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{ + Timeout: 5 * time.Second, // 设置超时时间为 5 秒 + } + command, _ := http2curl.GetCurlCommand(req) + fmt.Println(command) + resp, err := client.Do(req) + if err != nil { + log.Error("Event report fail", log.E(err)) + return fmt.Errorf("failed to send request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error("Event report fail", log.Any("http.code", resp.StatusCode)) + return fmt.Errorf("failed to report event, http.code: %d", resp.StatusCode) + } + // 读取返回的 body + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Error("Event report fail", log.E(err)) + return fmt.Errorf("failed to read response body: %v", err) + } + res := &Response{} + err = json.Unmarshal(body, res) + if err != nil { + log.Error("Event report json.Unmarshal(body, res) fail", log.Any("body", string(body)), log.E(err)) + return err + } + if res.Code != 0 { + log.Error("Event report fail", log.Any("code", res.Code), log.Any("msg", res.Msg), log.Any("data", res.Data)) + return fmt.Errorf("failed to report event, res.code: %d", res.Code) + } + // 输出响应状态和返回的 body + log.Info("Event report Response", log.Any("eventData", eventData), log.Any("body", string(body))) + return nil +} + +func structToFormData(data interface{}) (string, error) { + // 序列化结构体为 JSON 字符串 + jsonData, err := json.Marshal(data) + if err != nil { + return "", err + } + + // 反序列化为 map + var dataMap map[string]interface{} + err = json.Unmarshal(jsonData, &dataMap) + if err != nil { + return "", err + } + + var formData []string + + // 遍历 map,将其转换为 form 数据 + for key, value := range dataMap { + if value == nil { + continue // 如果值为 nil,跳过 + } + + // 将值转换为字符串 + valueStr := fmt.Sprintf("%v", value) + + // 使用 URL 编码 + formData = append(formData, fmt.Sprintf( + "%s=%s", + url.QueryEscape(key), + url.QueryEscape(valueStr), + )) + } + + return strings.Join(formData, "&"), nil +} + +func NewCommonField(event string, districtCode string, ua useragent.UA, ip string, uid int64) CommonField { + return NewCommonFieldAt(event, districtCode, ua, ip, uid, time.Now().Unix()) +} + +func NewCommonFieldAt(event string, districtCode string, ua useragent.UA, ip string, uid int64, clientTS int64) CommonField { + eventId := GenerateGlobalEventID() + // 五项公共字段和 app_version 使用当前请求中的 SDK 原值,旧客户端缺失时留空。 + // 不能用业务 DevID、用户表会话或推算的平台补齐,否则仍会与客户端埋点不一致。 + sdk := ua.SDKCommon + if missing := sdk.MissingFields(); len(missing) > 0 { + log.Warn("【SDK公共参数缺失】NewCommonFieldAt", + log.Any("接口", ua.RequestPath), log.Any("事件", event), + log.Any("eventId", eventId), log.Any("userId", uid), log.Any("客户端版本", ua.Ver), + log.Any("已获取SDK参数", sdk != nil), log.Any("缺失字段", missing)) + } + if sdk == nil { + sdk = &useragent.SDKCommonFields{} + } + return CommonField{ + UID: fmt.Sprintf("%v", uid), + EventID: eventId, + Event: event, + Channel: districtCode, + AppID: pubdef.ReportAppId(), + AppVersion: sdk.AppVersion, + ClientTS: fmt.Sprintf("%v", clientTS), + Device: sdk.Device, + DeviceID: sdk.DeviceID, + DeviceFingerprint: sdk.DeviceFingerprint, + FPVersion: sdk.FPVersion, + UserAgent: strings.TrimSpace(ua.UserAgent), + DeviceModel: strings.TrimSpace(ua.DevType), + SID: sdk.SID, + IP: ip, + } +} + +func JsonNumber(v int64) json.Number { + return json.Number(strconv.FormatInt(v, 10)) +} +func JsonRawMessage(v interface{}) json.RawMessage { + b, _ := json.Marshal(v) + return b +} + +// 批量上报事件 +func BatchReport(ctx context.Context, data interface{}) error { + apiUrl := config.ApiUrl + "/api/eventTracking/batchReport.json" + reqBody, err := json.Marshal(data) + if err != nil { + log.Error("Event report json.Marshal fail", log.E(err)) + return err + } + req, err := http.NewRequest("POST", apiUrl, bytes.NewReader(reqBody)) + if err != nil { + log.Error("Event report NewRequest fail", log.E(err)) + return fmt.Errorf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + curl, _ := http2curl.GetCurlCommand(req) + client := &http.Client{ + Timeout: 5 * time.Second, // 设置超时时间为 5 秒 + } + resp, err := client.Do(req) + if err != nil { + log.Error("Event report fail", log.Any("curl", curl), log.E(err)) + return fmt.Errorf("failed to send request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error("Event report fail", log.Any("curl", curl), log.Any("http.code", resp.StatusCode)) + return fmt.Errorf("failed to report event, status code: %d", resp.StatusCode) + } + // 读取返回的 body + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Error("Event report fail", log.Any("curl", curl), log.E(err)) + return fmt.Errorf("failed to read response body: %v", err) + } + // 输出响应状态和返回的 body + log.Info("Event report Response", log.Any("body", string(body))) + return nil +} diff --git a/pub/dataReport/sdk_config.go b/pub/dataReport/sdk_config.go new file mode 100644 index 0000000..e8c748f --- /dev/null +++ b/pub/dataReport/sdk_config.go @@ -0,0 +1,131 @@ +package dataReport + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/redis" + + "github.com/robfig/cron/v3" +) + +const ( + sdkConfigAPIProd = "https://api.dc-server.cc/sdk/getAvailableDomainList" + sdkConfigAPIDev = "https://dcapi.dc-dev.cc/sdk/getAvailableDomainList" + sdkConfigCacheKey = "report:sdk:domain_config" + sdkConfigCacheTTL = 30 * time.Minute + sdkConfigFetchInterval = "0 */10 * * * *" +) + +var ( + sdkConfigAPIURL = sdkConfigAPIProd // 默认正式环境,Init时会根据RunMod覆盖 + sdkConfigCron *cron.Cron + sdkConfigOnce sync.Once + + sdkConfigMu sync.RWMutex + sdkDomainConfig string + sdkConfigUpdatedAt time.Time +) + +type sdkConfigResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data string `json:"data"` +} + +// StartSDKConfigCron 定时拉取 SDK 域名配置串,服务端只缓存并原样透传。 +func StartSDKConfigCron() { + sdkConfigOnce.Do(func() { + if err := RefreshSDKDomainConfig(); err != nil { + log.Error("RefreshSDKDomainConfig first run fail", log.E(err)) + } + + sdkConfigCron = cron.New(cron.WithSeconds()) + if _, err := sdkConfigCron.AddFunc(sdkConfigFetchInterval, func() { + if err := RefreshSDKDomainConfig(); err != nil { + log.Error("RefreshSDKDomainConfig cron fail", log.E(err)) + } + }); err != nil { + log.Error("StartSDKConfigCron AddFunc fail", log.E(err)) + return + } + sdkConfigCron.Start() + }) +} + +func StopSDKConfigCron() { + if sdkConfigCron == nil { + return + } + ctx := sdkConfigCron.Stop() + <-ctx.Done() +} + +func RefreshSDKDomainConfig() error { + data, err := fetchSDKDomainConfig(sdkConfigAPIURL, &http.Client{Timeout: 5 * time.Second}) + if err != nil { + return err + } + setSDKDomainConfig(data) + if redis.Client() == nil { + return nil + } + return redis.Set(sdkConfigCacheKey, data, sdkConfigCacheTTL) +} + +func GetSDKDomainConfig() string { + if redis.Client() != nil { + data, err := redis.Get(sdkConfigCacheKey) + if err != nil { + log.Error("GetSDKDomainConfig redis get fail", log.E(err)) + } else if data != nil && *data != "" { + setSDKDomainConfig(*data) + return *data + } + } + + sdkConfigMu.RLock() + defer sdkConfigMu.RUnlock() + return sdkDomainConfig +} + +func setSDKDomainConfig(data string) { + sdkConfigMu.Lock() + defer sdkConfigMu.Unlock() + sdkDomainConfig = data + sdkConfigUpdatedAt = time.Now() +} + +func fetchSDKDomainConfig(apiURL string, client *http.Client) (string, error) { + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("sdk config http status: %d", resp.StatusCode) + } + + var result sdkConfigResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if result.Code != 0 { + return "", fmt.Errorf("sdk config code: %d, msg: %s", result.Code, result.Msg) + } + if strings.TrimSpace(result.Data) == "" { + return "", fmt.Errorf("sdk config data is empty") + } + return result.Data, nil +} diff --git a/pub/dataReport/type.go b/pub/dataReport/type.go new file mode 100644 index 0000000..ba7089b --- /dev/null +++ b/pub/dataReport/type.go @@ -0,0 +1,224 @@ +package dataReport + +import ( + "context" + "encoding/json" +) + +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data"` +} + +type Config struct { + ApiUrl string `json:"apiUrl"` + RunMod string `json:"runMod"` // 运行模式: dev/test/prod/release +} + +const ( + EventTypeAdClick = "ad_click" + EventTypeUserLogin = "user_login" + EventTypeUserRegister = "user_register" + EventTypeOrderCreated = "order_created" + EventTypeOrderPaid = "order_paid" + EventTypeCoinConsume = "coin_consume" + EventTypeVideoPurchase = "video_purchase" + EventTypeVideoLike = "video_like" + EventTypeVideoCollect = "video_collect" + EventTypePlayletLike = "playlet_like" + EventTypeVideoComment = "video_comment" + EventTypeVideoStatusChange = "video_status_change" // 视频状态变更 + EventTypeDramaLike = "drama_like" + EventTypeDramaCollect = "drama_collect" + EventTypeDramaComment = "drama_comment" + EventTypeDramaPurchase = "drama_purchase" + + DramaContentTypeShortDrama = "short_drama" + DramaUnlockTypeCoin = "coin" + DramaUnlockTypeVIP = "vip_subscription" + DramaUnlockTypeAd = "ad" + DramaUnlockTypeOther = "other" +) + +// Event 定义事件的接口 +type Event interface { + Generate(ctx context.Context, retryCount int) + AsyncReport(ctx context.Context, retryCount int) error // 执行事件上报 +} + +// 定义公共字段和事件数据结构 +type CommonField struct { + Event string `json:"event"` // 事件类型 + Channel string `json:"channel"` // 渠道码 + EventID string `json:"event_id"` // 事件唯一标识符 + AppID string `json:"app_id"` // 应用ID + AppVersion string `json:"app_version"` // 客户端通过 X-SDK-Common 传入的版本,缺失时留空 + UID string `json:"uid"` // 用户ID + SID string `json:"sid"` // 会话ID + ClientTS string `json:"client_ts"` // 客户端时间戳 + Device string `json:"device"` // 设备类型(如:Android, iOS, PC) + DeviceID string `json:"device_id"` // 设备ID + DeviceFingerprint string `json:"device_fingerprint"` // SDK 设备指纹,SDK 原值为空时也保留字段。 + FPVersion string `json:"fp_version"` // SDK 指纹算法版本 + UserAgent string `json:"user_agent"` // 用户代理信息 + DeviceBrand string `json:"device_brand"` // 设备品牌(如:HUAWEI) + DeviceModel string `json:"device_model"` // 设备型号 + SystemName string `json:"system_name"` // 系统名称 + SystemVersion string `json:"system_version"` // 系统版本 + IP string `json:"ip"` // 用户IP地址 + Payload json.RawMessage `json:"payload"` // 事件数据 +} + +// UserRegisterEvent 注册事件 +type UserRegisterEvent struct { + Type string `json:"type"` // 注册方式:phone, deviceid, email, username + TraceID string `json:"trace_id"` // 唯一标识,落地页点击生成 + CreateTime json.Number `json:"create_time"` // 注册时间,10位时间戳 +} + +type UserLoginEvent struct { + Type string `json:"type"` // 登录方式 +} + +// OrderCreatedEvent 订单创建 +type OrderCreatedEvent struct { + OrderID string `json:"order_id"` // 订单号(唯一标识) + OrderType string `json:"order_type"` // 订单类型 + ProductID string `json:"product_id"` // 商品ID + ProductName string `json:"product_name"` // 商品名称 + VIPDurationID string `json:"vip_duration_type"` + VIPDurationName string `json:"vip_duration_name"` + Amount json.Number `json:"amount"` // 订单金额(分) + Currency string `json:"currency"` // 货币类型 + CoinQuantity json.Number `json:"coin_quantity"` // 金币数量 + SourcePageKey string `json:"source_page_key"` // 来源页面标识 + SourcePageName string `json:"source_page_name"` // 来源页面名称 + CreateTime json.Number `json:"create_time"` // 订单创建时间 +} + +// OrderPaidEvent 订单支付成功 +type OrderPaidEvent struct { + OrderID string `json:"order_id"` // 订单号(关联创建事件) + OrderType string `json:"order_type"` // 订单类型:coin_purchase, vip_subscription + ProductID string `json:"product_id"` // 商品ID + Amount json.Number `json:"amount"` // 实际支付金额(分) + Currency string `json:"currency"` // 货币类型:CNY, USD + CoinQuantity json.Number `json:"coin_quantity"` // 金币数量(仅金币订单) + VIPExpirationTime json.Number `json:"vip_expiration_time"` // VIP过期时间(10位时间戳) + PayType string `json:"pay_type"` // 支付方式:wechat, alipay, bank_card + PayChannel string `json:"pay_channel"` // 支付渠道:银行名称 + TransactionID string `json:"transaction_id"` // 第三方支付交易号 + CreateTime json.Number `json:"create_time"` +} + +// CoinConsumeEvent 金币消耗 +type CoinConsumeEvent struct { + ProductID string `json:"product_id"` // 商品ID + ProductName string `json:"product_name"` // 商品名称 + CoinConsumeAmount json.Number `json:"coin_consume_amount"` // 金币消耗数量 + CoinBalanceBefore json.Number `json:"coin_balance_before"` // 消耗前金币余额 + CoinBalanceAfter json.Number `json:"coin_balance_after"` // 消耗后金币余额 + ConsumeReasonKey string `json:"consume_reason_key"` // 消耗原因 + ConsumeReasonName string `json:"consume_reason_name"` // 消耗原因名称 + CreateTime json.Number `json:"create_time"` // 订单创建时间 + OrderId string `json:"order_id"` // 订单id +} + +// VideoPurchaseEvent 视频购买事件 +type VideoPurchaseEvent struct { + MediaId string `json:"media_id"` // 老司机媒体资源ID(老司机接口返回的id字段) + VideoID string `json:"video_id"` // 视频ID + VideoTitle string `json:"video_title"` // 视频标题 + VideoTypeID string `json:"video_type_id"` // 视频分类ID + VideoTypeName string `json:"video_type_name"` // 视频分类名称 + VideoContentType string `json:"video_content_type"` // 视频类型: video(长视频)、short_video(短视频) + RecommendTraceId string `json:"recommend_trace_id"` // 推荐引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。 + CoinQuantity json.Number `json:"coin_quantity"` // 金币数量 + OrderID string `json:"order_id"` // 订单号 +} + +type ( + VideoEventPub struct { + MediaId string `json:"media_id"` // 老司机媒体资源ID(老司机接口返回的id字段) + VideoID string `json:"video_id"` // 视频ID + VideoTitle string `json:"video_title"` // 视频标题 + VideoTypeID string `json:"video_type_id"` // 视频分类ID + VideoTypeName string `json:"video_type_name"` // 视频分类名称 + VideoContentType string `json:"video_content_type"` // 视频类型: video(长视频)、short_video(短视频) + RecommendTraceId string `json:"recommend_trace_id"` // 推荐引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。 + } + + VideoLikeEvent struct { + VideoEventPub + Flag json.Number `json:"flag"` // 点赞状态:1(点赞), 2(取消点赞) + } + + VideoCollectEvent struct { + VideoEventPub + Flag json.Number `json:"flag"` // 收藏状态:1(收藏), 2(取消收藏) + } + + VideoCommentEvent struct { + VideoEventPub + CommentContent string `json:"comment_content"` // 评论内容 + } + + // VideoStatusChangeEvent 视频状态变更事件 + VideoStatusChangeEvent struct { + MediaId string `json:"media_id"` // 老司机媒体资源ID + VideoID string `json:"video_id"` // 视频ID + VideoTitle string `json:"video_title"` // 视频标题 + VideoTypeID string `json:"video_type_id"` // 视频分类ID + VideoTypeName string `json:"video_type_name"` // 视频分类名称 + Flag json.Number `json:"flag"` // 状态标识 + } + + DramaEventPub struct { + DramaID string `json:"drama_id"` // 剧集章节ID(PlayletChapter.Id) + DramaTitle string `json:"drama_title"` // 剧集名称 + Episode json.Number `json:"episode"` // 第几集,默认1 + VideoID string `json:"video_id"` // 媒资中心章节ID(PlayletChapter.MediaContentId) + TotalEpisode json.Number `json:"total_episode"` // 已上线总集数 + DramaTypeID string `json:"drama_type_id"` // 剧集分类ID + DramaTypeName string `json:"drama_type_name"` // 剧集分类名称 + DramaContentType string `json:"drama_content_type"` // short_drama + DramaTagKey string `json:"drama_tag_key"` // 标签KEY,逗号分隔 + DramaTagName string `json:"drama_tag_name"` // 标签名称,逗号分隔 + DramaDuration json.Number `json:"drama_duration"` // 剧集时长(秒) + } + + DramaLikeEvent struct { + DramaEventPub + Flag json.Number `json:"flag"` // 点赞状态:1(点赞), 2(取消点赞) + } + + DramaCollectEvent struct { + DramaEventPub + Flag json.Number `json:"flag"` // 收藏状态:1(收藏), 2(取消收藏) + } + + DramaCommentEvent struct { + DramaEventPub + CommentContent string `json:"comment_content"` // 评论内容 + } + + DramaPurchaseEvent struct { + DramaEventPub + CoinQuantity json.Number `json:"coin_quantity"` // 金币数量 + OrderID string `json:"order_id"` // 订单号,与 coin_consume 保持一致 + UnlockType string `json:"unlock_type"` // coin / vip_subscription / ad / other + AdID string `json:"ad_id"` // 广告解锁时的广告ID + } +) + +type AdClickEvent struct { + CommonField + PageKey string `json:"page_key"` // 页面标识 + PageName string `json:"page_name"` // 页面名称 + AdSlotKey string `json:"ad_slot_key"` // 广告位标识 + AdSlotName string `json:"ad_slot_name"` // 广告位名称 + AdID string `json:"ad_id"` // 广告ID + CreativeID string `json:"creative_id"` // 素材ID(可选) + AdType string `json:"ad_type"` // 广告类型 +} diff --git a/pub/data_center/datacenter.go b/pub/data_center/datacenter.go new file mode 100644 index 0000000..bcd0ab2 --- /dev/null +++ b/pub/data_center/datacenter.go @@ -0,0 +1,437 @@ +// 接入方只需要填自己的业务数据,其余交给 SDK: +// +// - data_date 留空自动填当天 UTC 日期(回补历史数据时显式指定) +// - company 留空自动填「瑞升」 +// - role_id 留空自动补 [](不是 null) +// - 发送前做必填校验和日期格式校验,问题一次全列出来 +// - JSON 编码、带 API Key 的 POST、超时控制 +// +// 最小用法: +// +// client := datacenter.New(os.Getenv("STUDIO_API_KEY")) +// +// resp, err := client.Send(ctx, datacenter.Record{ +// CompanyPlatform: "黄果短剧", // 必填:所属产品/项目名 +// PublishedData: "01/09/2026", // 必填:视频上线时间 +// ScriptName: "剧本A", // 必填:剧本名称 +// DramaName: "短剧A", // 必填:短剧名称 +// DramaNo: "第1集", // 必填:集数 +// VideoURL: "https://cdn.x/a.mp4", // 必填:成片下载链接 +// PlatformURL: "https://x.com/a", // 必填:产品内播放页链接 +// +// VideoID: "12345", // 选填但建议填 +// Views: 10000, +// Clicks: 8200, +// Likes: 300, +// }) +// +// 一次可以传多条:client.Send(ctx, r1, r2, r3)。 +package datacenter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +const ( + // DefaultEndpoint 是数据回流接口地址,Client.Endpoint 留空时用它。 + DefaultEndpoint = "https://ldschool.up.railway.app/api/v1/video-performance/daily" + + // DefaultCompany 是所属公司的默认值,接入方不用填。 + DefaultCompany = "瑞升" + + // DateLayout 是接口要求的日期格式:DD/MM/YYYY,对应 Go 的 "02/01/2006"。 + // 注意和 "2006-01-02" 的区别,写成后者会被本地校验拦下。 + DateLayout = "02/01/2006" +) + +// Record 是一条视频在某一天的数据。一次请求可以带多条。 +// +// 字段分三类: +// +// 1. SDK 自动填:DataDate、Company、RoleID,接入方留空即可。 +// 2. 必填:CompanyPlatform、PublishedData、ScriptName、DramaName、DramaNo、 +// VideoURL、PlatformURL。留空会在本地就报错,不会白跑一次请求。 +// 3. 选填:其余字段。带 omitempty 的字段留空就整个不出现在请求体里, +// 不会用空字符串污染服务端数据。 +type Record struct { + // ---- 由 SDK 自动填,接入方留空即可 ---- + + // DataDate 数据所属日期,格式 DD/MM/YYYY。 + // 留空 = 当天 UTC 日期;要回补历史数据就显式写,如 "21/09/2026"。 + DataDate string `json:"data_date"` + + // Company 所属公司,留空 = 瑞升。 + Company string `json:"company"` + + // RoleID 角色 ID 数组,留空会发送 [](而不是 null)。 + RoleID []string `json:"role_id"` + + // ---- 必填 ---- + + // CompanyPlatform 所属项目/产品名称,如「黄果短剧」。 + CompanyPlatform string `json:"company_platform"` + + // PublishedData 视频首次上线时间,格式 DD/MM/YYYY。 + PublishedData string `json:"published_data"` + + // ScriptName 剧本名称,需与剧本 ID 对应一致。 + ScriptName string `json:"script_name"` + + // DramaName 短剧名称,需与集数保持一致。 + DramaName string `json:"drama_name"` + + // DramaNo 集数,如「第1集」。 + DramaNo string `json:"drama_no"` + + // VideoURL 可访问的成片下载/存储链接。 + VideoURL string `json:"video_url"` + + // PlatformURL 视频在产品内的播放页面链接。 + PlatformURL string `json:"platform_url"` + + // ---- 选填 ---- + + // VideoID 视频唯一标识。选填,但建议填,便于中台对齐同一条视频的历史数据。 + VideoID string `json:"video_id,omitempty"` + + // ScriptID 剧本唯一编号。 + ScriptID string `json:"script_id,omitempty"` + + // PlatformVideoID 视频在产品平台内的唯一 ID。 + PlatformVideoID string `json:"platform_video_id,omitempty"` + + // Views 浏览量:统计周期内的视频曝光/浏览次数。 + Views int64 `json:"views"` + + // Clicks 播放量:统计周期内的有效播放次数。 + Clicks int64 `json:"clicks"` + + // Likes 点赞量:统计周期内的累计点赞次数。 + Likes int64 `json:"likes"` + + // LikeRate 点赞率,字符串带百分号,如 "3.66%"。 + // 计算公式:点赞量 ÷ 播放量 × 100%,保留两位小数。 + LikeRate string `json:"like_rate,omitempty"` + + // CompletionRate 完播率,如 "43.00%"。 + // 没有单条视频数据时,可以填剧本整体的完播率。 + CompletionRate string `json:"completion_rate,omitempty"` + + // PaidRate 付费率,如 "0.00%"。 + // 计算公式:付费用户 ÷ 播放用户 × 100%;免费短剧统一填 "0.00%"。 + PaidRate string `json:"paid_rate,omitempty"` + + // Comments 评论量。用指针是为了区分「没填」和「填了 0」: + // 填 datacenter.Int(0) 会真的发出 0,留 nil 则整个字段省略。 + Comments *int64 `json:"comments,omitempty"` + + // Shares 分享量,和 Comments 同理。 + Shares *int64 `json:"shares,omitempty"` + + // CompletionRateScope 完播率口径,取值 video / script / unknown。 + CompletionRateScope string `json:"completion_rate_scope,omitempty"` +} + +// Int 返回指向 v 的指针,用来给 Comments / Shares 填 0。 +// +// Comments: datacenter.Int(0) // 发出 "comments": 0 +// Comments: nil // 不发送 comments 字段 +func Int(v int64) *int64 { return &v } + +// request 是接口的请求体,数据都包在 records 数组里。 +type request struct { + Records []Record `json:"records"` +} + +// Response 是接口返回,方便直接打日志。 +type Response struct { + StatusCode int // HTTP 状态码 + Body []byte // 响应体原文,最多读 1MB +} + +// OK 表示接口返回 200。 +func (r *Response) OK() bool { return r != nil && r.StatusCode == http.StatusOK } + +// ValidationError 表示本地校验没过。Problems 里逐条说明是第几条、缺什么, +// 一次列全,不用改一个跑一次。 +type ValidationError struct { + Problems []string // 例:`records[0]: 缺少必填字段: script_name, video_url` +} + +func (e *ValidationError) Error() string { + return "datacenter: 数据校验失败:\n " + strings.Join(e.Problems, "\n ") +} + +// Client 是回流客户端。创建一次就能一直复用,多个 goroutine 同时用也没问题。 +type Client struct { + APIKey string // 领取时展示的完整 Key,以 ld_ 开头;建议从环境变量读,别写进代码 + Endpoint string // 接口地址,留空用 DefaultEndpoint + HTTPClient *http.Client // 留空用 30 秒超时的默认 client + + // OnError 是异步上报失败时的回调,可选。不设置就打到 stderr。 + // 校验失败、网络失败、队列满被丢弃,都会走这里。 + // 回调在后台 goroutine 里执行,里面别做耗时操作。 + OnError func(error) + + // QueueSize 是异步队列能缓存的批次数,留空用 DefaultQueueSize。 + // 队列满了会丢弃新数据(不阻塞主流程)并回调 OnError。 + QueueSize int + + // 以下为异步内部状态,接入方不用管。 + mu sync.Mutex + queue chan []Record + done chan struct{} + started bool + closed bool +} + +// DefaultQueueSize 是异步队列的默认长度(批次数)。 +const DefaultQueueSize = 1000 + +// asyncTimeout 是后台单次请求的超时,比 HTTPClient 的 30 秒留一点余量。 +const asyncTimeout = 60 * time.Second + +// New 用 API Key 创建客户端,地址和 HTTP client 都用默认值。 +func New(apiKey string) *Client { + return &Client{ + APIKey: strings.TrimSpace(apiKey), + Endpoint: DefaultEndpoint, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + QueueSize: DefaultQueueSize, + } +} + +// Send 上报一批数据:先补齐默认值(日期/公司/角色数组),再校验,最后整体 POST。 +// +// 校验不通过返回 *ValidationError(不发请求);服务端返回非 200 时同时返回 +// Response 和 error,Response 里带着状态码和响应体,方便排查。 +// +// 只打印不发请求的话用 JSON()。 +func (c *Client) Send(ctx context.Context, records ...Record) (*Response, error) { + if c.APIKey == "" { + return nil, fmt.Errorf("datacenter: APIKey 为空") + } + if !strings.HasPrefix(c.APIKey, "ld_") { + return nil, fmt.Errorf("datacenter: APIKey 需要是领取时展示的完整 Key,以 ld_ 开头") + } + if len(records) == 0 { + return nil, fmt.Errorf("datacenter: 没有要发送的数据") + } + + // 复制一份再动,不改调用方传进来的切片。 + payload := make([]Record, len(records)) + copy(payload, records) + + // 校验全部走一遍再返回,把问题攒一起报。 + var problems []string + for i := range payload { + payload[i].fillDefaults() + if err := payload[i].validate(); err != nil { + problems = append(problems, fmt.Sprintf("records[%d]: %v", i, err)) + } + } + if len(problems) > 0 { + return nil, &ValidationError{Problems: problems} + } + + body, err := json.Marshal(request{Records: payload}) + if err != nil { + return nil, fmt.Errorf("datacenter: 编码请求失败: %w", err) + } + + endpoint := c.Endpoint + if endpoint == "" { + endpoint = DefaultEndpoint + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("datacenter: 构造请求失败: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Content-Type", "application/json") + + client := c.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("datacenter: 请求失败: %w", err) + } + defer resp.Body.Close() + + // 只读 1MB,防止对面返回异常大的内容把内存吃掉。 + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("datacenter: 读取响应失败: %w", err) + } + out := &Response{StatusCode: resp.StatusCode, Body: respBody} + if !out.OK() { + return out, fmt.Errorf("datacenter: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + return out, nil +} + +// SendAsync 异步上报一批数据,立即返回,不会阻塞调用方。 +// +// 数据先进内存队列,由后台 goroutine 依次发送;同一个 client 的发送顺序 +// 和调用顺序一致。适合放在主流程里(定时任务、接口 handler)用。 +// +// 三点必须知道: +// +// 1. 失败不会返回给调用方,通过 c.OnError 回调(不设置就打到 stderr)。 +// 2. 队列满了会丢弃新数据并回调,宁丢不堵——主流程优先。 +// 3. 进程退出前必须调 c.Close() 等数据发完,否则队列里的会丢。 +// +// 需要拿到结果、或者只是短脚本发一次就退出,直接用同步的 Send。 +func (c *Client) SendAsync(records ...Record) { + if len(records) == 0 { + return + } + + var err error + c.mu.Lock() + switch { + case c.closed: + err = fmt.Errorf("datacenter: 客户端已 Close,丢弃 %d 条数据", len(records)) + default: + if !c.started { + size := c.QueueSize + if size <= 0 { + size = DefaultQueueSize + } + c.queue = make(chan []Record, size) + c.done = make(chan struct{}) + c.started = true + go c.worker() + } + select { + case c.queue <- records: + default: + err = fmt.Errorf("datacenter: 异步队列已满(%d 批),丢弃 %d 条数据", cap(c.queue), len(records)) + } + } + c.mu.Unlock() + + // 回调放在锁外,这样 OnError 里再调 SendAsync 也不会死锁。 + if err != nil { + c.reportErr(err) + } +} + +// worker 是后台发送协程,按队列顺序逐批发,发完一批再取下一批。 +func (c *Client) worker() { + defer close(c.done) + for batch := range c.queue { + ctx, cancel := context.WithTimeout(context.Background(), asyncTimeout) + _, err := c.Send(ctx, batch...) + cancel() + if err != nil { + c.reportErr(err) + } + } +} + +// Close 停止接收新的异步数据,并等队列里剩下的发完。 +// +// 进程/任务退出前一定要调(defer 或任务末尾)。没调过 SendAsync 的话是空操作, +// 重复调也安全。Close 之后还调 SendAsync 不会 panic,数据会被丢弃并回调 OnError。 +func (c *Client) Close() { + c.mu.Lock() + c.closed = true + if c.started { + close(c.queue) + } + done := c.done + c.mu.Unlock() + + if done != nil { + <-done // 等 worker 把队列清空 + } +} + +// reportErr 把异步过程中的错误交给 OnError,没设置就打到 stderr。 +func (c *Client) reportErr(err error) { + if c.OnError != nil { + c.OnError(err) + return + } + log.Println(err) +} + +// JSON 返回这批数据最终会发出去的 JSON(同样会补默认值),用来本地核对, +// 不发任何请求。配合 Send 使用可以做到「先看一眼,再真发」。 +func JSON(records ...Record) ([]byte, error) { + payload := make([]Record, len(records)) + copy(payload, records) + for i := range payload { + payload[i].fillDefaults() + } + return json.MarshalIndent(request{Records: payload}, "", " ") +} + +// fillDefaults 补齐 SDK 负责的三个字段。显式填过的值不会被覆盖。 +func (r *Record) fillDefaults() { + if strings.TrimSpace(r.DataDate) == "" { + r.DataDate = time.Now().UTC().Format(DateLayout) + } + if strings.TrimSpace(r.Company) == "" { + r.Company = DefaultCompany + } + if r.RoleID == nil { + // 用空数组而不是 nil,避免 JSON 里出现 "role_id": null。 + r.RoleID = []string{} + } +} + +// validate 检查必填字段和日期格式,返回一条汇总的错误信息。 +func (r *Record) validate() error { + // 必填字段,顺序和《参数说明》表格一致。 + required := []struct { + name string // 报错时显示的字段名,用 JSON 里的名字方便对照 + value string + }{ + {"company_platform", r.CompanyPlatform}, + {"published_data", r.PublishedData}, + {"script_name", r.ScriptName}, + {"drama_name", r.DramaName}, + {"drama_no", r.DramaNo}, + {"video_url", r.VideoURL}, + {"platform_url", r.PlatformURL}, + } + var missing []string + for _, f := range required { + if strings.TrimSpace(f.value) == "" { + missing = append(missing, f.name) + } + } + + // 从示例代码抄下来忘了改的占位符,也拦一下。 + if strings.HasPrefix(r.VideoID, "REPLACE_") { + missing = append(missing, "video_id") + } + + // 日期必须是 DD/MM/YYYY。写成 2026-09-21 这种会在这里被拦下。 + for _, d := range []struct{ name, value string }{ + {"data_date", r.DataDate}, + {"published_data", r.PublishedData}, + } { + if _, err := time.Parse(DateLayout, d.value); err != nil { + missing = append(missing, fmt.Sprintf("%s 日期格式应为 DD/MM/YYYY,当前为 %q", d.name, d.value)) + } + } + + if len(missing) == 0 { + return nil + } + return fmt.Errorf("缺少必填字段: %s", strings.Join(missing, ", ")) +} diff --git a/pub/data_center/datacenter_test.go b/pub/data_center/datacenter_test.go new file mode 100644 index 0000000..90fd519 --- /dev/null +++ b/pub/data_center/datacenter_test.go @@ -0,0 +1,150 @@ +package datacenter + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func sample() Record { + return Record{ + VideoID: "12345", + CompanyPlatform: "黄果短剧", + PublishedData: "01/09/2026", + ScriptName: "剧本A", + DramaName: "短剧A", + DramaNo: "第1集", + VideoURL: "https://cdn.example.com/a.mp4", + PlatformURL: "https://example.com/a", + Views: 10000, + Clicks: 8200, + Likes: 300, + Comments: Int(0), + } +} + +func TestFillDefaults(t *testing.T) { + r := sample() + r.fillDefaults() + if r.Company != DefaultCompany { + t.Fatalf("company = %q", r.Company) + } + if r.DataDate != time.Now().UTC().Format(DateLayout) { + t.Fatalf("data_date = %q", r.DataDate) + } + if r.RoleID == nil { + t.Fatal("role_id 应为空数组而不是 null") + } + // 显式传的日期不能被覆盖 + r2 := sample() + r2.DataDate = "21/09/2026" + r2.fillDefaults() + if r2.DataDate != "21/09/2026" { + t.Fatalf("data_date 被覆盖为 %q", r2.DataDate) + } +} + +func TestValidate(t *testing.T) { + s := sample() + if err := s.validate(); err != nil && !strings.Contains(err.Error(), "data_date") { + t.Fatalf("sample 不应报错: %v", err) + } + bad := sample() + bad.ScriptName = "" + bad.VideoURL = "" + if err := bad.validate(); err == nil || + !strings.Contains(err.Error(), "script_name") || + !strings.Contains(err.Error(), "video_url") { + t.Fatalf("应报缺 script_name/video_url: %v", err) + } + wrongDate := sample() + wrongDate.DataDate = "2026-09-21" + if err := wrongDate.validate(); err == nil || !strings.Contains(err.Error(), "DD/MM/YYYY") { + t.Fatalf("应报日期格式错误: %v", err) + } +} + +func TestJSONShape(t *testing.T) { + b, err := JSON(sample()) + if err != nil { + t.Fatal(err) + } + var got struct { + Records []map[string]any `json:"records"` + } + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + row := got.Records[0] + if row["data_date"] != time.Now().UTC().Format(DateLayout) { + t.Fatalf("data_date = %v", row["data_date"]) + } + if row["company"] != DefaultCompany { + t.Fatalf("company = %v", row["company"]) + } + if comments, ok := row["comments"].(float64); !ok || comments != 0 { + t.Fatalf("comments 应该发出 0,实际 %v", row["comments"]) + } + if shares, ok := row["shares"]; ok { + t.Fatalf("shares 没填就不应出现,实际 %v", shares) + } +} + +// TestSendAsync 验证异步上报不阻塞、按顺序发完,且 Close 会等队列清空。 +func TestSendAsync(t *testing.T) { + var mu sync.Mutex + var got []string // 收到的 video_id,按到达顺序 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Records []struct { + VideoID string `json:"video_id"` + } `json:"records"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("解码请求失败: %v", err) + } + mu.Lock() + for _, rec := range req.Records { + got = append(got, rec.VideoID) + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New("ld_test") + c.Endpoint = srv.URL + c.OnError = func(err error) { t.Errorf("异步上报不应报错: %v", err) } + + for _, id := range []string{"a", "b", "c"} { + r := sample() + r.VideoID = id + c.SendAsync(r) + } + c.Close() // 等发完 + + mu.Lock() + defer mu.Unlock() + if strings.Join(got, ",") != "a,b,c" { + t.Fatalf("发送顺序不对: %v", got) + } +} + +// TestSendAsyncAfterClose 验证 Close 之后再上报不会 panic,而是走 OnError。 +func TestSendAsyncAfterClose(t *testing.T) { + c := New("ld_test") + var reported []error + c.OnError = func(err error) { reported = append(reported, err) } + + c.Close() // 没调过 SendAsync,应该是空操作 + c.SendAsync(sample()) + c.Close() // 重复调也安全 + + if len(reported) != 1 || !strings.Contains(reported[0].Error(), "已 Close") { + t.Fatalf("应报一次「已 Close」: %v", reported) + } +} diff --git a/pub/data_center/example/main.go b/pub/data_center/example/main.go new file mode 100644 index 0000000..5f54e70 --- /dev/null +++ b/pub/data_center/example/main.go @@ -0,0 +1,119 @@ +// 短剧数据回流 demo。 +// +// 用法: +// +// go run ./example # 只看将发送的 JSON,不发请求 +// STUDIO_API_KEY='ld_xxx' go run ./example -send # 真正上报 +// +// 接入时照着这个文件抄:改 records 里的数据,把 main 里的发送逻辑搬进你们自己的任务里。 +package main + +import ( + "flag" + "fmt" + "os" + + datacenter "videoweb/pub/data_center" +) + +// ============================================================ +// 1. 要上报的数据。一条 = 一个视频在某一天的表现。 +// +// 不用填的(SDK 自动补): +// - data_date 数据所属日期 → 当天 UTC 日期 +// - company 所属公司 → 瑞升 +// - role_id 角色 ID 数组 → [] +// +// 必须填的(漏了会在本地就报错,不会白跑请求): +// - company_platform 所属产品/项目名 +// - published_data 视频上线时间 +// - script_name 剧本名称 +// - drama_name 短剧名称 +// - drama_no 集数 +// - video_url 成片下载链接 +// - platform_url 产品内播放页链接 +// +// 其余都是选填,不填就不会出现在请求体里。 +// ============================================================ +var records = []datacenter.Record{ + { + // ---- 必填 ---- + CompanyPlatform: "黄果短剧", // 你们的产品/项目名 + PublishedData: "01/09/2026", + ScriptName: "剧本A", + DramaName: "短剧A", + DramaNo: "第1集", + VideoURL: "https://cdn.example.com/a.mp4", + PlatformURL: "https://example.com/a", + + // ---- 选填,建议填:方便中台把同一条视频的历史数据串起来 ---- + VideoID: "12345", + ScriptID: "S-001", + PlatformVideoID: "pv-12345", + + // ---- 数据 ---- + Views: 10000, // 浏览量 + Clicks: 8200, // 播放量 + Likes: 300, // 点赞量 + + // ---- 选填,不用就整行删掉 ---- + // LikeRate: "3.66%", // 点赞量 ÷ 播放量 × 100% + // CompletionRate: "43.00%", // 完播率,没单条数据可填剧本整体完播率 + // PaidRate: "0.00%", // 免费短剧统一 0.00% + // CompletionRateScope: "video", // video | script | unknown + // Comments: datacenter.Int(14), // 填 0 也会正常发出,删掉则不发这个字段 + // Shares: datacenter.Int(0), + }, + // 想一次发多条就继续往这里加,SDK 会打包成一个请求发出去。 + // { + // CompanyPlatform: "黄果短剧", + // ... + // }, +} + +func main() { + send := flag.Bool("send", false, "真正 POST。默认只打印 JSON") + flag.Parse() + + // ------------------------------------------------------------ + // 第 1 步:本地核对。打印补齐默认值之后最终会发出的 JSON。 + // ------------------------------------------------------------ + body, err := datacenter.JSON(records...) + if err != nil { + fatal(err) + } + fmt.Println(string(body)) + + if !*send { + fmt.Println("\n未发送。确认无误后执行:") + fmt.Println(" STUDIO_API_KEY='ld_...' go run ./example -send") + return + } + + // ------------------------------------------------------------ + // 第 2 步:异步上报。 + // + // SendAsync 把数据丢进内存队列就立即返回,HTTP 请求在后台发, + // 不会拖慢你们的主流程(接口 handler、定时任务都适用)。 + // + // 两条铁律: + // 1. 失败不会返回给你,用 OnError 接住写日志。 + // 2. 进程退出前必须 Close(),否则队列里的数据会丢。 + // ------------------------------------------------------------ + client := datacenter.New(os.Getenv("STUDIO_API_KEY")) // 创建一次,全程复用 + + client.OnError = func(err error) { + // 漏填字段、网络失败、队列满被丢弃,都会走到这里。 + fmt.Fprintln(os.Stderr, "上报失败:", err) + } + + client.SendAsync(records...) // 立即返回,不阻塞 + + client.Close() // 等队列里的数据发完再退出 + fmt.Println("\n上报完成") +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/pub/esmanager/client.go b/pub/esmanager/client.go new file mode 100644 index 0000000..94883bf --- /dev/null +++ b/pub/esmanager/client.go @@ -0,0 +1,205 @@ +package esmanager + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/elastic/go-elasticsearch/v7/esapi" +) + +const ( + AnalyzerIkSmart = "ik_smart" + AnalyzerIkMaxWord = "ik_max_word" + NumberOfShards = 1 + NumberOfReplicas = 1 +) + +func (m *Manager) InitIndices(indexName string, setting M) error { + res, err := m.client.Indices.Create(indexName, func(request *esapi.IndicesCreateRequest) { + request.Body = marshalSearchM(setting) + }) + defer res.Body.Close() + return err +} + +// GetTokens 获取分词 +func (m *Manager) Tokens(index string, bind interface{}, query M, o ...func(*esapi.IndicesAnalyzeRequest)) error { + o = append(o, + m.client.Indices.Analyze.WithContext(context.Background()), + m.client.Indices.Analyze.WithIndex(index), + m.client.Indices.Analyze.WithBody(marshalSearchM(query)), + m.client.Indices.Analyze.WithPretty(), + ) + + res, err := m.client.Indices.Analyze(o...) + if err != nil { + return err + } + defer res.Body.Close() + err = unmarshalAnalyzeBody(bind, res) + return err +} + +// search 搜索 +func (m *Manager) Search(index string, bind interface{}, query M, o ...func(*esapi.SearchRequest)) error { + marshalSearchM(query) + o = append(o, + m.client.Search.WithContext(context.Background()), + m.client.Search.WithIndex(index), + m.client.Search.WithBody(marshalSearchM(query)), + m.client.Search.WithTrackTotalHits(true), + m.client.Search.WithPretty(), + ) + res, err := m.client.Search(o...) + if err != nil { + return err + } + defer res.Body.Close() + err = unmarshalBody(bind, res) + return err +} + +// 批量插入 +func (m *Manager) Bulk(index string, source M, o ...func(*esapi.BulkRequest)) error { + o = append(o, + m.client.Bulk.WithIndex(index), + m.client.Bulk.WithContext(context.Background()), + ) + res, err := m.client.Bulk(marshalBulkM(source), o...) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", res)) + } + var bulkResp struct { + Errors bool `json:"errors"` + Items []map[string]struct { + Status int `json:"status"` + Error interface{} `json:"error"` + } `json:"items"` + } + if err := json.NewDecoder(res.Body).Decode(&bulkResp); err != nil { + return err + } + if bulkResp.Errors { + errs := make([]string, 0, 3) + for _, item := range bulkResp.Items { + for op, result := range item { + if result.Status >= http.StatusBadRequest { + errs = append(errs, fmt.Sprintf("%s status=%d error=%v", op, result.Status, result.Error)) + if len(errs) >= 3 { + return errors.New(fmt.Sprintf("elastic bulk item errors: %v", errs)) + } + } + } + } + return errors.New(fmt.Sprintf("elastic bulk item errors: %v", errs)) + } + return nil +} + +func (m *Manager) Refresh(index string, o ...func(*esapi.IndicesRefreshRequest)) error { + o = append(o, + m.client.Indices.Refresh.WithContext(context.Background()), + m.client.Indices.Refresh.WithIndex(index), + ) + res, err := m.client.Indices.Refresh(o...) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= http.StatusBadRequest { + return errors.New(fmt.Sprintf("%+v", res)) + } + return nil +} + +// Count 获取数量 +func (m *Manager) Count(index string, query M, o ...func(*esapi.CountRequest)) (int64, error) { + o = append(o, + m.client.Count.WithIndex(index), + m.client.Count.WithBody(marshalSearchM(query)), + m.client.Count.WithContext(context.Background()), + ) + res, err := m.client.Count(o...) + if err != nil { + return 0, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return 0, errors.New(fmt.Sprintf("%+v", res)) + } + type Bind struct { + Count int64 `json:"count"` + } + var bind = Bind{} + err = unmarshalStruct(&bind, res) + if err != nil { + return 0, err + } + return bind.Count, err +} + +func (m *Manager) Get(index string, id string, bind interface{}, o ...func(*esapi.GetRequest)) error { + o = append(o, + m.client.Get.WithContext(context.Background()), + m.client.Get.WithPretty(), + ) + res, err := m.client.Get(index, id, o...) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", res)) + } + return unmarshalBody(bind, res) +} + +func (m *Manager) Add(index string, id string, doc interface{}, o ...func(*esapi.IndexRequest)) error { + o = append(o, + m.client.Index.WithDocumentID(id), + m.client.Index.WithContext(context.Background()), + m.client.Index.WithPretty(), + m.client.Index.WithRefresh("true"), + ) + res, err := m.client.Index(index, marshalStruct(doc), o...) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} + +func (m *Manager) Del(index string, id string, o ...func(*esapi.DeleteRequest)) error { + o = append(o, + m.client.Delete.WithContext(context.Background()), + m.client.Delete.WithPretty(), + m.client.Delete.WithRefresh("true"), + ) + res, err := m.client.Delete(index, id, o...) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} + +func (m *Manager) DelMany(indexs []string, query M, o ...func(*esapi.DeleteByQueryRequest)) error { + o = append(o, + m.client.DeleteByQuery.WithContext(context.Background()), + m.client.DeleteByQuery.WithPretty(), + m.client.DeleteByQuery.WithRefresh(true), + ) + res, err := m.client.DeleteByQuery(indexs, marshalSearchM(query), o...) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} diff --git a/pub/esmanager/elastic.go b/pub/esmanager/elastic.go new file mode 100644 index 0000000..ba00bbd --- /dev/null +++ b/pub/esmanager/elastic.go @@ -0,0 +1,143 @@ +package esmanager + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/elastic/go-elasticsearch/v7/esapi" +) + +type M map[string]interface{} + +type A []map[string]interface{} + +type MGetBody struct { + ID string `json:"_id"` +} + +// ElasticResp 响应 +type Response = esapi.Response + +type Options struct { + Address []string + MaxIdleConnPerHost int + IdleConnTimeout time.Duration +} + +func unmarshalAggregateBody(bind interface{}, resp *Response) error { + var v map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&v) + if err != nil { + return err + } + if v != nil && len(v) > 0 { + if v, ok := v["aggregations"].(map[string]interface{}); ok { + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &bind) + } + } + return nil +} + +func unmarshalAnalyzeBody(bind interface{}, resp *Response) error { + var v map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&v) + if err != nil { + return err + } + if v != nil && len(v) > 0 { + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &bind) + } + return nil +} + +func unmarshalBody(bind interface{}, resp *Response) error { + var data map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + return err + } + if data != nil && len(data) > 0 { + if hits1, ok := data["hits"].(map[string]interface{}); ok { + b, _ := json.Marshal(hits1) + _ = json.Unmarshal(b, &bind) + } else if _, ok := data["docs"].([]interface{}); ok { + b, _ := json.Marshal(data["docs"]) + _ = json.Unmarshal(b, &bind) + } else if _, ok := data["_source"].(map[string]interface{}); ok { + b, _ := json.Marshal(data) + _ = json.Unmarshal(b, &bind) + } + } + return nil +} + +func marshalSearchM(query M) io.Reader { + var buf bytes.Buffer + if query == nil || len(query) == 0 { + return nil + } + err := json.NewEncoder(&buf).Encode(query) + if err != nil { + return nil + } + return bytes.NewReader(buf.Bytes()) +} + +func marshalBulkM(source M) io.Reader { + var buf bytes.Buffer + for k, v := range source { + var meta = []byte(fmt.Sprintf(`{ "index" : { "_id" : "%s" } }%s`, k, "\n")) + var data, _ = json.Marshal(v) + data = append(data, "\n"...) + buf.Grow(len(meta) + len(data)) + buf.Write(meta) + buf.Write(data) + } + return bytes.NewReader(buf.Bytes()) +} + +func marshalStruct(v interface{}) io.Reader { + var buf bytes.Buffer + if v == nil { + return nil + } + err := json.NewEncoder(&buf).Encode(v) + if err != nil { + return nil + } + return bytes.NewReader(buf.Bytes()) +} + +func unmarshalStruct(bind interface{}, resp *Response) error { + var v map[string]interface{} + defer func() { _ = resp.Body.Close() }() + if bind == nil { //传入空表示只执行 defer + return nil + } + err := json.NewDecoder(resp.Body).Decode(&v) + if err != nil { + return err + } + if v != nil && len(v) > 0 { + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &bind) + } + return nil +} \ No newline at end of file diff --git a/pub/esmanager/manager.go b/pub/esmanager/manager.go new file mode 100644 index 0000000..0f58f4e --- /dev/null +++ b/pub/esmanager/manager.go @@ -0,0 +1,53 @@ +package esmanager + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/elastic/go-elasticsearch/v7" +) + +var Instance *Manager + +type Manager struct { + client *elasticsearch.Client + conf *elasticsearch.Config +} + +func NewManager(url string, user string, pw string) (*Manager, error) { + if url == "" { + return nil, errors.New(fmt.Sprintf("Elastic NewManager url is empty!")) + } + cfg := elasticsearch.Config{ + Addresses: []string{url}, + Transport: &http.Transport{ + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 30, + }, + Username: user, + Password: pw, + } + return &Manager{conf: &cfg}, nil +} + +func (m *Manager) Client() *elasticsearch.Client { + return m.client +} + +func (m *Manager) ClientConnect() error { + c, err := elasticsearch.NewClient(*m.conf) + if err != nil { + return err + } + resp, err := c.Ping(c.Ping.WithContext(context.Background())) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", resp)) + } + m.client = c + return nil +} \ No newline at end of file diff --git a/pub/google/googleAuth.go b/pub/google/googleAuth.go new file mode 100644 index 0000000..1ba566d --- /dev/null +++ b/pub/google/googleAuth.go @@ -0,0 +1,92 @@ +package google + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "strings" + "time" +) + +type GoogleAuth struct { +} + +func NewGoogleAuth() *GoogleAuth { + return &GoogleAuth{} +} + +func (ga *GoogleAuth) un() int64 { + return time.Now().UnixNano() / 1000 / 30 +} + +func (ga *GoogleAuth) hmacSha1(key, data []byte) []byte { + h := hmac.New(sha1.New, key) + if total := len(data); total > 0 { + h.Write(data) + } + return h.Sum(nil) +} + +func (ga *GoogleAuth) base32encode(src []byte) string { + return base32.StdEncoding.EncodeToString(src) +} + +func (ga *GoogleAuth) base32decode(s string) ([]byte, error) { + return base32.StdEncoding.DecodeString(s) +} + +func (ga *GoogleAuth) toBytes(value int64) []byte { + var result []byte + mask := int64(0xFF) + shifts := [8]uint16{56, 48, 40, 32, 24, 16, 8, 0} + for _, shift := range shifts { + result = append(result, byte((value>>shift)&mask)) + } + return result +} + +func (ga *GoogleAuth) toUint32(bts []byte) uint32 { + return (uint32(bts[0]) << 24) + (uint32(bts[1]) << 16) + + (uint32(bts[2]) << 8) + uint32(bts[3]) +} + +func (ga *GoogleAuth) oneTimePassword(key []byte, data []byte) uint32 { + hash := ga.hmacSha1(key, data) + offset := hash[len(hash)-1] & 0x0F + hashParts := hash[offset : offset+4] + hashParts[0] = hashParts[0] & 0x7F + number := ga.toUint32(hashParts) + return number % 1000000 +} + +func (ga *GoogleAuth) GetSecret() string { + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, ga.un()) + return strings.ToUpper(ga.base32encode(ga.hmacSha1(buf.Bytes(), nil))) +} + +func (ga *GoogleAuth) GetCode(secret string) (string, error) { + secretUpper := strings.ToUpper(secret) + secretKey, err := ga.base32decode(secretUpper) + if err != nil { + return "", err + } + number := ga.oneTimePassword(secretKey, ga.toBytes(time.Now().Unix()/30)) + return fmt.Sprintf("%06d", number), nil +} + +func (ga *GoogleAuth) GetQrcode(user, secret string) string { + return fmt.Sprintf("otpauth://totp/%s?secret=%s", user, secret) +} + +func (ga *GoogleAuth) VerifyCode(secret, code string) (bool, error) { + _code, err := ga.GetCode(secret) + fmt.Println(_code, code, err) + if err != nil { + return false, err + } + return _code == code, nil +} diff --git a/pub/models/actress/actress.go b/pub/models/actress/actress.go new file mode 100644 index 0000000..f88ed9e --- /dev/null +++ b/pub/models/actress/actress.go @@ -0,0 +1,181 @@ +package actress + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.Actress) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Alias, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Alias, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Rank, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.UpdatedAt, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Works, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Works, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.HotValue, Value: -1}, + {Key: FN.Actress.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Real.WeekWatchs, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Real.MonthWatchs, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Actress.Status, Value: 1}, + {Key: FN.Actress.Real.WatchTimes, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initActressIndex) Create Actress Index failed! err:%+v:", err)) + } + +} + +// InsertOneActress 插入一个导航分类 +func InsertOneActress(ctx context.Context, qa *Actress) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + log.Warn("Actressmod:InsertOneActress db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneActress 删除一个导航分类 +func DelOneActress(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("Actressmod:DelOneActress db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneActress 查找一个导航分类 +func FindOneActress(ctx context.Context, filter bson.M) ( + *Actress, *srvdef.SrvErr) { + var Actress Actress + if err := db.FindOneEx(ctx, &Actress, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("Actressmod:FindOneActress db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &Actress, nil +} + +func FindManyActresss(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (ActressList, *srvdef.SrvErr) { + data := make(ActressList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyActresss", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountActresss 统计导航分类数量 +func CountActresss(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountActresss db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneActress 更新一个导航分类 +func UpdateOneActress(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneActress db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneActress db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} +func UpdateManyActress(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyActress db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} diff --git a/pub/models/actress/tag.go b/pub/models/actress/tag.go new file mode 100644 index 0000000..8a55d80 --- /dev/null +++ b/pub/models/actress/tag.go @@ -0,0 +1,96 @@ +package actress + +type TagActress struct { + Meas struct { + Gender string // Type: int , Desc: 性别 0:女 1:男 2:其他 + Height string // Type: int , Desc: 身高 厘米 + Weight string // Type: int , Desc: 体重 公斤 + Bust string // Type: int , Desc: 胸围 英寸 + Waist string // Type: int , Desc: 腰围 英寸 + Hip string // Type: int , Desc: 臀围 英寸 + Cup string // Type: int , Desc: 罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I + Len string // Type: int , Desc: 长度 毫米(前端可以显示为厘米,如45毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145) + Dia string // Type: int , Desc: 直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45) + Blood string // Type: string , Desc: 血型 + } + Id string + Alias string + Slug string + Avatar string + Cover string + Desc string + TranslationCategoryId string + Office string + DebutDate string + BornDate string + Region string + Score string + Rank string + HotValue string + Status string + Works string + CreatedAt string + UpdatedAt string + RealUpdateAt string + SeoInfo string + IndexNow string + Real struct { + WatchTimes string + ShowTimes string + Comments string + Likes string + WeekWatchs string + MonthWatchs string + TodayWatchs string + } +} + +var FN struct { + Actress TagActress +} +var TN struct { + Actress string +} + +func init() { + FN.Actress.Id = "_id" + FN.Actress.UpdatedAt = "updatedAt" + FN.Actress.Works = "works" + FN.Actress.Status = "status" + FN.Actress.Rank = "rank" + FN.Actress.HotValue = "hotValue" + FN.Actress.Score = "score" + FN.Actress.Region = "region" + FN.Actress.BornDate = "bornDate" + FN.Actress.DebutDate = "debutDate" + FN.Actress.Office = "office" + FN.Actress.Meas.Gender = "meas.gender" + FN.Actress.Meas.Height = "meas.height" + FN.Actress.Meas.Weight = "meas.weight" + FN.Actress.Meas.Bust = "meas.bust" + FN.Actress.Meas.Waist = "meas.waist" + FN.Actress.Meas.Hip = "meas.hip" + FN.Actress.Meas.Cup = "meas.cup" + FN.Actress.Meas.Len = "meas.len" + FN.Actress.Meas.Dia = "meas.dia" + FN.Actress.Meas.Blood = "meas.blood" + FN.Actress.Desc = "desc" + FN.Actress.Cover = "cover" + FN.Actress.Avatar = "avatar" + FN.Actress.Alias = "alias" + FN.Actress.Slug = "slug" + FN.Actress.CreatedAt = "createdAt" + FN.Actress.UpdatedAt = "updatedAt" + FN.Actress.TranslationCategoryId = "translationCategoryId" + FN.Actress.Real.WatchTimes = "watchTimes" + FN.Actress.Real.ShowTimes = "showTimes" + FN.Actress.Real.Comments = "comments" + FN.Actress.Real.Likes = "likes" + FN.Actress.Real.WeekWatchs = "weekWatchs" + FN.Actress.Real.MonthWatchs = "monthWatchs" + FN.Actress.Real.TodayWatchs = "todayWatchs" + FN.Actress.SeoInfo = "seoInfo" + FN.Actress.IndexNow = "indexNow" + FN.Actress.RealUpdateAt = "realUpdateAt" + TN.Actress = "actress" +} diff --git a/pub/models/actress/type.go b/pub/models/actress/type.go new file mode 100644 index 0000000..0a0e036 --- /dev/null +++ b/pub/models/actress/type.go @@ -0,0 +1,172 @@ +// @title:女优管理 +package actress + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + "videoweb/pub/pubdef" +) + +type Meas struct { + Gender int `bson:"gender" json:"gender"` // 性别 0:女 1:男 2:其他 + Height int `bson:"height" json:"height"` // 身高 厘米 + Weight int `bson:"weight" json:"weight"` // 体重 公斤 + Bust int `bson:"bust" json:"bust"` // 胸围 英寸 + Waist int `bson:"waist" json:"waist"` // 腰围 英寸 + Hip int `bson:"hip" json:"hip"` // 臀围 英寸 + Cup int `bson:"cup" json:"cup"` // 罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I + Len int `bson:"len" json:"len"` // 长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145) + Dia int `bson:"dia" json:"dia"` // 直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45) + Blood string `json:"blood" bson:"blood"` // 血型 +} + +func calculateCup(bust int, waist int) int { + // 计算胸围和腰围的差值 + difference := bust - waist + // 根据差值大致判断罩杯 + switch { + case difference < 5: + return 1 // A杯 + case difference < 7: + return 2 // B杯 + case difference < 9: + return 3 // C杯 + case difference < 11: + return 4 // D杯 + case difference < 13: + return 5 // E杯 + case difference < 15: + return 6 // F杯 + case difference < 17: + return 7 // G杯 + case difference < 19: + return 8 // H杯 + case difference < 21: + return 9 // I杯 + default: + return 0 + } +} + +func (m *Meas) Parse(text string) { + text = strings.ReplaceAll(text, "长度", "尺寸") + text = strings.ReplaceAll(text, "長度", "尺寸") + text = strings.ReplaceAll(text, "厘米", "cm") + text = strings.ReplaceAll(text, "公分", "cm") + text = strings.ReplaceAll(text, "公斤", "kg") + text = strings.ReplaceAll(text, "體", "体") + text = strings.ReplaceAll(text, "圍", "围") + text = strings.ReplaceAll(text, "徑", "径") + // 将字母转换为对应的数字 + cupMap := map[string]int{ + "A": 1, "B": 2, "C": 3, "D": 4, + "E": 5, "F": 6, "G": 7, "H": 8, "I": 9, + "a": 1, "b": 2, "c": 3, "d": 4, + "e": 5, "f": 6, "g": 7, "h": 8, "i": 9, + } + // 身高提取cm + heightReg := regexp.MustCompile(`(?i)身高\s*(\d+)cm`) + if matches := heightReg.FindStringSubmatch(text); len(matches) > 1 { + m.Height, _ = strconv.Atoi(matches[1]) + } + // 体重提取kg + weightReg := regexp.MustCompile(`(?i)体重\s*(\d+)kg`) + if matches := weightReg.FindStringSubmatch(text); len(matches) > 1 { + m.Weight, _ = strconv.Atoi(matches[1]) + } + // 体重提取斤 + weightReg = regexp.MustCompile(`(?i)体重\s*(\d+)斤`) + if matches := weightReg.FindStringSubmatch(text); len(matches) > 1 { + m.Weight, _ = strconv.Atoi(matches[1]) + m.Weight = m.Weight / 2 + } + // 胸围提取 + bustReg := regexp.MustCompile(`胸围\s*(\d+)`) + if matches := bustReg.FindStringSubmatch(text); len(matches) > 1 { + m.Bust, _ = strconv.Atoi(matches[1]) + } + // 三围单个数值提取 + bustReg = regexp.MustCompile(`三围\s*(\d+)`) + if matches := bustReg.FindStringSubmatch(text); len(matches) > 1 { + m.Bust, _ = strconv.Atoi(matches[1]) + } + // 罩杯提取 + cupReg := regexp.MustCompile(`(?i)(\d+)([A-Z])`) + if matches := cupReg.FindStringSubmatch(text); len(matches) > 2 { + m.Cup = cupMap[matches[2]] + } + // 长度提取cm,支持浮点数 + lengthReg := regexp.MustCompile(`(?i)尺寸\s*(\d+\.?\d*)cm`) + if matches := lengthReg.FindStringSubmatch(text); len(matches) > 1 { + value, _ := strconv.ParseFloat(matches[1], 64) + m.Len = int(value * 10) // 转换为mm + } + // 直径提取cm,支持浮点数 + diaReg := regexp.MustCompile(`(?i)直径\s*(\d+\.?\d*)cm`) + if matches := diaReg.FindStringSubmatch(text); len(matches) > 1 { + value, _ := strconv.ParseFloat(matches[1], 64) + m.Len = int(value * 10) // 转换为mm + } + // 提取三围和罩杯 + threeSizeReg := regexp.MustCompile(`(?i)三围\s*(\d+)([A-Z])/(\d+)/(\d+)`) + if matches := threeSizeReg.FindStringSubmatch(text); len(matches) > 1 { + m.Bust, _ = strconv.Atoi(matches[1]) + m.Cup = cupMap[matches[2]] + m.Waist, _ = strconv.Atoi(matches[3]) + m.Hip, _ = strconv.Atoi(matches[4]) + } +} + +func (m *Meas) convertCupToString(cup int) string { + if cup <= 0 { + cup = calculateCup(m.Bust, m.Waist) + } + if cup >= 1 && cup <= 9 { + letter := rune('A' + cup - 1) + return string(letter) + } else { + if m.Gender == 1 { + return "" // 男性默认返回空字符串 + } + return "B" // 非男性默认返回B杯 + } +} + +func (m *Meas) Measurements() string { + //三围 格式为 35D-24-34 // 胸围(罩杯)-腰围-臀围 + return fmt.Sprintf("%d%s-%d-%d", m.Bust, m.convertCupToString(m.Cup), m.Waist, m.Hip) +} + +// @title:女优 +// @table:actress +type Actress struct { + Id int64 `json:"id" bson:"_id"` // Id + Alias string `bson:"alias" json:"alias"` // 别名 + Slug string `json:"slug" bson:"slug"` // slug + Avatar string `bson:"avatar" json:"avatar"` // 头像 + Cover string `bson:"cover" json:"cover"` // 封面大图 + Desc string `bson:"desc" json:"desc"` // 详细描述 + TranslationCategoryId int64 `bson:"translationCategoryId" json:"translationCategoryId"` // 关联导航分类id + Meas Meas `bson:"meas" json:"meas"` // 三围 + Office string `json:"office" bson:"office"` // 事务所 + DebutDate time.Time `bson:"debutDate" json:"debutDate" ` // 出道日期 + BornDate time.Time `bson:"bornDate" json:"bornDate"` // 出生日期 + Region string `json:"region" bson:"region"` // 地区/国家 + Score string `json:"score" bson:"score"` // 评分 + Rank int64 `json:"rank" bson:"rank"` // 排序 + HotValue int64 `json:"hotValue" bson:"hotValue"` // 热度值 + Status bool `json:"status" bson:"status"` // 是否启用 + Works int64 `json:"works" bson:"works"` // 作品数 + Real pubdef.Flow `json:"real" bson:"real"` // 真实流量 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + RealUpdateAt time.Time `json:"realUpdateAt" bson:"realUpdateAt"` // 播放相关数据更新时间 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + +} + +type ActressList []*Actress diff --git a/pub/models/analyticsdailystat/analytics.go b/pub/models/analyticsdailystat/analytics.go new file mode 100644 index 0000000..48a0a5a --- /dev/null +++ b/pub/models/analyticsdailystat/analytics.go @@ -0,0 +1,182 @@ +package analyticsdailystat + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.AnalyticsDailyInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.AnalyticsDailyInfo.AppId, Value: 1}, + {Key: FN.AnalyticsDailyInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsDailyInfo.SumDate, Value: 1}, + {Key: FN.AnalyticsDailyInfo.AppId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsDailyInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(AnalyticsDailyInfo:initAnalyticsDailyInfoIndex) Create AnalyticsDailyInfo Index failed! err:%+v:", err)) + } +} + +// InsertOneAnalyticsDailyInfo 插入一个评论 +func InsertOneAnalyticsDailyInfo(ctx context.Context, AnalyticsDailyInfo *AnalyticsDailyInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, AnalyticsDailyInfo); err != nil { + log.Warn("AnalyticsDailyInfo:InsertOneAnalyticsDailyInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneAnalyticsDailyInfo 删除一个评论 +func DelOneAnalyticsDailyInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("AnalyticsDailyInfo:DelOneAnalyticsDailyInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyAnalyticsDailyInfo 删除一个评论评论 +func DelManyAnalyticsDailyInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("AnalyticsDailyInfo:DelOneAnalyticsDailyInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneAnalyticsDailyInfo 查找一个评论 +func FindOneAnalyticsDailyInfo(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *AnalyticsDailyInfo, *srvdef.SrvErr) { + var AnalyticsDailyInfo AnalyticsDailyInfo + if err := db.FindOneEx( + ctx, &AnalyticsDailyInfo, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("AnalyticsDailyInfo:FindOneAnalyticsDailyInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &AnalyticsDailyInfo, nil +} + +// CountAnalyticsDailyInfos 统计评论视频评论数量 +func CountAnalyticsDailyInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("AnalyticsDailyInfo:CountAnalyticsDailyInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneAnalyticsDailyInfo 更新一个评论 +func UpdateOneAnalyticsDailyInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsDailyInfo:UpdateOneAnalyticsDailyInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("AnalyticsDailyInfo:UpdateOneAnalyticsDailyInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyAnalyticsDailyInfo 更新一批评论 +func UpdateManyAnalyticsDailyInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsDailyInfo:UpdateManyAnalyticsDailyInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("AnalyticsDailyInfo:UpdateManyAnalyticsDailyInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrAnalyticsDailyInfoNotExsit, "", "") + } + return nil +} + +// FindManyAnalyticsDailyInfo 查找一批评论 +func FindManyAnalyticsDailyInfo(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (AnalyticsDailyInfoList, *srvdef.SrvErr) { + data := make(AnalyticsDailyInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("AnalyticsDailyInfo:FindManyAnalyticsDailyInfo db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyAnalyticsDailyInfo 插入一批用户 +func InsertManyAnalyticsDailyInfo(ctx context.Context, histrory *AnalyticsDailyInfoList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("AnalyticsDailyInfo:InsertManyAnalyticsDailyInfo db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateAnalyticsDailyInfo 聚合查找一批数据 +func AggregateAnalyticsDailyInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsDailyInfo:AggregateAnalyticsDailyInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateAnalyticsDailyInfo 聚合查找一批数据 +func ObjectAggregateAnalyticsDailyInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsDailyInfo:AggregateAnalyticsDailyInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/analyticsdailystat/tag.go b/pub/models/analyticsdailystat/tag.go new file mode 100755 index 0000000..d5712a8 --- /dev/null +++ b/pub/models/analyticsdailystat/tag.go @@ -0,0 +1,42 @@ +package analyticsdailystat + +type TagAnalyticsDailyInfo struct { + AppId string + AppName string + CreatedAt string + Id string + LeadAmount string + LeadNewCount string + SumDate string + UpdatedAt string + YestodayActiveUsers string + YestodayNewUsers string + YestodayPV string + YestodaySessions string + YestodayUV string +} + +var FN struct { + AnalyticsDailyInfo TagAnalyticsDailyInfo +} + +var TN struct { + AnalyticsDailyInfo string +} + +func init() { + FN.AnalyticsDailyInfo.AppId = "appId" + FN.AnalyticsDailyInfo.AppName = "appName" + FN.AnalyticsDailyInfo.CreatedAt = "createdAt" + FN.AnalyticsDailyInfo.Id = "_id" + FN.AnalyticsDailyInfo.LeadAmount = "leadAmount" + FN.AnalyticsDailyInfo.LeadNewCount = "leadNewCount" + FN.AnalyticsDailyInfo.SumDate = "sumDate" + FN.AnalyticsDailyInfo.UpdatedAt = "updatedAt" + FN.AnalyticsDailyInfo.YestodayActiveUsers = "yestodayActiveUsers" + FN.AnalyticsDailyInfo.YestodayNewUsers = "yestodayNewUsers" + FN.AnalyticsDailyInfo.YestodayPV = "yestodayPV" + FN.AnalyticsDailyInfo.YestodaySessions = "yestodaySessions" + FN.AnalyticsDailyInfo.YestodayUV = "yestodayUV" + TN.AnalyticsDailyInfo = "analytics_daily_info" +} \ No newline at end of file diff --git a/pub/models/analyticsdailystat/type.go b/pub/models/analyticsdailystat/type.go new file mode 100644 index 0000000..9fa54f7 --- /dev/null +++ b/pub/models/analyticsdailystat/type.go @@ -0,0 +1,27 @@ +package analyticsdailystat + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 数据统计 +// @table:analytics_daily_info +type AnalyticsDailyInfo struct { + Id primitive.ObjectID `bson:"_id,omitempty"` + SumDate time.Time `json:"sumDate" bson:"sumDate"` // 时间半小时 + AppId int64 `json:"appId" bson:"appId"` // appId + AppName string `json:"appName" bson:"appName"` // APP名称 + YestodayPV int64 `json:"yestodayPV" bson:"yestodayPV"` // 页面浏览量 (PV) + YestodayUV int64 `json:"yestodayUV" bson:"yestodayUV"` // 独立用户数 (UV) + YestodayActiveUsers int64 `json:"yestodayActiveUsers" bson:"yestodayActiveUsers"` // 活跃用户数 + YestodaySessions int64 `json:"yestodaySessions" bson:"yestodaySessions"` // 会话数 + YestodayNewUsers int64 `json:"yestodayNewUsers" bson:"yestodayNewUsers"` // 新增数量 + LeadNewCount float64 `json:"leadNewCount" bson:"leadNewCount"` // 日导量新增 + LeadAmount float64 `json:"leadAmount" bson:"leadAmount"` // 日导量充值 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type AnalyticsDailyInfoList []*AnalyticsDailyInfo \ No newline at end of file diff --git a/pub/models/analyticsstat/analytics.go b/pub/models/analyticsstat/analytics.go new file mode 100644 index 0000000..cf82160 --- /dev/null +++ b/pub/models/analyticsstat/analytics.go @@ -0,0 +1,182 @@ +package analyticsstat + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.AnalyticsInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.AnalyticsInfo.AppId, Value: 1}, + {Key: FN.AnalyticsInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsInfo.SumDate, Value: 1}, + {Key: FN.AnalyticsInfo.AppId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.AnalyticsInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(AnalyticsInfo:initAnalyticsInfoIndex) Create AnalyticsInfo Index failed! err:%+v:", err)) + } +} + +// InsertOneAnalyticsInfo 插入一个评论 +func InsertOneAnalyticsInfo(ctx context.Context, AnalyticsInfo *AnalyticsInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, AnalyticsInfo); err != nil { + log.Warn("AnalyticsInfo:InsertOneAnalyticsInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneAnalyticsInfo 删除一个评论 +func DelOneAnalyticsInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("AnalyticsInfo:DelOneAnalyticsInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyAnalyticsInfo 删除一个评论评论 +func DelManyAnalyticsInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("AnalyticsInfo:DelOneAnalyticsInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneAnalyticsInfo 查找一个评论 +func FindOneAnalyticsInfo(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *AnalyticsInfo, *srvdef.SrvErr) { + var AnalyticsInfo AnalyticsInfo + if err := db.FindOneEx( + ctx, &AnalyticsInfo, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("AnalyticsInfo:FindOneAnalyticsInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &AnalyticsInfo, nil +} + +// CountAnalyticsInfos 统计评论视频评论数量 +func CountAnalyticsInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("AnalyticsInfo:CountAnalyticsInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneAnalyticsInfo 更新一个评论 +func UpdateOneAnalyticsInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsInfo:UpdateOneAnalyticsInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("AnalyticsInfo:UpdateOneAnalyticsInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyAnalyticsInfo 更新一批评论 +func UpdateManyAnalyticsInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("AnalyticsInfo:UpdateManyAnalyticsInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("AnalyticsInfo:UpdateManyAnalyticsInfo db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrAnalyticsInfoNotExsit, "", "") + } + return nil +} + +// FindManyAnalyticsInfo 查找一批评论 +func FindManyAnalyticsInfo(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (AnalyticsInfoList, *srvdef.SrvErr) { + data := make(AnalyticsInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("AnalyticsInfo:FindManyAnalyticsInfo db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyAnalyticsInfo 插入一批用户 +func InsertManyAnalyticsInfo(ctx context.Context, histrory *AnalyticsInfoList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("AnalyticsInfo:InsertManyAnalyticsInfo db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateAnalyticsInfo 聚合查找一批数据 +func AggregateAnalyticsInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsInfo:AggregateAnalyticsInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateAnalyticsInfo 聚合查找一批数据 +func ObjectAggregateAnalyticsInfo(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("AnalyticsInfo:AggregateAnalyticsInfo db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/analyticsstat/tag.go b/pub/models/analyticsstat/tag.go new file mode 100755 index 0000000..d386cda --- /dev/null +++ b/pub/models/analyticsstat/tag.go @@ -0,0 +1,42 @@ +package analyticsstat + +type TagAnalyticsInfo struct { + ActiveUsers string + AppId string + AppName string + CreatedAt string + DaySumDate string + Id string + SumDate string + UpdatedAt string + YestodayActiveUsers string + YestodayNewUsers string + YestodayPV string + YestodaySessions string + YestodayUV string +} + +var FN struct { + AnalyticsInfo TagAnalyticsInfo +} + +var TN struct { + AnalyticsInfo string +} + +func init() { + FN.AnalyticsInfo.ActiveUsers = "activeUsers" + FN.AnalyticsInfo.AppId = "appId" + FN.AnalyticsInfo.AppName = "appName" + FN.AnalyticsInfo.CreatedAt = "createdAt" + FN.AnalyticsInfo.DaySumDate = "daySumDate" + FN.AnalyticsInfo.Id = "_id" + FN.AnalyticsInfo.SumDate = "sumDate" + FN.AnalyticsInfo.UpdatedAt = "updatedAt" + FN.AnalyticsInfo.YestodayActiveUsers = "yestodayActiveUsers" + FN.AnalyticsInfo.YestodayNewUsers = "yestodayNewUsers" + FN.AnalyticsInfo.YestodayPV = "yestodayPV" + FN.AnalyticsInfo.YestodaySessions = "yestodaySessions" + FN.AnalyticsInfo.YestodayUV = "yestodayUV" + TN.AnalyticsInfo = "analytics_info" +} \ No newline at end of file diff --git a/pub/models/analyticsstat/type.go b/pub/models/analyticsstat/type.go new file mode 100644 index 0000000..b60a115 --- /dev/null +++ b/pub/models/analyticsstat/type.go @@ -0,0 +1,27 @@ +package analyticsstat + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 数据统计 +// @table:analytics_info +type AnalyticsInfo struct { + Id primitive.ObjectID `bson:"_id,omitempty"` + SumDate time.Time `json:"sumDate" bson:"sumDate"` // 时间半小时 + DaySumDate time.Time `json:"daySumDate" bson:"daySumDate"` // 当天 + AppId int64 `json:"appId" bson:"appId"` // appId + AppName string `json:"appName" bson:"appName"` // APP名称 + ActiveUsers int64 `json:"activeUsers" bson:"activeUsers"` // 最近半小时活跃数 + YestodayPV int64 `json:"yestodayPV" bson:"yestodayPV"` // 页面浏览量 (PV) + YestodayUV int64 `json:"yestodayUV" bson:"yestodayUV"` // 独立用户数 (UV) + YestodayActiveUsers int64 `json:"yestodayActiveUsers" bson:"yestodayActiveUsers"` // 活跃用户数 + YestodaySessions int64 `json:"yestodaySessions" bson:"yestodaySessions"` // 会话数 + YestodayNewUsers int64 `json:"yestodayNewUsers" bson:"yestodayNewUsers"` // 新增数量 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type AnalyticsInfoList []*AnalyticsInfo \ No newline at end of file diff --git a/pub/models/announcementmod/announcement.go b/pub/models/announcementmod/announcement.go new file mode 100644 index 0000000..6de89b0 --- /dev/null +++ b/pub/models/announcementmod/announcement.go @@ -0,0 +1,102 @@ +package announcementmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.Announcement) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Announcement.Status, Value: 1}, + {Key: FN.Announcement.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Announcement.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(announcementmod:Init) Create Announcement Index failed! err:%+v:", err)) + } +} + +func InsertOneAnnouncement(ctx context.Context, doc *Announcement) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, doc); err != nil { + log.Warn("announcementmod:InsertOneAnnouncement db.InsertOne failed", + log.E(err), log.Any("doc", doc)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +func DelOneAnnouncement(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("announcementmod:DelOneAnnouncement db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +func FindOneAnnouncement(ctx context.Context, filter bson.M) (*Announcement, *srvdef.SrvErr) { + var doc Announcement + if err := db.FindOneEx(ctx, &doc, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("announcementmod:FindOneAnnouncement db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrAnnouncementNotExsit, "", "") + } + return &doc, nil +} + +func FindManyAnnouncement(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (AnnouncementList, *srvdef.SrvErr) { + data := make(AnnouncementList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("announcementmod:FindManyAnnouncement db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +func CountAnnouncement(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("announcementmod:CountAnnouncement db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +func UpdateOneAnnouncement(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("announcementmod:UpdateOneAnnouncement db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("announcementmod:UpdateOneAnnouncement not matched", + log.Any("filter", filter)) + return srvdef.New(srvdef.ErrAnnouncementNotExsit, "", "") + } + return nil +} diff --git a/pub/models/announcementmod/tag.go b/pub/models/announcementmod/tag.go new file mode 100644 index 0000000..bb684f5 --- /dev/null +++ b/pub/models/announcementmod/tag.go @@ -0,0 +1,30 @@ +package announcementmod + +type TagAnnouncement struct { + Id string + Title string + Content string + Sort string + Status string + CreatedAt string + UpdatedAt string +} + +var FN struct { + Announcement TagAnnouncement +} + +var TN struct { + Announcement string +} + +func init() { + FN.Announcement.Id = "_id" + FN.Announcement.Title = "title" + FN.Announcement.Content = "content" + FN.Announcement.Sort = "sort" + FN.Announcement.Status = "status" + FN.Announcement.CreatedAt = "createdAt" + FN.Announcement.UpdatedAt = "updatedAt" + TN.Announcement = "announcement" +} diff --git a/pub/models/announcementmod/type.go b/pub/models/announcementmod/type.go new file mode 100644 index 0000000..3fb6d7a --- /dev/null +++ b/pub/models/announcementmod/type.go @@ -0,0 +1,17 @@ +package announcementmod + +import "time" + +// Announcement 公告 +// @table:announcement +type Announcement struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 标题 + Content string `json:"content" bson:"content"` // 内容 + Sort int64 `json:"sort" bson:"sort"` // 排序,越大越靠前 + Status bool `json:"status" bson:"status"` // 是否启用 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +type AnnouncementList []*Announcement diff --git a/pub/models/cfg/appkey.go b/pub/models/cfg/appkey.go new file mode 100644 index 0000000..31f6f3a --- /dev/null +++ b/pub/models/cfg/appkey.go @@ -0,0 +1,42 @@ +package cfg + +import ( + "context" + "strings" + "sync" + + "go.mongodb.org/mongo-driver/bson" +) + +var appKeyCache struct { + sync.RWMutex + value string + ready bool +} + +// CacheAppKey 把系统配置里的 appKey 放进内存,供媒资写接口读取。 +func CacheAppKey(appKey string) { + appKeyCache.Lock() + appKeyCache.value = strings.TrimSpace(appKey) + appKeyCache.ready = true + appKeyCache.Unlock() +} + +// CachedAppKey 返回已缓存的 appKey。缓存未建立时读取 sys_cfg 并写入缓存。 +func CachedAppKey(ctx context.Context) string { + appKeyCache.RLock() + if appKeyCache.ready { + value := appKeyCache.value + appKeyCache.RUnlock() + return value + } + appKeyCache.RUnlock() + + sysCfg, serr := FindOneSysCfg(ctx, bson.M{FN.SysCfg.Id: 1}) + if serr != nil || sysCfg == nil { + return "" + } + value := strings.TrimSpace(sysCfg.AppKey) + CacheAppKey(value) + return value +} diff --git a/pub/models/cfg/dbhelper.go b/pub/models/cfg/dbhelper.go new file mode 100644 index 0000000..6e8c6d5 --- /dev/null +++ b/pub/models/cfg/dbhelper.go @@ -0,0 +1,9 @@ +package cfg + +import ( + "videoweb/pub/mongos" +) + +var ( + sysCfgColl *mongos.Collection +) diff --git a/pub/models/cfg/dbop.go b/pub/models/cfg/dbop.go new file mode 100644 index 0000000..04ab8b5 --- /dev/null +++ b/pub/models/cfg/dbop.go @@ -0,0 +1,55 @@ +package cfg + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + sysCfgColl = mongos.Shop.Coll(TN.SysCfg) +} + +// FindOneSysCfg 查找一个系统配置 +func FindOneSysCfg(ctx context.Context, filter bson.M) ( + *SysCfg, *srvdef.SrvErr) { + var sysCfg SysCfg + if err := sysCfgColl.FindOneEx( + ctx, &sysCfg, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("cfg:FindOneSysCfg db.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &sysCfg, nil +} + +// UpdateOneSysCfg 更新一个系统配置 +func UpdateOneSysCfg(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := sysCfgColl.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("cfg:UpdateOneSysCfg db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +func DeleteOneSysCfg(ctx context.Context, filter bson.M) *srvdef.SrvErr { + _, err := sysCfgColl.DeleteOne(ctx, filter) + if err != nil { + log.Warn("cfg:DeleteOneSysCfg db.DeleteOne failed", log.Any("filter", filter), log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/cfg/tag.go b/pub/models/cfg/tag.go new file mode 100755 index 0000000..8d483ed --- /dev/null +++ b/pub/models/cfg/tag.go @@ -0,0 +1,88 @@ +package cfg + +type TagSysCfg struct { + AppAddr string + DownloadVideoUrl string + ForeverUrlAddr string + GitAddr string + GmailAddr string + Id string + ImgAppCdn string + ImgWebCdn string + MediaAppCdn string + MediaWebCdn string + NewUrlAddr string + NewUrlAddr1 string + NewUrlAddr2 string + QQ string + Telegram string + TelegramAdd string + TgGroup string + TranslationAddr string + TwitterAddr string + WatermarkImage string + WatermarkMedia string + WatermarkMediaTop string + WatermarkMediaTopRight string + WatermarkMediaBottomLeft string + WeiChart string + DHNewUrlAddr1 string + DHNewUrlAddr2 string + DHNewUrlAddr3 string + DHForeverUrlAddr string + UnencryptedCdn string + SitemapDefaultImages string + Version string + ChannelCode string + EnableH265 string + M3U8DayRateLimitMax string + AppKey string +} + +var FN struct { + SysCfg TagSysCfg +} + +var TN struct { + SysCfg string +} + +func init() { + FN.SysCfg.AppAddr = "appAddr" + FN.SysCfg.DownloadVideoUrl = "downloadVideoUrl" + FN.SysCfg.ForeverUrlAddr = "foreverUrlAddr" + FN.SysCfg.GitAddr = "gitAddr" + FN.SysCfg.GmailAddr = "gmailAddr" + FN.SysCfg.Id = "_id" + FN.SysCfg.ImgAppCdn = "imgAppCdn" + FN.SysCfg.ImgWebCdn = "imgWebCdn" + FN.SysCfg.MediaAppCdn = "mediaAppCdn" + FN.SysCfg.MediaWebCdn = "mediaWebCdn" + FN.SysCfg.NewUrlAddr = "newUrlAddr" + FN.SysCfg.NewUrlAddr1 = "newUrlAddr1" + FN.SysCfg.NewUrlAddr2 = "newUrlAddr2" + FN.SysCfg.QQ = "qq" + FN.SysCfg.Telegram = "telegram" + FN.SysCfg.TelegramAdd = "telegramAdd" + FN.SysCfg.TgGroup = "tgGroup" + FN.SysCfg.TranslationAddr = "translationAddr" + FN.SysCfg.TwitterAddr = "twitterAddr" + FN.SysCfg.WatermarkImage = "watermarkImage" + FN.SysCfg.WatermarkMedia = "watermarkMedia" + FN.SysCfg.WatermarkMediaTop = "watermarkMediaTop" + FN.SysCfg.WatermarkMediaTopRight = "watermarkMediaTopRight" + FN.SysCfg.WatermarkMediaBottomLeft = "watermarkMediaBottomLeft" + FN.SysCfg.WeiChart = "weiChart" + FN.SysCfg.DHNewUrlAddr1 = "dHNewUrlAddr1" + FN.SysCfg.DHNewUrlAddr2 = "dHNewUrlAddr2" + FN.SysCfg.DHNewUrlAddr3 = "dHNewUrlAddr3" + FN.SysCfg.DHForeverUrlAddr = "dHForeverUrlAddr" + FN.SysCfg.UnencryptedCdn = "unencryptedCdn" + FN.SysCfg.SitemapDefaultImages = "sitemapDefaultImages" + FN.SysCfg.Version = "version" + FN.SysCfg.ChannelCode = "channelCode" + FN.SysCfg.EnableH265 = "enableH265" + FN.SysCfg.M3U8DayRateLimitMax = "m3u8DayRateLimitMax" + FN.SysCfg.AppKey = "appKey" + TN.SysCfg = "sys_cfg" +} diff --git a/pub/models/cfg/type.go b/pub/models/cfg/type.go new file mode 100644 index 0000000..5187530 --- /dev/null +++ b/pub/models/cfg/type.go @@ -0,0 +1,73 @@ +package cfg + +import "strings" + +// @table:sys_cfg +type SysCfg struct { + Id int64 `json:"id" bson:"_id"` + Version int64 `json:"version" bson:"version"` // 版本号,用于更新乐观锁 + UnencryptedCdn string `json:"unencryptedCdn" bson:"unencryptedCdn"` // 不加密资源使用的cdn + SitemapDefaultImages []string `json:"sitemapDefaultImages" bson:"sitemapDefaultImages"` // Sitemap里面默认封面图 + ImgAppCdn string `json:"imgAppCdn" bson:"imgAppCdn"` // 图片CDN(app使用) + ImgAppUsCdn string `json:"imgAppUsCdn" bson:"imgAppUsCdn"` // 图片CDN(海外app使用) + ImgWebCdn string `json:"imgWebCdn" bson:"imgWebCdn"` // 图片CDN(web使用) + MediaWebCdn string `json:"mediaWebCdn" bson:"mediaWebCdn"` // 视频CDN(web使用) + MediaAppCdn string `json:"mediaAppCdn" bson:"mediaAppCdn"` // 视频CDN(app使用) + MediaAppUsCdn string `json:"mediaAppUsCdn" bson:"mediaAppUsCdn"` // 视频CDN(海外app使用) + WatermarkImage string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件 + WatermarkMedia string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件(底部右边) + WatermarkMediaTop string `json:"watermarkMediaTop" bson:"watermarkMediaTop"` // 视频水印文件(顶部左边) + WatermarkMediaTopRight string `json:"watermarkMediaTopRight" bson:"watermarkMediaTopRight"` // 视频水印文件(顶部右边) + WatermarkMediaBottomLeft string `json:"watermarkMediaBottomLeft" bson:"watermarkMediaBottomLeft"` // 视频水印文件(底部左边) + Mp4Addr string `json:"mp4Addr" bson:"mp4Addr"` // mp4地址(web使用) + PdfAddr string `json:"pdfAddr" bson:"pdfAddr"` // 回家的路PDF + //网站管理 + NewUrlAddr string `json:"newUrlAddr" bson:"newUrlAddr"` //最新地址 + NewUrlAddr1 string `json:"newUrlAddr1" bson:"newUrlAddr1"` //最新地址(备用地址1) + NewUrlAddr2 string `json:"newUrlAddr2" bson:"newUrlAddr2"` //最新地址(备用地址2) + ForeverUrlAddr string `json:"foreverUrlAddr" bson:"foreverUrlAddr"` //永久地址 + GmailAddr string `json:"gmailAddr" bson:"gmailAddr"` //邮箱地址 + AppAddr string `json:"appAddr" bson:"appAddr"` //APP下载地址(落地页) + DownloadVideoUrl string `json:"downloadVideoUrl" bson:"downloadVideoUrl"` // 下载视频URL + WeiChart string `json:"weiChart" bson:"weiChart"` //微信群 + QQ string `json:"qq" bson:"qq"` //qq群 + TgGroup string `json:"tgGroup" bson:"tgGroup"` //tg群 + PotaoGroup string `json:"potaoGroup" bson:"potaoGroup"` //土豆群 + GitAddr string `json:"gitAddr" bson:"gitAddr"` //git地址 + TwitterAddr string `json:"twitterAddr" bson:"twitterAddr"` //推特地址 + Telegram string `json:"telegram" bson:"telegram"` //商务tg + TelegramAdd string `json:"telegramAdd" bson:"telegramAdd"` //加入我们tg + TranslationAddr string `json:"translationAddr" bson:"translationAddr"` //应用导航 + //SEO + WebTitle string `json:"webTitle" bson:"webTitle"` // 网站SEO标题 + WebKeyworld []string `json:"webKeyworld" bson:"webKeyworld"` // 网站SEO关键字 + WebDescription string `json:"webDescription" bson:"webDescription"` // 网站SEO描述 + WebSiteName string `json:"webSiteName" bson:"webSiteName"` // 网站SEO网站名称 + WebAddr string `json:"webAddr" bson:"webAddr"` // 网站SEO网站地址 + DHNewUrlAddr1 string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1"` // 导航页-线路1 + DHNewUrlAddr2 string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2"` // 导航页-线路2 + DHNewUrlAddr3 string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3"` // 导航页-线路3 + FreeVPN string `json:"freeVPN" bson:"freeVPN"` // 免费vpn链接 + UseAddress []string `json:"useAddress" bson:"useAddress"` // 所有使用中的域名 + DHTitle string `json:"dhTitle" bson:"dhTitle"` // 导航SEO标题 + DHKeyworld []string `json:"dhKeyworld" bson:"dhKeyworld"` // 导航SEO关键字 + DHDescription string `json:"dhDescription" bson:"dhDescription"` // 导航SEO描述 + DHSiteName string `json:"dhSiteName" bson:"dhSiteName"` // 导航SEO网站名称 + ChannelCode string `json:"channelCode" bson:"channelCode"` // 渠道码 + EnableH265 bool `json:"enableH265" bson:"enableH265"` // 是否允许客户端使用 H265 地址播放 + M3U8DayRateLimitMax int64 `json:"m3u8DayRateLimitMax" bson:"m3u8DayRateLimitMax"` // 每 IP 每天最多不同正片数,<=0 使用默认 80 + AppKey string `json:"appKey" bson:"appKey"` // 媒资库 appKey,调用 media 写接口时传入 +} + +// SEOBaseURL 返回与页面 canonical 一致的 SEO 主域。 +// 永久域名未配置时回退到 WebAddr,避免生成空地址。 +func (c *SysCfg) SEOBaseURL() string { + if c == nil { + return "" + } + base := strings.TrimSpace(c.ForeverUrlAddr) + if base == "" { + base = strings.TrimSpace(c.WebAddr) + } + return strings.TrimRight(base, "/") +} diff --git a/pub/models/commentlikemod/comment_like.go b/pub/models/commentlikemod/comment_like.go new file mode 100644 index 0000000..a28664f --- /dev/null +++ b/pub/models/commentlikemod/comment_like.go @@ -0,0 +1,87 @@ +package commentlikemod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const collectionName = "comment_like" + +const CollectionName = collectionName + +type CommentLike struct { + UserId int64 `bson:"userId"` + CommentId int64 `bson:"commentId"` + CreatedAt time.Time `bson:"createdAt"` +} + +var coll *mongos.Collection + +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(collectionName) + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: "userId", Value: 1}, {Key: "commentId", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "commentId", Value: 1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("commentlikemod.Init CreateIndex failed: %v", err)) + } +} + +// Add 新增点赞关系,重复点赞不改变状态 +func Add(ctx context.Context, userId, commentId int64) (bool, *srvdef.SrvErr) { + _, err := coll.InsertOne(ctx, &CommentLike{UserId: userId, CommentId: commentId, CreatedAt: time.Now()}) + if mongo.IsDuplicateKeyError(err) { + return false, nil + } + if err != nil { + log.Warn("commentlikemod.Add", log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// Remove 删除点赞关系,重复取消不改变状态 +func Remove(ctx context.Context, userId, commentId int64) (bool, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, bson.M{"userId": userId, "commentId": commentId}) + if err != nil { + log.Warn("commentlikemod.Remove", log.E(err)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count > 0, nil +} + +// FindByUserAndComments 批量查询当前用户的评论点赞关系 +func FindByUserAndComments(ctx context.Context, userId int64, commentIds []int64) ([]CommentLike, *srvdef.SrvErr) { + result := make([]CommentLike, 0) + if userId <= 0 || len(commentIds) == 0 { + return result, nil + } + err := coll.Find(ctx, &result, bson.M{"userId": userId, "commentId": bson.M{"$in": commentIds}}) + if err != nil { + log.Warn("commentlikemod.FindByUserAndComments", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return result, nil +} + +// DeleteByCommentIds 清理已删除评论的点赞关系 +func DeleteByCommentIds(ctx context.Context, commentIds []int64) *srvdef.SrvErr { + if len(commentIds) == 0 { + return nil + } + _, err := coll.DeleteMany(ctx, bson.M{"commentId": bson.M{"$in": commentIds}}) + if err != nil { + log.Warn("commentlikemod.DeleteByCommentIds", log.E(err)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return nil +} diff --git a/pub/models/commentmod/comment.go b/pub/models/commentmod/comment.go new file mode 100644 index 0000000..abfd80a --- /dev/null +++ b/pub/models/commentmod/comment.go @@ -0,0 +1,238 @@ +package commentmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.Comment) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.Id, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.Level, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + {Key: FN.Comment.ObjectType, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.Level, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ObjectId, Value: 1}, + {Key: FN.Comment.ObjectType, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.Level, Value: 1}, + {Key: FN.Comment.CountLike, Value: -1}, + {Key: FN.Comment.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ParentsId, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.TopRank, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(comment:initCommentIndex) Create Comment Index failed! err:%+v:", err)) + } +} + +// InsertOneComment 插入一个评论 +func InsertOneComment(ctx context.Context, Comment *CommentModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, Comment); err != nil { + log.Warn("comment:InsertOneComment db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneComment 删除一个评论 +func DelOneComment(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("comment:DelOneComment db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyComment 删除一个评论评论 +func DelManyComment(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("comment:DelOneComment db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneComment 查找一个评论 +func FindOneComment(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *CommentModel, *srvdef.SrvErr) { + var Comment CommentModel + if err := db.FindOneEx( + ctx, &Comment, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("comment:FindOneComment db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &Comment, nil +} + +// CountComments 统计评论视频评论数量 +func CountComments(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("comment:CountComments db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneComment 更新一个评论 +func UpdateOneComment(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("comment:UpdateOneComment db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("comment:UpdateOneComment db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// ChangeLikeCount 原子更新已审核评论的点赞数并返回最新值 +func ChangeLikeCount(ctx context.Context, commentId, delta int64) (int64, *srvdef.SrvErr) { + filter := bson.M{FN.Comment.Id: commentId, FN.Comment.ReviewStatus: 1} + if delta < 0 { + filter[FN.Comment.CountLike] = bson.M{"$gt": 0} + } + var comment CommentModel + err := db.FindOneAndUpdate(ctx, &comment, filter, + bson.M{"$inc": bson.M{FN.Comment.CountLike: delta}}, + options.FindOneAndUpdate().SetReturnDocument(options.After)) + if err == mongo.ErrNoDocuments { + return 0, srvdef.New(srvdef.ErrNoData, "", "") + } + if err != nil { + log.Warn("commentmod.ChangeLikeCount", log.E(err)) + return 0, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return comment.CountLike, nil +} + +// UpdateManyComment 更新一批评论 +func UpdateManyComment(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("comment:UpdateManyComment db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("comment:UpdateManyComment db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrCommentNotExsit, "", "") + } + return nil +} + +// FindManyComment 查找一批评论 +func FindManyComment(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (CommentModelList, *srvdef.SrvErr) { + data := make(CommentModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("comment:FindManyComment db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyComment 插入一批用户 +func InsertManyComment(ctx context.Context, histrory *CommentModelList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("comment:InsertManyComment db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateComment 聚合查找一批数据 +func AggregateComment(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("comment:AggregateComment db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateComment 聚合查找一批数据 +func ObjectAggregateComment(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("comment:AggregateComment db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/commentmod/tag.go b/pub/models/commentmod/tag.go new file mode 100755 index 0000000..2c5d1db --- /dev/null +++ b/pub/models/commentmod/tag.go @@ -0,0 +1,52 @@ +package commentmod + +type TagComment struct { + CreatedAt string + Id string + Level string + ObjectId string + ParentsId string + ReplyUserName string + Text string + TopRank string + UpdatedAt string + UserName string + UserId string + Avatar string + ReviewStatus string + CommentCount string + CountLike string + Ip string + ObjectName string + ObjectType string +} + +var FN struct { + Comment TagComment +} + +var TN struct { + Comment string +} + +func init() { + FN.Comment.CreatedAt = "createdAt" + FN.Comment.Id = "_id" + FN.Comment.Level = "level" + FN.Comment.ObjectId = "objectId" + FN.Comment.ParentsId = "parentsId" + FN.Comment.ReplyUserName = "replyUserName" + FN.Comment.Text = "text" + FN.Comment.TopRank = "topRank" + FN.Comment.UpdatedAt = "updatedAt" + FN.Comment.UserName = "userName" + FN.Comment.ReviewStatus = "reviewStatus" + FN.Comment.Ip = "ip" + FN.Comment.CommentCount = "commentCount" + FN.Comment.CountLike = "countLike" + FN.Comment.ObjectName = "objectName" + FN.Comment.ObjectType = "objectType" + FN.Comment.UserId = "userId" + FN.Comment.Avatar = "avatar" + TN.Comment = "_comment" +} diff --git a/pub/models/commentmod/type.go b/pub/models/commentmod/type.go new file mode 100644 index 0000000..77c3f81 --- /dev/null +++ b/pub/models/commentmod/type.go @@ -0,0 +1,31 @@ +package commentmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// 评论管理 +// @table:_comment +type CommentModel struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID + Ip string `json:"ip" bson:"ip"` // 评论IP + ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectType pubdef.CommentObjectType `json:"objectType" bson:"objectType"` // 对象类型 0=视频 4=短剧 + ObjectName string `json:"objectName" bson:"objectName"` // 对象类型名称 视频/短剧 + UserName string `json:"userName" bson:"userName"` // 用户名称 + UserId int64 `json:"userId" bson:"userId"` // 用户ID + Avatar string `json:"avatar" bson:"avatar"` // 用户头像 + Level int64 `json:"level" bson:"level"` // 评论级别 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + ReplyUserName string `json:"replyUserName" bson:"replyUserName"` // 回复名称 + Text string `json:"text" bson:"text"` // 评论内容 + CommentCount int64 `json:"commentCount" bson:"commentCount"` // 该评论回复数量 + CountLike int64 `json:"countLike" bson:"countLike"` // 点赞数 + ReviewStatus int `json:"reviewStatus" bson:"reviewStatus"` // 审核状态 0 未审核 1审核成功 2 审核失败 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // 创建时间 + TopRank int64 `json:"topRank" bson:"topRank"` // 置顶排序 +} + +type CommentModelList []*CommentModel diff --git a/pub/models/contactusmod/contactus.go b/pub/models/contactusmod/contactus.go new file mode 100644 index 0000000..57bdad0 --- /dev/null +++ b/pub/models/contactusmod/contactus.go @@ -0,0 +1,189 @@ +package contactusmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.ContactUsModel) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.ContactUsModel.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUsModel.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUsModel.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUsModel.Email, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUsModel.Ip, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(ContactUsModel:initContactUsModelIndex) Create ContactUsModel Index failed! err:%+v:", err)) + } +} + +// InsertOneContactUsModel 插入一个评论 +func InsertOneContactUsModel(ctx context.Context, ContactUsModel *ContactUsModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, ContactUsModel); err != nil { + log.Warn("ContactUsModel:InsertOneContactUsModel db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneContactUsModel 删除一个评论 +func DelOneContactUsModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("ContactUsModel:DelOneContactUsModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyContactUsModel 删除一个评论评论 +func DelManyContactUsModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("ContactUsModel:DelOneContactUsModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneContactUsModel 查找一个评论 +func FindOneContactUsModel(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *ContactUsModel, *srvdef.SrvErr) { + var ContactUsModel ContactUsModel + if err := db.FindOneEx( + ctx, &ContactUsModel, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("ContactUsModel:FindOneContactUsModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &ContactUsModel, nil +} + +// CountContactUsModels 统计评论视频评论数量 +func CountContactUsModels(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("ContactUsModel:CountContactUsModels db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneContactUsModel 更新一个评论 +func UpdateOneContactUsModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("ContactUsModel:UpdateOneContactUsModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("ContactUsModel:UpdateOneContactUsModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyContactUsModel 更新一批评论 +func UpdateManyContactUsModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("ContactUsModel:UpdateManyContactUsModel db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("ContactUsModel:UpdateManyContactUsModel db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrContactUsModelNotExsit, "", "") + } + return nil +} + +// FindManyContactUsModel 查找一批评论 +func FindManyContactUsModel(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (ContactUsModelList, *srvdef.SrvErr) { + data := make(ContactUsModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("ContactUsModel:FindManyContactUsModel db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// InsertManyContactUsModel 插入一批用户 +func InsertManyContactUsModel(ctx context.Context, histrory *ContactUsModelList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, histrory); err != nil { + log.Warn("ContactUsModel:InsertManyContactUsModel db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateContactUsModel 聚合查找一批数据 +func AggregateContactUsModel(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("ContactUsModel:AggregateContactUsModel db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateContactUsModel 聚合查找一批数据 +func ObjectAggregateContactUsModel(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("ContactUsModel:AggregateContactUsModel db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/contactusmod/tag.go b/pub/models/contactusmod/tag.go new file mode 100755 index 0000000..3b15090 --- /dev/null +++ b/pub/models/contactusmod/tag.go @@ -0,0 +1,34 @@ +package contactusmod + +type TagContactUsModel struct { + Id string + Ip string + Name string + Email string + CategoryName string + Content string + Status string + UpdatedAt string + CreatedAt string +} + +var FN struct { + ContactUsModel TagContactUsModel +} + +var TN struct { + ContactUsModel string +} + +func init() { + FN.ContactUsModel.Id = "_id" + FN.ContactUsModel.Ip = "ip" + FN.ContactUsModel.Name = "name" + FN.ContactUsModel.Email = "email" + FN.ContactUsModel.CategoryName = "categoryName" + FN.ContactUsModel.Content = "content" + FN.ContactUsModel.Status = "status" + FN.ContactUsModel.UpdatedAt = "updatedAt" + FN.ContactUsModel.CreatedAt = "createdAt" + TN.ContactUsModel = "contact_us" +} diff --git a/pub/models/contactusmod/type.go b/pub/models/contactusmod/type.go new file mode 100644 index 0000000..bc1a4df --- /dev/null +++ b/pub/models/contactusmod/type.go @@ -0,0 +1,23 @@ +package contactusmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// ContactUsModel 联系我们 +// @table:contact_us +type ContactUsModel struct { + Id primitive.ObjectID `json:"id" bson:"_id,omitempty"` // ID + Ip string `json:"ip" bson:"ip"` // 评论IP + Name string `json:"name" bson:"name"` // 名字 + Email string `json:"email" bson:"email"` // 邮件 + CategoryName string `json:"categoryName" bson:"categoryName"` // 主旨分类 + Content string `json:"content" bson:"content"` // 内容 + Status int64 `json:"status" bson:"status"` // 状态 0=初始 1=已回复 2=不予处理 3=已完结 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // 创建时间 +} + +type ContactUsModelList []*ContactUsModel diff --git a/pub/models/contenttagmod/content_tag.go b/pub/models/contenttagmod/content_tag.go new file mode 100644 index 0000000..04d1e00 --- /dev/null +++ b/pub/models/contenttagmod/content_tag.go @@ -0,0 +1,206 @@ +package contenttagmod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 content_tag 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.ContentTag) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.ContentTag.Name, Value: 1}, + }, + Options: options.Index(). + SetUnique(true). + SetName("uniq_content_tag_name"). + SetPartialFilterExpression(bson.M{FN.ContentTag.Name: bson.M{"$type": "string", "$gt": ""}}), + }, + { + Keys: bson.D{ + {Key: FN.ContentTag.Types, Value: 1}, + {Key: FN.ContentTag.Status, Value: 1}, + {Key: FN.ContentTag.Rank, Value: -1}, + {Key: FN.ContentTag.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContentTag.Status, Value: 1}, + {Key: FN.ContentTag.Rank, Value: -1}, + {Key: FN.ContentTag.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContentTag.VideoCount, Value: -1}, + {Key: FN.ContentTag.Rank, Value: -1}, + {Key: FN.ContentTag.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContentTag.PlayletCount, Value: -1}, + {Key: FN.ContentTag.Rank, Value: -1}, + {Key: FN.ContentTag.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(contenttagmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOneContentTag 插入一条统一标签数据 +func InsertOneContentTag(ctx context.Context, doc *ContentTag) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.ContentTag.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + if mongo.IsDuplicateKeyError(err) { + log.Warn("contenttagmod:InsertOneContentTag duplicate key", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInputExist, "标签名称已存在", "") + } + log.Warn("contenttagmod:InsertOneContentTag failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneContentTag 删除一条统一标签数据 +func DelOneContentTag(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("contenttagmod:DelOneContentTag failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneContentTag 查询一条统一标签数据 +func FindOneContentTag(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*ContentTag, *srvdef.SrvErr) { + var doc ContentTag + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("contenttagmod:FindOneContentTag failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyContentTag 按条件查询多条统一标签数据 +func FindManyContentTag(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (ContentTagList, *srvdef.SrvErr) { + data := make(ContentTagList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("contenttagmod:FindManyContentTag failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// AggregateContentTag 聚合查询统一标签数据 +func AggregateContentTag(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline, opts...); err != nil { + log.Warn("contenttagmod:AggregateContentTag 聚合查询统一标签失败", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// GetMapTags 按标签ID批量查询统一标签映射 +func GetMapTags(ctx context.Context, ids []int64) (ContentTagMap, *srvdef.SrvErr) { + result := make(ContentTagMap) + if len(ids) == 0 { + return result, nil + } + list, err := FindManyContentTag(ctx, bson.M{ + FN.ContentTag.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.M{ + FN.ContentTag.Id: 1, + FN.ContentTag.Name: 1, + FN.ContentTag.Slug: 1, + FN.ContentTag.Types: 1, + FN.ContentTag.Status: 1, + "seoInfo": 1, + FN.ContentTag.Rank: 1, + })) + if err != nil { + return nil, err + } + for _, item := range list { + if item != nil { + result[item.Id] = item + } + } + return result, nil +} + +// CountContentTag 统计满足条件的统一标签数量 +func CountContentTag(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("contenttagmod:CountContentTag failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneContentTag 更新一条统一标签数据 +func UpdateOneContentTag(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + log.Warn("contenttagmod:UpdateOneContentTag duplicate key", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbInputExist, "标签名称已存在", "") + } + log.Warn("contenttagmod:UpdateOneContentTag failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyContentTag 批量更新统一标签数据 +func UpdateManyContentTag(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("contenttagmod:UpdateManyContentTag failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// BulkWriteContentTag 批量写入/更新统一标签数据 +func BulkWriteContentTag(ctx context.Context, docs []mongo.WriteModel, opts ...*options.BulkWriteOptions) (*mongo.BulkWriteResult, *srvdef.SrvErr) { + result, err := coll.BulkWrite(ctx, docs, opts...) + if err != nil { + log.Warn("contenttagmod:BulkWriteContentTag failed", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result, nil +} diff --git a/pub/models/contenttagmod/tag.go b/pub/models/contenttagmod/tag.go new file mode 100644 index 0000000..7d079c7 --- /dev/null +++ b/pub/models/contenttagmod/tag.go @@ -0,0 +1,51 @@ +package contenttagmod + +// TagContentTag 保存 content_tag 集合字段名 +type TagContentTag struct { + Id string + Name string + Slug string + Types string + Status string + Rank string + VideoCount string + PlayletCount string + SeoInfo struct { + Description string + Keyworld string + Title string + } + CreatedById string + CreatedByName string + UpdatedByName string + CreatedAt string + UpdatedAt string +} + +var FN struct { + ContentTag TagContentTag +} + +var TN struct { + ContentTag string +} + +func init() { + FN.ContentTag.Id = "_id" + FN.ContentTag.Name = "name" + FN.ContentTag.Slug = "slug" + FN.ContentTag.Types = "types" + FN.ContentTag.Status = "status" + FN.ContentTag.Rank = "rank" + FN.ContentTag.VideoCount = "videoCount" + FN.ContentTag.PlayletCount = "playletCount" + FN.ContentTag.SeoInfo.Description = "seoInfo.webDescription" + FN.ContentTag.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.ContentTag.SeoInfo.Title = "seoInfo.webTitle" + FN.ContentTag.CreatedById = "createdById" + FN.ContentTag.CreatedByName = "createdByName" + FN.ContentTag.UpdatedByName = "updatedByName" + FN.ContentTag.CreatedAt = "createdAt" + FN.ContentTag.UpdatedAt = "updatedAt" + TN.ContentTag = "content_tag" +} diff --git a/pub/models/contenttagmod/type.go b/pub/models/contenttagmod/type.go new file mode 100644 index 0000000..4631521 --- /dev/null +++ b/pub/models/contenttagmod/type.go @@ -0,0 +1,40 @@ +package contenttagmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// ContentTagType 统一内容标签支持的内容类型 +type ContentTagType int32 + +const ( + ContentTagTypeVideo ContentTagType = 1 // 视频 + ContentTagTypePlaylet ContentTagType = 5 // 短剧 +) + +// ContentTag @table: content_tag +// +// ContentTag 内容统一标签集合模型 +type ContentTag struct { + Id int64 `json:"id" bson:"_id,omitempty"` // ID + Name string `json:"name" bson:"name"` // 标签名称 + Slug string `json:"slug" bson:"slug"` // slug + Types []ContentTagType `json:"types" bson:"types"` // 支持类型 1-视频 5-短剧 + Status bool `json:"status" bson:"status"` // 状态 true-启用 false-停用 + Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 绑定视频数量 + PlayletCount int64 `json:"playletCount" bson:"playletCount"` // 绑定短剧数量 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedById int64 `json:"createdById" bson:"createdById"` // 创建人ID + CreatedByName string `json:"createdByName" bson:"createdByName"` // 创建人名称 + UpdatedByName string `json:"updatedByName" bson:"updatedByName"` // 修改人名称 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// ContentTagList 内容统一标签列表 +type ContentTagList []*ContentTag + +// ContentTagMap 内容统一标签ID映射 +type ContentTagMap map[int64]*ContentTag diff --git a/pub/models/detailnavmod/detailnav.go b/pub/models/detailnavmod/detailnav.go new file mode 100644 index 0000000..b0840bf --- /dev/null +++ b/pub/models/detailnavmod/detailnav.go @@ -0,0 +1,102 @@ +package detailnavmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.DetailNavModel) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.DetailNavModel.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(detailnavmod:Init) Create DetailNavModel Index failed! err:%+v:", err)) + } +} + +// InsertOneDetailNav 插入一条详情页导航 +func InsertOneDetailNav(ctx context.Context, doc *DetailNavModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, doc); err != nil { + log.Warn("detailnavmod:InsertOneDetailNav db.InsertOne failed", + log.E(err), log.Any("doc", doc)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneDetailNav 删除一条详情页导航 +func DelOneDetailNav(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("detailnavmod:DelOneDetailNav db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneDetailNav 查找一条详情页导航 +func FindOneDetailNav(ctx context.Context, filter bson.M) (*DetailNavModel, *srvdef.SrvErr) { + var doc DetailNavModel + if err := db.FindOneEx(ctx, &doc, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("detailnavmod:FindOneDetailNav db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyDetailNav 查找多条详情页导航 +func FindManyDetailNav(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (DetailNavModelList, *srvdef.SrvErr) { + data := make(DetailNavModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("detailnavmod:FindManyDetailNav db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountDetailNav 统计详情页导航数量 +func CountDetailNav(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("detailnavmod:CountDetailNav db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneDetailNav 更新一条详情页导航 +func UpdateOneDetailNav(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("detailnavmod:UpdateOneDetailNav db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("detailnavmod:UpdateOneDetailNav not matched", + log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/detailnavmod/tag.go b/pub/models/detailnavmod/tag.go new file mode 100644 index 0000000..6bef236 --- /dev/null +++ b/pub/models/detailnavmod/tag.go @@ -0,0 +1,30 @@ +package detailnavmod + +type TagDetailNavModel struct { + CreatedAt string + ID string + Name string + Url string + Sort string + Status string + UpdatedAt string +} + +var FN struct { + DetailNavModel TagDetailNavModel +} + +var TN struct { + DetailNavModel string +} + +func init() { + FN.DetailNavModel.CreatedAt = "createdAt" + FN.DetailNavModel.ID = "_id" + FN.DetailNavModel.Name = "name" + FN.DetailNavModel.Url = "url" + FN.DetailNavModel.Sort = "sort" + FN.DetailNavModel.Status = "status" + FN.DetailNavModel.UpdatedAt = "updatedAt" + TN.DetailNavModel = "detail_nav" +} diff --git a/pub/models/detailnavmod/type.go b/pub/models/detailnavmod/type.go new file mode 100644 index 0000000..cb5c91e --- /dev/null +++ b/pub/models/detailnavmod/type.go @@ -0,0 +1,21 @@ +package detailnavmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 详情页导航配置 +// @table:detail_nav +type DetailNavModel struct { + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + Name string `json:"name" bson:"name"` // 导航名称 + Url string `json:"url" bson:"url"` // 导航链接 + Sort int64 `json:"sort" bson:"sort"` // 排序 + Status bool `json:"status" bson:"status"` // 是否启用 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +type DetailNavModelList []*DetailNavModel diff --git a/pub/models/esmod/actress.go b/pub/models/esmod/actress.go new file mode 100644 index 0000000..61db243 --- /dev/null +++ b/pub/models/esmod/actress.go @@ -0,0 +1,105 @@ +package esmod + +import ( + "fmt" + "videoweb/app/api/proto" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" +) + +// SearchActress 根据关键字搜索媒体 +func SearchActress(content string, from int64, size int64, sort string) (int64, []*ESActress, *srvdef.SrvErr) { + fields := []string{FN.ESActress.Alias, FN.ESActress.Office, FN.ESActress.Region, FN.ESActress.Desc} + must := esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields}, + }, + {"term": esmanager.M{FN.ESActress.Status: true}}, + }, + }, + } + switch sort { + case string(proto.SearchSortTypeHot): + must = esmanager.M{ + "function_score": esmanager.M{ + "query": esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields, + }}, + {"term": esmanager.M{FN.ESActress.Status: true}}, + }, + }, + }, + "functions": esmanager.A{ + esmanager.M{ + "field_value_factor": esmanager.M{ + "field": FN.ESActress.Real.WatchTimes, + "factor": 1.0, + "modifier": "none", + }, + }, + }, + "score_mode": "sum", + "boost_mode": "multiply", + }, + } + case string(proto.SearchSortTypeNew): + must = esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields}, + }, + {"term": esmanager.M{FN.ESActress.Status: true}}, + }, + "should": esmanager.A{ + {"range": esmanager.M{FN.ESActress.CreatedAt: esmanager.M{"gte": "now-30d/d", "boost": 2.0}}}, //7天内提升4倍权重 + {"range": esmanager.M{FN.ESActress.CreatedAt: esmanager.M{"gte": "now-180d/d", "boost": 1.5}}}, // 30天内提升3倍权重 + {"range": esmanager.M{FN.ESActress.CreatedAt: esmanager.M{"gte": "now-365d/d", "boost": 1.1}}}, // 90天内提升1.5倍权重 + }, + "minimum_should_match": 0, // 允许不匹配任何should条件 + }, + } + } + + query := esmanager.M{ + "query": must, + "sort": esmanager.A{ + esmanager.M{"_score": esmanager.M{"order": "desc"}}, + }, + "from": from, + "size": size, + } + + actressList := &ESActressHits{} + err := esmanager.Instance.Search(actressIndex, actressList, query) + if err != nil { + info := fmt.Sprintf( + "(esmod:SearchActress) Search failed! err:%+v:", err) + log.Warn(info, + log.Any("keywords", content), + log.Any("from", from), + log.Any("size", size)) + return 0, nil, srvdef.New(srvdef.ErrElsOperError, "", "") + } + total := int64(0) + rslData := make([]*ESActress, 0) + if actressList != nil && actressList.Hits != nil && len(actressList.Hits) > 0 { + total = actressList.Total.Value + for _, video := range actressList.Hits { + rslData = append(rslData, video.Source) + } + } + return total, rslData, nil +} \ No newline at end of file diff --git a/pub/models/esmod/eshelper.go b/pub/models/esmod/eshelper.go new file mode 100644 index 0000000..187ec5e --- /dev/null +++ b/pub/models/esmod/eshelper.go @@ -0,0 +1,62 @@ +package esmod + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + FiveMinuteScale = 5 //对齐时间(分钟) +) + +var ( + videoIndex = "webhjsq_video_index_v1" + playletIndex = "webhjsq_playlet_index_v1" + actressIndex = "webhjsq_actress_index_v1" +) + +func GetVideoIndex() string { + return videoIndex +} +func GetPlayletIndex() string { + return playletIndex +} +func GetActressIndex() string { + return actressIndex +} + +func GetRecordByESIndex(ctx context.Context, esIndex string) ( + *ESTimestamp, *srvdef.SrvErr) { + + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + esRecord, serr := FindOneESRecord(ctx, filter) + if serr != nil { + if serr.Code != srvdef.ErrNoData { + return nil, serr + } + return nil, nil + } + return &esRecord.Timestamp, nil +} + +func SetOneEsRecord(ctx context.Context, esIndex string, + timestamp *ESTimestamp) *srvdef.SrvErr { + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + upsertDoc := bson.M{ + "$set": bson.M{ + FN.ESRecord.Timestamp.T: timestamp.T, + FN.ESRecord.Timestamp.I: timestamp.I, + FN.ESRecord.UpdatedAt: time.Now(), + }, + "$setOnInsert": bson.M{ + FN.ESRecord.Id: primitive.NewObjectID(), + FN.ESRecord.CreatedAt: time.Now(), + }, + } + serr := UpsertOneESRecord(ctx, filter, upsertDoc) + return serr +} diff --git a/pub/models/esmod/esop.go b/pub/models/esmod/esop.go new file mode 100644 index 0000000..7e082c9 --- /dev/null +++ b/pub/models/esmod/esop.go @@ -0,0 +1,343 @@ +package esmod + +import ( + "context" + "fmt" + "videoweb/pub/esmanager" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +// Init 初始化数据 +func Init(ctx context.Context) { + initDBIndex(ctx) + initIndex() + return +} + +// initIndex 初始化索引 +func initIndex() { + if esmanager.Instance == nil { + return + } + initVideoIndex(videoIndex) + initPlayletIndex(playletIndex) + initActressIndex(actressIndex) +} + +// initDBIndex 初始化数据库索引 +func initDBIndex(ctx context.Context) { + //获取数据库表 + db = mongos.Shop.Coll(TN.ESRecord) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.ESRecord.EsIndex, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(esmod:initDBIndex) Create es_record Index failed! err:%+v:", err)) + } +} + +func initVideoIndex(esIndex string) { + var setting = esmanager.M{ + "settings": esmanager.M{ + "number_of_shards": esmanager.NumberOfShards, + "number_of_replicas": esmanager.NumberOfReplicas, + "analysis": esmanager.M{ + "analyzer": esmanager.M{ + "ik": esmanager.M{ + "tokenizer": esmanager.AnalyzerIkSmart, + }, + }, + }, + }, + "mappings": esmanager.M{ + "properties": esmanager.M{ + FN.ESVideo.Title: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.MediaType: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.Remark: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Desc: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Keyworlds: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.CreatorName: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Bango: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.IssuedBrand: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESVideo.Status: esmanager.M{ + "type": "integer", + }, + //FN.ESVideo.IsHot: esmanager.M{ + // "type": "boolean", + //}, + //FN.ESVideo.Sort: esmanager.M{ + // "type": "integer", + //}, + FN.ESVideo.WatchTimes: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.ShowTimes: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.Comments: esmanager.M{ + "type": "integer", + }, + FN.ESVideo.AddedTime: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + FN.ESVideo.IssuedAt: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + //FN.ESVideo.CreatedAt: esmanager.M{ + // "type": "date", + // "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + //}, + }, + }, + } + if err := esmanager.Instance.InitIndices(esIndex, setting); err != nil { + panic(fmt.Sprintf("ElasticSearch Index %s initMediaIndex error", esIndex)) + } +} + +func initPlayletIndex(esIndex string) { + var setting = esmanager.M{ + "settings": esmanager.M{ + "number_of_shards": esmanager.NumberOfShards, + "number_of_replicas": esmanager.NumberOfReplicas, + "analysis": esmanager.M{ + "analyzer": esmanager.M{ + "ik": esmanager.M{ + "tokenizer": esmanager.AnalyzerIkSmart, + }, + }, + }, + }, + "mappings": esmanager.M{ + "properties": esmanager.M{ + FN.ESPlaylet.Id: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.Status: esmanager.M{ + "type": "integer", + }, + FN.ESPlaylet.IsFeatured: esmanager.M{ + "type": "boolean", + }, + FN.ESPlaylet.IsRecommend: esmanager.M{ + "type": "boolean", + }, + FN.ESPlaylet.ShowOnBanner: esmanager.M{ + "type": "boolean", + }, + FN.ESPlaylet.Banner: esmanager.M{ + "type": "keyword", + "index": false, + }, + FN.ESPlaylet.OnlineAt: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + FN.ESPlaylet.CategoryIds: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.CategoryNames: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.ThemeIds: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.ThemeNames: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.Title: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.Desc: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.Anchor: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.Cover: esmanager.M{ + "type": "keyword", + "index": false, + }, + FN.ESPlaylet.TagIds: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.TagNames: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.TotalEpisode: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.UpdateStatus: esmanager.M{ + "type": "integer", + }, + FN.ESPlaylet.CountBrowse: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.HeatValue: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.CountCollect: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.CountLike: esmanager.M{ + "type": "long", + }, + FN.ESPlaylet.SeoInfo: esmanager.M{ + "properties": esmanager.M{ + "title": esmanager.M{ + "type": "keyword", + "index": false, + }, + "keyworld": esmanager.M{ + "type": "keyword", + "index": false, + }, + "description": esmanager.M{ + "type": "keyword", + "index": false, + }, + }, + }, + FN.ESPlaylet.UpdatedAt: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + FN.ESPlaylet.CreatedAt: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + }, + }, + } + if err := esmanager.Instance.InitIndices(esIndex, setting); err != nil { + panic(fmt.Sprintf("ElasticSearch Index %s initPlayletIndex error", esIndex)) + } +} + +func initActressIndex(esIndex string) { + var setting = esmanager.M{ + "settings": esmanager.M{ + "number_of_shards": esmanager.NumberOfShards, + "number_of_replicas": esmanager.NumberOfReplicas, + "analysis": esmanager.M{ + "analyzer": esmanager.M{ + "ik": esmanager.M{ + "tokenizer": esmanager.AnalyzerIkSmart, + }, + }, + }, + }, + "mappings": esmanager.M{ + "properties": esmanager.M{ + FN.ESActress.Alias: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESActress.Desc: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESActress.Office: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESActress.Region: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkSmart, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESActress.Rank: esmanager.M{ + "type": "integer", + }, + FN.ESActress.Status: esmanager.M{ + "type": "integer", + }, + FN.ESActress.Works: esmanager.M{ + "type": "integer", + }, + FN.ESActress.HotValue: esmanager.M{ + "type": "integer", + }, + FN.ESActress.Real.WatchTimes: esmanager.M{ + "type": "integer", + }, + FN.ESActress.Real.WeekWatchs: esmanager.M{ + "type": "integer", + }, + FN.ESActress.Real.MonthWatchs: esmanager.M{ + "type": "integer", + }, + FN.ESActress.DebutDate: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + FN.ESActress.DebutDate: esmanager.M{ + "type": "date", + "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss", + }, + }, + }, + } + if err := esmanager.Instance.InitIndices(esIndex, setting); err != nil { + panic(fmt.Sprintf("ElasticSearch Index %s initMediaIndex error", esIndex)) + } +} diff --git a/pub/models/esmod/playlet.go b/pub/models/esmod/playlet.go new file mode 100644 index 0000000..e49ae39 --- /dev/null +++ b/pub/models/esmod/playlet.go @@ -0,0 +1,112 @@ +package esmod + +import ( + "fmt" + "videoweb/app/api/proto" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/playletmod" +) + +// SearchPlaylet 根据关键字搜索短剧 +func SearchPlaylet(content string, from int64, size int64, sort string) (int64, []*ESPlaylet, *srvdef.SrvErr) { + fields := []string{ + FN.ESPlaylet.Title, + FN.ESPlaylet.Desc, + FN.ESPlaylet.Anchor, + FN.ESPlaylet.CategoryNames, + FN.ESPlaylet.ThemeNames, + FN.ESPlaylet.TagNames, + } + must := esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields, + }}, + {"term": esmanager.M{FN.ESPlaylet.Status: playletmod.StatusUp}}, + }, + }, + } + switch sort { + case string(proto.SearchSortTypeHot): + must = esmanager.M{ + "function_score": esmanager.M{ + "query": esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields, + }}, + {"term": esmanager.M{FN.ESPlaylet.Status: playletmod.StatusUp}}, + }, + }, + }, + "functions": esmanager.A{ + esmanager.M{ + "field_value_factor": esmanager.M{ + "field": FN.ESPlaylet.CountBrowse, + "factor": 1.0, + "modifier": "none", + }, + }, + }, + "score_mode": "sum", + "boost_mode": "multiply", + }, + } + case string(proto.SearchSortTypeNew): + must = esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields, + }}, + {"term": esmanager.M{FN.ESPlaylet.Status: playletmod.StatusUp}}, + }, + "should": esmanager.A{ + {"range": esmanager.M{FN.ESPlaylet.OnlineAt: esmanager.M{"gte": "now-30d/d", "boost": 2.0}}}, + {"range": esmanager.M{FN.ESPlaylet.OnlineAt: esmanager.M{"gte": "now-180d/d", "boost": 1.5}}}, + {"range": esmanager.M{FN.ESPlaylet.OnlineAt: esmanager.M{"gte": "now-365d/d", "boost": 1.1}}}, + }, + "minimum_should_match": 0, + }, + } + } + + query := esmanager.M{ + "query": must, + "sort": esmanager.A{ + esmanager.M{"_score": esmanager.M{"order": "desc"}}, + }, + "from": from, + "size": size, + "terminate_after": 1000, + } + + playletList := &ESPlayletHits{} + err := esmanager.Instance.Search(playletIndex, playletList, query) + if err != nil { + info := fmt.Sprintf("(esmod:SearchPlaylet) Search failed! err:%+v:", err) + log.Warn(info, log.Any("keywords", content), log.Any("from", from), log.Any("size", size)) + return 0, nil, srvdef.New(srvdef.ErrElsOperError, "", "") + } + total := int64(0) + result := make([]*ESPlaylet, 0) + if playletList != nil && playletList.Hits != nil && len(playletList.Hits) > 0 { + total = playletList.Total.Value + for _, item := range playletList.Hits { + if item != nil && item.Source != nil { + result = append(result, item.Source) + } + } + } + return total, result, nil +} diff --git a/pub/models/esmod/recordop.go b/pub/models/esmod/recordop.go new file mode 100644 index 0000000..bb01d84 --- /dev/null +++ b/pub/models/esmod/recordop.go @@ -0,0 +1,144 @@ +package esmod + +import ( + "context" + "go.mongodb.org/mongo-driver/bson/primitive" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// InsertOneESRecord 插入ES操作记录 +func InsertOneESRecord(ctx context.Context, esRecord *ESRecord) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, esRecord); err != nil { + log.Warn("esmod:InsertOneESRecord db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneESRecord 删除一个ES操作记录 +func DelOneESRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("esmod:DelOnAppTag db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneESRecord 查找一个ES操作记录 +func FindOneESRecord(ctx context.Context, filter bson.M) ( + *ESRecord, *srvdef.SrvErr) { + var esRecord ESRecord + if err := db.FindOneEx( + ctx, &esRecord, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Info("esmod:FindOnESRecord db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &esRecord, nil +} + +// CountESRecords 统计ES操作记录 +func CountESRecords(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("esmod:CountMediaCats db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneESRecord 更新ES操作记录 +func UpdateOneESRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("esmod:UpdateOnESRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("esmod:UpdateOnESRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrMediaRecordNotExsit, "", "") + } + return nil +} + +// UpsertOneESRecord 插入或更新一条ES操作记录 +func UpsertOneESRecord(ctx context.Context, + filter bson.M, update bson.M) *srvdef.SrvErr { + if _, err := db.UpsertOne(ctx, filter, update); err != nil { + log.Info("esmod:UpsertOneESRecord db.InsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// UpdateManyESRecord 更新一批ES操作记录 +func UpdateManyESRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMMany(ctx, filter, update) + if err != nil { + log.Warn("esmod:UpdateManyMediaCat db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("esmod:UpdateManyMediaCat db.UpdateMMany failed", + log.E(err), log.Any("filter", filter)) + //return srvdef.New(srvdef.ErrMediaTagNotExsit, "", "") + } + return nil +} + +// FindManyESRecord 查找一批ES操作记录 +func FindManyESRecord(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (ESRecordList, *srvdef.SrvErr) { + data := make(ESRecordList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("esmod:FindManyESRecord db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +func GetRecordTimeByESIndex(ctx context.Context, esIndex string) (*ESRecord, *srvdef.SrvErr) { + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + esRecord, serr := FindOneESRecord(ctx, filter) + if serr != nil { + if serr.Code != srvdef.ErrNoData { + return nil, serr + } + return nil, nil + } + return esRecord, nil +} +func SetOneEsRecordTime(ctx context.Context, esIndex string, recordTime time.Time) *srvdef.SrvErr { + filter := bson.M{FN.ESRecord.EsIndex: esIndex} + upsertDoc := bson.M{ + "$set": bson.M{ + FN.ESRecord.RecordTime: recordTime, + FN.ESRecord.UpdatedAt: time.Now(), + }, + "$setOnInsert": bson.M{ + FN.ESRecord.Id: primitive.NewObjectID(), + FN.ESRecord.CreatedAt: time.Now(), + }, + } + serr := UpsertOneESRecord(ctx, filter, upsertDoc) + return serr +} \ No newline at end of file diff --git a/pub/models/esmod/tag.go b/pub/models/esmod/tag.go new file mode 100755 index 0000000..8dc7be9 --- /dev/null +++ b/pub/models/esmod/tag.go @@ -0,0 +1,227 @@ +package esmod + +type TagESRecord struct { + CreatedAt string + EsIndex string + Id string + RecordTime string + Timestamp struct { + I string + T string + } + UpdatedAt string +} +type TagESVideo struct { + Id string + Title string + MediaType string + AddedTime string + CreatorName string + Keyworlds string + Desc string + Remark string + Status string + IsHot string + Sort string + WatchTimes string + ShowTimes string + Comments string + UpdatedAt string + CreatedAt string + + Keyworld string + CreatorId string + FirstTag string + SecondTags string + CoverImg string + Nodes string + Video string + CategoryIds string + Tags string + Real struct { + WatchTimes string + ShowTimes string + Comments string + WeekWatchs string + MonthWatchs string + TodayWatchs string + } + IssuedAt string + IssuedBrand string + Bango string +} +type TagESPlaylet struct { + Id string + Status string + IsFeatured string + IsRecommend string + ShowOnBanner string + Banner string + OnlineAt string + CategoryIds string + CategoryNames string + ThemeIds string + ThemeNames string + Title string + Desc string + Anchor string + Cover string + TagIds string + TagNames string + TotalEpisode string + UpdateStatus string + CountBrowse string + HeatValue string + CountCollect string + CountLike string + SeoInfo string + UpdatedAt string + CreatedAt string +} +type TagESActress struct { + Id string + Alias string + Avatar string + Meas string + Office string + DebutDate string + BornDate string + Region string + Score string + Works string + HotValue string + Real struct { + WatchTimes string + ShowTimes string + Comments string + WeekWatchs string + MonthWatchs string + TodayWatchs string + } + Desc string + Status string + CreatedAt string + UpdatedAt string + Rank string +} + +var FN struct { + ESRecord TagESRecord + ESVideo TagESVideo + ESPlaylet TagESPlaylet + ESActress TagESActress +} + +var TN struct { + ESRecord string + ESVideo string + ESPlaylet string + ESActress string +} + +func init() { + FN.ESRecord = TagESRecord{ + CreatedAt: "createdAt", + EsIndex: "esIndex", + Id: "_id", + RecordTime: "recordTime", + Timestamp: struct { + I string + T string + }{ + I: "i", + T: "t", + }, + UpdatedAt: "updatedAt", + } + FN.ESVideo = TagESVideo{ + Id: "_id", + Title: "title", + Desc: "desc", + Status: "status", + AddedTime: "addedTime", + MediaType: "mediaType", + CreatorName: "creatorName", + Keyworlds: "keyworlds", + Remark: "remark", + IsHot: "isHot", + Sort: "sort", + UpdatedAt: "updatedAt", + CreatedAt: "createdAt", + WatchTimes: "watchTimes", + ShowTimes: "showTimes", + Comments: "comments", + IssuedAt: "issuedAt", + IssuedBrand: "issuedBrand", + Bango: "bango", + Real: struct { + WatchTimes string + ShowTimes string + Comments string + WeekWatchs string + MonthWatchs string + TodayWatchs string + }{WatchTimes: "watchTimes", ShowTimes: "showTimes", Comments: "comments", WeekWatchs: "weekWatchs", MonthWatchs: "monthWatchs", TodayWatchs: "todayWatchs"}, + } + + FN.ESPlaylet = TagESPlaylet{ + Id: "id", + Status: "status", + IsFeatured: "isFeatured", + IsRecommend: "isRecommend", + ShowOnBanner: "showOnBanner", + Banner: "banner", + OnlineAt: "onlineAt", + CategoryIds: "categoryIds", + CategoryNames: "categoryNames", + ThemeIds: "themeIds", + ThemeNames: "themeNames", + Title: "title", + Desc: "desc", + Anchor: "anchor", + Cover: "cover", + TagIds: "tagIds", + TagNames: "tagNames", + TotalEpisode: "totalEpisode", + UpdateStatus: "updateStatus", + CountBrowse: "countBrowse", + HeatValue: "heatValue", + CountCollect: "countCollect", + CountLike: "countLike", + SeoInfo: "seoInfo", + UpdatedAt: "updatedAt", + CreatedAt: "createdAt", + } + + FN.ESActress = TagESActress{ + Id: "_id", + Alias: "alias", + Avatar: "avatar", + Meas: "meas", + Office: "office", + DebutDate: "debutDate", + BornDate: "bornDate", + Region: "region", + Score: "score", + Works: "works", + HotValue: "hotValue", + Real: struct { + WatchTimes string + ShowTimes string + Comments string + WeekWatchs string + MonthWatchs string + TodayWatchs string + }{WatchTimes: "watchTimes", ShowTimes: "showTimes", Comments: "comments", WeekWatchs: "weekWatchs", MonthWatchs: "monthWatchs", TodayWatchs: "todayWatchs"}, + Desc: "desc", + Status: "status", + CreatedAt: "createdAt", + UpdatedAt: "updatedAt", + Rank: "rank", + } + + TN.ESRecord = "es_record" + TN.ESVideo = "es_video" + TN.ESPlaylet = "es_playlet" + TN.ESActress = "es_actress" +} diff --git a/pub/models/esmod/type.go b/pub/models/esmod/type.go new file mode 100644 index 0000000..ef95c0b --- /dev/null +++ b/pub/models/esmod/type.go @@ -0,0 +1,172 @@ +//go:generate gen_bson_tag -m esmod +//esmod essearch数据结构定义 + +package esmod + +import ( + "time" + "videoweb/pub/models/actress" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ESTimestamp struct { + T uint32 `json:"t" bson:"t"` + I uint32 `json:"i" bson:"i"` +} + +func (v *ESTimestamp) IsZero() bool { + if v == nil { + return true + } + return v.T == 0 && v.I == 0 +} + +// ESOperator es操作记录 +// @table:es_record +type ESRecord struct { + Id primitive.ObjectID `json:"id" bson:"_id"` + EsIndex string `json:"esIndex" bson:"esIndex"` //es索引名称 + Timestamp ESTimestamp `json:"timestamp" bson:"timestamp"` //操作时间 + RecordTime time.Time `json:"recordTime" bson:"recordTime"` // 操作记录时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` //更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` //创建时间 +} + +type ESRecordList []*ESRecord + +// ESVideo es中存储的media数据 +// @table:es_video +type ESVideo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + MediaType videoinformationmod.MediaType `json:"mediaType" bson:"mediaType"` // 资源类型 + AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 帖子上架时间 + CreatorName string `json:"creatorName" bson:"creatorName"` // 作者名称 + Keyworlds string `bson:"keyworlds,omitempty" json:"keyworlds"` // 关键字 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Status pubdef.InformationStatus `json:"status" bson:"status"` // 帖子上架状态 + IsHot bool `json:"isHot" bson:"isHot"` // 是否热门 + Sort int64 `json:"sort" bson:"sort"` // 排序 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 观看次数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + Comments int64 `json:"comments" bson:"comments"` // 评论条数 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + CreatorId int64 `json:"creatorId" bson:"creatorId"` // 作者Id + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + Nodes []videoinformationmod.Node `json:"nodes" bson:"nodes"` // 节点 + Video videoinformationmod.VideoInfo `json:"video" bson:"video"` // 视频 + Tags []int64 `json:"tags" bson:"tags"` // 标签 + Real pubdef.Flow `json:"real" bson:"real"` // 真实流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 + IssuedAt time.Time `json:"issuedAt" bson:"issuedAt"` // 发行时间 + IssuedBrand string `json:"issuedBrand" bson:"issuedBrand"` // 发行商 + Bango string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 +} + +type ESVideoList []*ESVideo +type ESTotal struct { + Value int64 `json:"value"` +} + +type ESVideoSource struct { + ID string `json:"_id"` + Score float32 `json:"_score"` + Source *ESVideo `json:"_source"` +} + +// ESVideoHits search media hits +type ESVideoHits struct { + Total ESTotal `json:"total"` + Hits []*ESVideoSource `json:"hits"` +} + +// ESPlaylet es中存储的短剧搜索数据 +// @table:es_playlet +type ESPlaylet struct { + Id int64 `json:"id" bson:"id"` // 短剧ID + Status playletmod.Status `json:"status" bson:"status"` // 状态: 0下架 1上架 + IsFeatured bool `json:"isFeatured" bson:"isFeatured"` // 是否爆剧 + IsRecommend bool `json:"isRecommend" bson:"isRecommend"` // 是否推荐 + ShowOnBanner bool `json:"showOnBanner" bson:"showOnBanner"` // 是否显示在首页banner + Banner string `json:"banner" bson:"banner"` // banner图 + OnlineAt time.Time `json:"onlineAt" bson:"onlineAt"` // 上架时间 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合 + CategoryNames []string `json:"categoryNames" bson:"categoryNames"` // 分类名称集合 + ThemeIds []int64 `json:"themeIds" bson:"themeIds"` // 题材ID集合 + ThemeNames []string `json:"themeNames" bson:"themeNames"` // 题材名称集合 + Title string `json:"title" bson:"title"` // 剧名 + Desc string `json:"desc" bson:"desc"` // 简介 + Anchor string `json:"anchor" bson:"anchor"` // 作者/演员 + Cover string `json:"cover" bson:"cover"` // 封面 + TagIds []int64 `json:"tagIds" bson:"tagIds"` // 标签ID集合 + TagNames []string `json:"tagNames" bson:"tagNames"` // 标签名称集合 + TotalEpisode int64 `json:"totalEpisode" bson:"totalEpisode"` // 总集数 + UpdateStatus int `json:"updateStatus" bson:"updateStatus"` // 更新状态 1-连载中 2-已完结 + CountBrowse int64 `json:"countBrowse" bson:"countBrowse"` // 观看量 + HeatValue int64 `json:"heatValue" bson:"heatValue"` // 热度值 + CountCollect int64 `json:"countCollect" bson:"countCollect"` // 收藏数 + CountLike int64 `json:"countLike" bson:"countLike"` // 展示点赞数 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} + +type ESPlayletList []*ESPlaylet + +type ESPlayletSource struct { + ID string `json:"_id"` + Score float32 `json:"_score"` + Source *ESPlaylet `json:"_source"` +} + +// ESPlayletHits search playlet hits +type ESPlayletHits struct { + Total ESTotal `json:"total"` + Hits []*ESPlayletSource `json:"hits"` +} + +// ESActress es中存储的actress数据 +// @table:es_actress +type ESActress struct { + Id int64 `json:"id" bson:"_id"` // Id + Alias string `bson:"alias" json:"alias"` // 别名 + Avatar string `bson:"avatar" json:"avatar"` // 头像 + Meas actress.Meas `bson:"meas" json:"meas"` // 三围 + Desc string `bson:"desc" json:"desc"` // 详细描述 + Office string `json:"office" bson:"office"` // 事务所 + DebutDate time.Time `bson:"debutDate" json:"debutDate" ` // 出道日期 + BornDate time.Time `bson:"bornDate" json:"bornDate"` // 出生日期 + Region string `json:"region" bson:"region"` // 地区/国家 + Score string `json:"score" bson:"score"` // 评分 + Works int64 `json:"works" bson:"works"` // 作品数 + HotValue int64 `json:"hotValue" bson:"hotValue"` // 热度值 + Real pubdef.Flow `json:"real" bson:"real"` // 真实流量 + Status bool `json:"status" bson:"status"` // 状态 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + TranslationCategoryId int64 `bson:"translationCategoryId" json:"translationCategoryId"` // 关联导航分类id + Rank int64 `json:"rank" bson:"rank"` // 排序 +} + +type ESActressList []*ESActress + +type ESActressSource struct { + ID string `json:"_id"` + Score float32 `json:"_score"` + Source *ESActress `json:"_source"` +} + +// ESActressHits search media hits +type ESActressHits struct { + Total ESTotal `json:"total"` + Hits []*ESActressSource `json:"hits"` +} diff --git a/pub/models/esmod/video.go b/pub/models/esmod/video.go new file mode 100644 index 0000000..2a154cb --- /dev/null +++ b/pub/models/esmod/video.go @@ -0,0 +1,153 @@ +package esmod + +import ( + "fmt" + "videoweb/app/api/proto" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +// SearchVideo 根据关键字搜索媒体 +func SearchVideo(content string, from int64, size int64, sort string, isAv bool) (int64, []*videoinformationmod.VideoInformation, *srvdef.SrvErr) { + fields := []string{FN.ESVideo.Title, FN.ESVideo.Bango, FN.ESVideo.IssuedBrand, FN.ESVideo.Keyworlds, FN.ESVideo.Desc} + mediaType := videoinformationmod.MediaType_Video + if isAv { + mediaType = videoinformationmod.MediaType_AV + } + must := esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields}, + }, + {"term": esmanager.M{FN.ESVideo.Status: pubdef.InformationStatusVerifyOK}}, + {"term": esmanager.M{FN.ESVideo.MediaType: mediaType}}, + }, + }, + } + switch sort { + case string(proto.SearchSortTypeHot): + must = esmanager.M{ + "function_score": esmanager.M{ + "query": esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields, + }}, + {"term": esmanager.M{FN.ESVideo.Status: pubdef.InformationStatusVerifyOK}}, + {"term": esmanager.M{FN.ESVideo.MediaType: mediaType}}, + }, + }, + }, + "functions": esmanager.A{ + esmanager.M{ + "field_value_factor": esmanager.M{ + "field": FN.ESVideo.WatchTimes, + "factor": 1.0, + "modifier": "none", + }, + }, + }, + "score_mode": "sum", + "boost_mode": "multiply", + }, + } + case string(proto.SearchSortTypeNew): + must = esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + {"multi_match": esmanager.M{ + "query": content, + "type": "best_fields", + "fields": fields}, + }, + {"term": esmanager.M{FN.ESVideo.Status: pubdef.InformationStatusVerifyOK}}, + {"term": esmanager.M{FN.ESVideo.MediaType: mediaType}}, + }, + "should": esmanager.A{ + {"range": esmanager.M{FN.ESVideo.AddedTime: esmanager.M{"gte": "now-30d/d", "boost": 2.0}}}, //7天内提升4倍权重 + {"range": esmanager.M{FN.ESVideo.AddedTime: esmanager.M{"gte": "now-180d/d", "boost": 1.5}}}, // 30天内提升3倍权重 + {"range": esmanager.M{FN.ESVideo.AddedTime: esmanager.M{"gte": "now-365d/d", "boost": 1.1}}}, // 90天内提升1.5倍权重 + }, + "minimum_should_match": 0, // 允许不匹配任何should条件 + }, + } + } + + query := esmanager.M{ + "query": must, + "sort": esmanager.A{ + esmanager.M{"_score": esmanager.M{"order": "desc"}}, + }, + "from": from, + "size": size, + } + + log.Info("(esmod:SearchVideo) 执行搜索", + log.Any("index", videoIndex), + log.Any("keywords", content), + log.Any("sort", sort), + log.Any("from", from), + log.Any("size", size)) + videos := &ESVideoHits{} + err := esmanager.Instance.Search(videoIndex, videos, query) + if err != nil { + info := fmt.Sprintf( + "(esmod:SearchVideo) Search failed! err:%+v:", err) + log.Warn(info, + log.Any("keywords", content), + log.Any("from", from), + log.Any("size", size)) + return 0, nil, srvdef.New(srvdef.ErrElsOperError, "", "") + } + total := int64(0) + rslData := make([]*videoinformationmod.VideoInformation, 0) + if videos != nil && videos.Hits != nil && len(videos.Hits) > 0 { + total = videos.Total.Value + for _, video := range videos.Hits { + rslData = append(rslData, EsVideoInfoToData(video.Source)) + } + } + log.Info("(esmod:SearchVideo) 搜索完成", + log.Any("index", videoIndex), + log.Any("keywords", content), + log.Any("total", total), + log.Any("hits", len(rslData))) + return total, rslData, nil +} + +func EsVideoInfoToData(esInfo *ESVideo) *videoinformationmod.VideoInformation { + info := &videoinformationmod.VideoInformation{ + Id: esInfo.Id, + Title: esInfo.Title, + MediaType: esInfo.MediaType, + AddedTime: esInfo.AddedTime, + CreatorId: esInfo.CreatorId, + CreatorName: esInfo.CreatorName, + Keyworld: esInfo.Keyworld, + FirstTag: esInfo.FirstTag, + SecondTags: esInfo.SecondTags, + CoverImg: esInfo.CoverImg, + Desc: esInfo.Desc, + Remark: esInfo.Remark, + Nodes: esInfo.Nodes, + Status: esInfo.Status, + Video: esInfo.Video, + Tags: esInfo.Tags, + IsHot: esInfo.IsHot, + Sort: esInfo.Sort, + Real: esInfo.Real, + VideoCount: esInfo.VideoCount, + UpdatedAt: esInfo.UpdatedAt, + CreatedAt: esInfo.CreatedAt, + } + return info +} diff --git a/pub/models/eventmod/index.go b/pub/models/eventmod/index.go new file mode 100644 index 0000000..5e34736 --- /dev/null +++ b/pub/models/eventmod/index.go @@ -0,0 +1,47 @@ +package eventmod + +import ( + "context" + "fmt" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.Event) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Event.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Event.EventType, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Event.EventId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Event.Status, Value: 1}, + {Key: FN.Event.RetryCount, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Event.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(eventmod:Init) Create Event Index failed! err:%+v:", err)) + } +} diff --git a/pub/models/eventmod/model.go b/pub/models/eventmod/model.go new file mode 100644 index 0000000..fa91bf7 --- /dev/null +++ b/pub/models/eventmod/model.go @@ -0,0 +1,130 @@ +package eventmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// InsertOneEvent 插入一个事件 +func InsertOneEvent(ctx context.Context, event *Event) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, event); err != nil { + log.Warn("eventmod:InsertOneEvent db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// InsertManyEvent 批量插入事件 +func InsertManyEvent(ctx context.Context, events *EventList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, events); err != nil { + log.Warn("eventmod:InsertManyEvent db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneEvent 删除一个事件 +func DelOneEvent(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("eventmod:DelOneEvent db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyEvent 批量删除事件 +func DelManyEvent(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("eventmod:DelManyEvent db.DeleteMany failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneEvent 查找一个事件 +func FindOneEvent(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*Event, *srvdef.SrvErr) { + var event Event + if err := db.FindOneEx(ctx, &event, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("eventmod:FindOneEvent db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &event, nil +} + +// FindManyEvent 查找多个事件 +func FindManyEvent(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (EventList, *srvdef.SrvErr) { + data := make(EventList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("eventmod:FindManyEvent db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountEvent 统计事件数量 +func CountEvent(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("eventmod:CountEvent db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneEvent 更新一个事件 +func UpdateOneEvent(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("eventmod:UpdateOneEvent db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("eventmod:UpdateOneEvent db.UpdateOne not matched", + log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyEvent 批量更新事件 +func UpdateManyEvent(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("eventmod:UpdateManyEvent db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("eventmod:UpdateManyEvent db.UpdateMany not matched", + log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpsertOneEvent 插入或更新一个事件 +func UpsertOneEvent(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + opts := options.Update().SetUpsert(true) + if _, err := db.UpdateOne(ctx, filter, update, opts); err != nil { + log.Warn("eventmod:UpsertOneEvent db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} diff --git a/pub/models/eventmod/tag.go b/pub/models/eventmod/tag.go new file mode 100644 index 0000000..12047d7 --- /dev/null +++ b/pub/models/eventmod/tag.go @@ -0,0 +1,31 @@ +package eventmod + +type TagEvent struct { + ID string // + CreatedAt string // 创建事件 + RetryCount string // 重试次数 + Status string // + Data string // + EventType string // + EventId string // + UpdatedAt string // 修改时间 +} + +var FN struct { + Event TagEvent +} +var TN struct { + Event string +} + +func init() { + FN.Event.ID = "_id" + FN.Event.CreatedAt = "createdAt" + FN.Event.RetryCount = "retryCount" + FN.Event.Status = "status" + FN.Event.Data = "data" + FN.Event.EventType = "eventType" + FN.Event.EventId = "eventId" + FN.Event.UpdatedAt = "updatedAt" + TN.Event = "event" +} diff --git a/pub/models/eventmod/type.go b/pub/models/eventmod/type.go new file mode 100644 index 0000000..7ddfa74 --- /dev/null +++ b/pub/models/eventmod/type.go @@ -0,0 +1,32 @@ +package eventmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type EventStatus int + +const ( + Default EventStatus = 0 // 默认状态,等待上报 + Success EventStatus = 1 // 成功 + Failure EventStatus = 2 // 失败 +) + +//go:generate fox mongo --model +//go:generate fox mongo --tag +//go:generate fox mongo --update + +// @table:event +// @title:事件表 +type Event struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + EventId string `json:"eventId" bson:"eventId"` + EventType string `json:"eventType" bson:"eventType"` + Data string `json:"data" bson:"data"` + Status EventStatus `json:"status" bson:"status"` // + RetryCount int `json:"retryCount" bson:"retryCount"` // 重试次数 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建事件 + UpdatedAt time.Time `bson:"updatedAt"` // 修改时间 +} diff --git a/pub/models/eventmod/update.go b/pub/models/eventmod/update.go new file mode 100644 index 0000000..f52d492 --- /dev/null +++ b/pub/models/eventmod/update.go @@ -0,0 +1,53 @@ +package eventmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type EventList []*Event +type EventUpdate struct { + RetryCount *int `json:"retryCount" bson:"retryCount,omitempty"` // 重试次数 + Status *EventStatus `json:"status" bson:"status,omitempty"` // + Data *string `json:"data" bson:"data,omitempty"` // + EventType *string `json:"eventType" bson:"eventType,omitempty"` // + EventId *string `json:"eventId" bson:"eventId,omitempty"` // + UpdatedAt *time.Time `json:"" bson:"updatedAt,omitempty"` // 修改时间 +} +type EventQuery struct { + ID *primitive.ObjectID `json:"" bson:"_id,omitempty"` // + RetryCount *int `json:"retryCount" bson:"retryCount,omitempty"` // 重试次数 + Status *EventStatus `json:"status" bson:"status,omitempty"` // + Data *string `json:"data" bson:"data,omitempty"` // + EventType *string `json:"eventType" bson:"eventType,omitempty"` // + EventId *string `json:"eventId" bson:"eventId,omitempty"` // + UpdatedAt *time.Time `json:"" bson:"updatedAt,omitempty"` // 修改时间 +} + +func (query *EventQuery) GetQueryFilter() bson.M { + filter := bson.M{} + if query.ID != nil { + filter[FN.Event.ID] = *query.ID + } + if query.RetryCount != nil { + filter[FN.Event.RetryCount] = *query.RetryCount + } + if query.Status != nil { + filter[FN.Event.Status] = *query.Status + } + if query.Data != nil { + filter[FN.Event.Data] = bson.M{"$regex": primitive.Regex{Pattern: *query.Data, Options: "i"}} + } + if query.EventType != nil { + filter[FN.Event.EventType] = bson.M{"$regex": primitive.Regex{Pattern: *query.EventType, Options: "i"}} + } + if query.EventId != nil { + filter[FN.Event.EventId] = bson.M{"$regex": primitive.Regex{Pattern: *query.EventId, Options: "i"}} + } + if query.UpdatedAt != nil { + filter[FN.Event.UpdatedAt] = *query.UpdatedAt + } + return filter +} diff --git a/pub/models/homebannermod/banner.go b/pub/models/homebannermod/banner.go new file mode 100644 index 0000000..a7d141f --- /dev/null +++ b/pub/models/homebannermod/banner.go @@ -0,0 +1,135 @@ +package homebannermod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 home_banner 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.HomeBanner) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.HomeBanner.Status, Value: 1}, + {Key: FN.HomeBanner.UpdatedAt, Value: -1}, + {Key: FN.HomeBanner.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeBanner.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeBanner.CreatedAt, Value: -1}, + {Key: FN.HomeBanner.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeBanner.Status, Value: 1}, + {Key: FN.HomeBanner.CreatedAt, Value: -1}, + {Key: FN.HomeBanner.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeBanner.Status, Value: 1}, + {Key: FN.HomeBanner.Rank, Value: -1}, + {Key: FN.HomeBanner.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(homebannermod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOneHomeBanner 插入一条首页 banner 数据 +func InsertOneHomeBanner(ctx context.Context, doc *HomeBanner) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.HomeBanner.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("homebannermod:InsertOneHomeBanner failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneHomeBanner 删除一条首页 banner 数据 +func DelOneHomeBanner(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("homebannermod:DelOneHomeBanner failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneHomeBanner 查询一条首页 banner 数据 +func FindOneHomeBanner(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*HomeBanner, *srvdef.SrvErr) { + var doc HomeBanner + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("homebannermod:FindOneHomeBanner failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyHomeBanner 按条件查询多条首页 banner 数据 +func FindManyHomeBanner(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (HomeBannerList, *srvdef.SrvErr) { + data := make(HomeBannerList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("homebannermod:FindManyHomeBanner failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountHomeBanner 统计满足条件的首页 banner 数量 +func CountHomeBanner(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("homebannermod:CountHomeBanner failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneHomeBanner 更新一条首页 banner 数据 +func UpdateOneHomeBanner(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("homebannermod:UpdateOneHomeBanner failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/homebannermod/tag.go b/pub/models/homebannermod/tag.go new file mode 100644 index 0000000..caa5413 --- /dev/null +++ b/pub/models/homebannermod/tag.go @@ -0,0 +1,41 @@ +package homebannermod + +// TagHomeBanner 保存 home_banner 集合字段名 +type TagHomeBanner struct { + Id string + Name string + Image string + VerticalImage string + Url string + Rank string + Status string + CreatedById string + CreatedByName string + UpdatedByName string + CreatedAt string + UpdatedAt string +} + +var FN struct { + HomeBanner TagHomeBanner +} + +var TN struct { + HomeBanner string +} + +func init() { + FN.HomeBanner.Id = "_id" + FN.HomeBanner.Name = "name" + FN.HomeBanner.Image = "image" + FN.HomeBanner.VerticalImage = "verticalImage" + FN.HomeBanner.Url = "url" + FN.HomeBanner.Rank = "rank" + FN.HomeBanner.Status = "status" + FN.HomeBanner.CreatedById = "createdById" + FN.HomeBanner.CreatedByName = "createdByName" + FN.HomeBanner.UpdatedByName = "updatedByName" + FN.HomeBanner.CreatedAt = "createdAt" + FN.HomeBanner.UpdatedAt = "updatedAt" + TN.HomeBanner = "home_banner" +} diff --git a/pub/models/homebannermod/type.go b/pub/models/homebannermod/type.go new file mode 100644 index 0000000..4d9fe3b --- /dev/null +++ b/pub/models/homebannermod/type.go @@ -0,0 +1,24 @@ +package homebannermod + +import "time" + +// HomeBanner @table: home_banner +// +// HomeBanner 首页 banner 配置集合模型 +type HomeBanner struct { + Id int64 `json:"id" bson:"_id,omitempty"` // ID + Name string `json:"name" bson:"name"` // 名称 + Image string `json:"image" bson:"image"` // 图片 + VerticalImage string `json:"verticalImage" bson:"verticalImage"` // 竖版图片 + Url string `json:"url" bson:"url"` // URL + Rank int64 `json:"rank" bson:"rank"` // 排序 + Status bool `json:"status" bson:"status"` // 状态 true-启用 false-停用 + CreatedById int64 `json:"createdById" bson:"createdById"` // 创建人ID + CreatedByName string `json:"createdByName" bson:"createdByName"` // 创建人名称 + UpdatedByName string `json:"updatedByName" bson:"updatedByName"` // 修改人名称 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// HomeBannerList 首页 banner 列表 +type HomeBannerList []*HomeBanner diff --git a/pub/models/hotnavmod/hotnav.go b/pub/models/hotnavmod/hotnav.go new file mode 100644 index 0000000..6adc314 --- /dev/null +++ b/pub/models/hotnavmod/hotnav.go @@ -0,0 +1,101 @@ +package hotnavmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.HotNav) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.HotNav.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HotNav.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(hotnavmod:Init) Create HotNav Index failed! err:%+v:", err)) + } +} + +func InsertOneHotNav(ctx context.Context, doc *HotNav) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, doc); err != nil { + log.Warn("hotnavmod:InsertOneHotNav db.InsertOne failed", + log.E(err), log.Any("doc", doc)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +func DelOneHotNav(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("hotnavmod:DelOneHotNav db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +func FindOneHotNav(ctx context.Context, filter bson.M) (*HotNav, *srvdef.SrvErr) { + var doc HotNav + if err := db.FindOneEx(ctx, &doc, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("hotnavmod:FindOneHotNav db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +func FindManyHotNav(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (HotNavList, *srvdef.SrvErr) { + data := make(HotNavList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("hotnavmod:FindManyHotNav db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +func CountHotNav(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("hotnavmod:CountHotNav db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +func UpdateOneHotNav(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("hotnavmod:UpdateOneHotNav db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("hotnavmod:UpdateOneHotNav not matched", + log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/hotnavmod/tag.go b/pub/models/hotnavmod/tag.go new file mode 100644 index 0000000..f8c821a --- /dev/null +++ b/pub/models/hotnavmod/tag.go @@ -0,0 +1,34 @@ +package hotnavmod + +type TagHotNav struct { + CreatedAt string + Icon string + Id string + Intro string + Sort string + Status string + Title string + UpdatedAt string + Url string +} + +var FN struct { + HotNav TagHotNav +} + +var TN struct { + HotNav string +} + +func init() { + FN.HotNav.CreatedAt = "createdAt" + FN.HotNav.Icon = "icon" + FN.HotNav.Id = "_id" + FN.HotNav.Intro = "intro" + FN.HotNav.Sort = "sort" + FN.HotNav.Status = "status" + FN.HotNav.Title = "title" + FN.HotNav.UpdatedAt = "updatedAt" + FN.HotNav.Url = "url" + TN.HotNav = "hot_nav" +} diff --git a/pub/models/hotnavmod/type.go b/pub/models/hotnavmod/type.go new file mode 100644 index 0000000..20c2357 --- /dev/null +++ b/pub/models/hotnavmod/type.go @@ -0,0 +1,19 @@ +package hotnavmod + +import "time" + +// HotNav 热门导航配置 +// @table:hot_nav +type HotNav struct { + Id int64 `json:"id" bson:"_id,omitempty"` // ID + Title string `json:"title" bson:"title"` // 标题 + Intro string `json:"intro" bson:"intro"` // 介绍 + Url string `json:"url" bson:"url"` // 链接 + Icon string `json:"icon" bson:"icon"` // 图标 + Sort int64 `json:"sort" bson:"sort"` // 排序 + Status bool `json:"status" bson:"status"` // 是否启用 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +type HotNavList []*HotNav diff --git a/pub/models/ipblackmod/ipblack.go b/pub/models/ipblackmod/ipblack.go new file mode 100644 index 0000000..c60960d --- /dev/null +++ b/pub/models/ipblackmod/ipblack.go @@ -0,0 +1,109 @@ +package ipblackmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.IPBlackModel) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.IPBlackModel.Ip, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(IPBlackModel:InitIPBlackModel) Create IPBlackModel Index failed! err:%+v:", err)) + } +} + +// CountIPBlackModel 统计活动数量 +func CountIPBlackModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("IPBlackModelmod:CountIPBlackModel db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// InsertOneIPBlackModel 插入一条活动 +func InsertOneIPBlackModel(ctx context.Context, IPBlackModel *IPBlackModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, IPBlackModel); err != nil { + log.Info("IPBlackModelmod:InsertOneIPBlackModel db.InsertOne failed", + log.E(err), log.Any("IPBlackModel", IPBlackModel)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneIPBlackModel 删除一个活动 +func DelOneIPBlackModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("IPBlackModelmod:DelOneIPBlackModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneIPBlackModel 更新一个IPBlackModel +func UpdateOneIPBlackModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("IPBlackModelmod:UpdateOneIPBlackModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("IPBlackModelmod:UpdateOneIPBlackModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ObjectNotExist, "", "") + } + return nil +} + +// FindManyIPBlackModel 查找一批活动数据 +func FindManyIPBlackModel(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (IPBlackModelList, *srvdef.SrvErr) { + data := make(IPBlackModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("IPBlackModelmod:FindManyAcivities db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindOneIPBlackModel 查找一个媒体 +func FindOneIPBlackModel(ctx context.Context, filter bson.M) ( + *IPBlackModel, *srvdef.SrvErr) { + var IPBlackModel IPBlackModel + if err := db.FindOneEx(ctx, &IPBlackModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("IPBlackModel:FindOneIPBlackModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ObjectDayStatDoesNotExist, "", "") + } + return &IPBlackModel, nil +} \ No newline at end of file diff --git a/pub/models/ipblackmod/tag.go b/pub/models/ipblackmod/tag.go new file mode 100755 index 0000000..ff235ed --- /dev/null +++ b/pub/models/ipblackmod/tag.go @@ -0,0 +1,26 @@ +package ipblackmod + +type TagIPBlackModel struct { + CreatedAt string + ID string + Ip string + Desc string + UpdatedAt string +} + +var FN struct { + IPBlackModel TagIPBlackModel +} + +var TN struct { + IPBlackModel string +} + +func init() { + FN.IPBlackModel.CreatedAt = "createdAt" + FN.IPBlackModel.ID = "_id" + FN.IPBlackModel.Ip = "ip" + FN.IPBlackModel.UpdatedAt = "updatedAt" + FN.IPBlackModel.Desc = "desc" + TN.IPBlackModel = "ip_black" +} \ No newline at end of file diff --git a/pub/models/ipblackmod/type.go b/pub/models/ipblackmod/type.go new file mode 100644 index 0000000..be9f72f --- /dev/null +++ b/pub/models/ipblackmod/type.go @@ -0,0 +1,18 @@ +package ipblackmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 资源同步 +// @table:ip_black +type IPBlackModel struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Ip string `json:"ip" bson:"ip"` + Desc string `json:"desc" bson:"desc"` // 备注 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} +type IPBlackModelList []*IPBlackModel \ No newline at end of file diff --git a/pub/models/ipdailystatmod/ipdailystat.go b/pub/models/ipdailystatmod/ipdailystat.go new file mode 100644 index 0000000..c9678d0 --- /dev/null +++ b/pub/models/ipdailystatmod/ipdailystat.go @@ -0,0 +1,62 @@ +package ipdailystatmod + +import ( + "context" + "time" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.IpDailyStat) + + // 唯一索引:(appId, date) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.IpDailyStat.AppId, Value: 1}, + {Key: FN.IpDailyStat.Date, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + _, _ = db.CreateIndex(ctx, indexes) +} + +// UpsertCount 写入或更新指定 appId 当天的 IP 去重数 +func UpsertCount(ctx context.Context, appId int64, date string, count int64) error { + filter := bson.M{ + FN.IpDailyStat.AppId: appId, + FN.IpDailyStat.Date: date, + } + update := bson.M{ + "$set": bson.M{ + FN.IpDailyStat.Count: count, + FN.IpDailyStat.UpdatedAt: time.Now(), + }, + } + _, err := db.UpsertOne(ctx, filter, update) + return err +} + +// FindCount 查询指定 appId 在某天的 IP 去重数,未找到返回 0 +func FindCount(ctx context.Context, appId int64, date string) (int64, error) { + filter := bson.M{ + FN.IpDailyStat.AppId: appId, + FN.IpDailyStat.Date: date, + } + var stat IpDailyStat + err := db.FindOneEx(ctx, &stat, filter) + if err != nil { + if err == mongo.ErrNoDocuments { + return 0, nil + } + return 0, err + } + return stat.Count, nil +} diff --git a/pub/models/ipdailystatmod/tag.go b/pub/models/ipdailystatmod/tag.go new file mode 100644 index 0000000..f85fbc6 --- /dev/null +++ b/pub/models/ipdailystatmod/tag.go @@ -0,0 +1,25 @@ +package ipdailystatmod + +type tagIpDailyStat struct { + AppId string + Date string + Count string + UpdatedAt string +} + +var FN struct { + IpDailyStat tagIpDailyStat +} + +var TN struct { + IpDailyStat string +} + +func init() { + FN.IpDailyStat.AppId = "appId" + FN.IpDailyStat.Date = "date" + FN.IpDailyStat.Count = "count" + FN.IpDailyStat.UpdatedAt = "updatedAt" + + TN.IpDailyStat = "ip_daily_stat" +} diff --git a/pub/models/ipdailystatmod/type.go b/pub/models/ipdailystatmod/type.go new file mode 100644 index 0000000..54bd3c5 --- /dev/null +++ b/pub/models/ipdailystatmod/type.go @@ -0,0 +1,13 @@ +package ipdailystatmod + +import "time" + +// IpDailyStat 每日访客 IP 去重统计 +// app 端写入,web 端读取,通过共享 MongoDB 跨进程共享数据。 +// @table:ip_daily_stat +type IpDailyStat struct { + AppId int64 `bson:"appId"` // 站点 ID + Date string `bson:"date"` // 日期,格式 "20060102" + Count int64 `bson:"count"` // 当日去重 IP 数 + UpdatedAt time.Time `bson:"updatedAt"` // 最后同步时间 +} diff --git a/pub/models/m3u8requestlogmod/m3u8_request_log.go b/pub/models/m3u8requestlogmod/m3u8_request_log.go new file mode 100644 index 0000000..bec15ce --- /dev/null +++ b/pub/models/m3u8requestlogmod/m3u8_request_log.go @@ -0,0 +1,73 @@ +package m3u8requestlogmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.M3U8RequestLog) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.VideoId, Value: 1}, + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.M3U8Path, Value: 1}, + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.ClientIP, Value: 1}, + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.Host, Value: 1}, + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.Status, Value: 1}, + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.M3U8RequestLog.TokenOK, Value: 1}, + {Key: FN.M3U8RequestLog.PathOK, Value: 1}, + {Key: FN.M3U8RequestLog.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(m3u8requestlogmod:Init) Create M3U8RequestLog Index failed! err:%+v:", err)) + } +} + +func InsertOneM3U8RequestLog(ctx context.Context, doc *M3U8RequestLog) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, doc); err != nil { + log.Warn("m3u8requestlogmod:InsertOneM3U8RequestLog db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} diff --git a/pub/models/m3u8requestlogmod/tag.go b/pub/models/m3u8requestlogmod/tag.go new file mode 100644 index 0000000..3aa5462 --- /dev/null +++ b/pub/models/m3u8requestlogmod/tag.go @@ -0,0 +1,66 @@ +package m3u8requestlogmod + +type TagM3U8RequestLog struct { + CDNDomain string + CFConnectingIP string + ClientIP string + CostMs string + CreatedAt string + Error string + Headers string + Host string + Id string + IsOverseas string + M3U8Path string + PathOK string + Query string + RawQuery string + Referer string + RemoteAddr string + RequestURI string + Status string + T string + Token string + TokenOK string + UserAgent string + VideoId string + XForwardedFor string + XRealIP string +} + +var FN struct { + M3U8RequestLog TagM3U8RequestLog +} + +var TN struct { + M3U8RequestLog string +} + +func init() { + FN.M3U8RequestLog.CDNDomain = "cdnDomain" + FN.M3U8RequestLog.CFConnectingIP = "cfConnectingIP" + FN.M3U8RequestLog.ClientIP = "clientIP" + FN.M3U8RequestLog.CostMs = "costMs" + FN.M3U8RequestLog.CreatedAt = "createdAt" + FN.M3U8RequestLog.Error = "error" + FN.M3U8RequestLog.Headers = "headers" + FN.M3U8RequestLog.Host = "host" + FN.M3U8RequestLog.Id = "_id" + FN.M3U8RequestLog.IsOverseas = "isOverseas" + FN.M3U8RequestLog.M3U8Path = "m3u8Path" + FN.M3U8RequestLog.PathOK = "pathOK" + FN.M3U8RequestLog.Query = "query" + FN.M3U8RequestLog.RawQuery = "rawQuery" + FN.M3U8RequestLog.Referer = "referer" + FN.M3U8RequestLog.RemoteAddr = "remoteAddr" + FN.M3U8RequestLog.RequestURI = "requestURI" + FN.M3U8RequestLog.Status = "status" + FN.M3U8RequestLog.T = "t" + FN.M3U8RequestLog.Token = "token" + FN.M3U8RequestLog.TokenOK = "tokenOK" + FN.M3U8RequestLog.UserAgent = "userAgent" + FN.M3U8RequestLog.VideoId = "videoId" + FN.M3U8RequestLog.XForwardedFor = "xForwardedFor" + FN.M3U8RequestLog.XRealIP = "xRealIP" + TN.M3U8RequestLog = "m3u8_request_log" +} diff --git a/pub/models/m3u8requestlogmod/type.go b/pub/models/m3u8requestlogmod/type.go new file mode 100644 index 0000000..338ebb6 --- /dev/null +++ b/pub/models/m3u8requestlogmod/type.go @@ -0,0 +1,37 @@ +package m3u8requestlogmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// M3U8RequestLog records every HLS playlist request for hotlink analysis. +// @table:m3u8_request_log +type M3U8RequestLog struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + CreatedAt time.Time `bson:"createdAt"` + Host string `bson:"host"` + RequestURI string `bson:"requestURI"` + M3U8Path string `bson:"m3u8Path"` + RawQuery string `bson:"rawQuery"` + Query map[string][]string `bson:"query"` + Headers map[string][]string `bson:"headers"` + VideoId int64 `bson:"videoId"` + T int64 `bson:"t"` + Token string `bson:"token"` + ClientIP string `bson:"clientIP"` + RemoteAddr string `bson:"remoteAddr"` + XForwardedFor string `bson:"xForwardedFor"` + XRealIP string `bson:"xRealIP"` + CFConnectingIP string `bson:"cfConnectingIP"` + UserAgent string `bson:"userAgent"` + Referer string `bson:"referer"` + IsOverseas bool `bson:"isOverseas"` + CDNDomain string `bson:"cdnDomain"` + TokenOK bool `bson:"tokenOK"` + PathOK bool `bson:"pathOK"` + Status string `bson:"status"` + Error string `bson:"error"` + CostMs int64 `bson:"costMs"` +} diff --git a/pub/models/models.go b/pub/models/models.go new file mode 100644 index 0000000..321b2f8 --- /dev/null +++ b/pub/models/models.go @@ -0,0 +1,202 @@ +package models + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/actress" + "videoweb/pub/models/announcementmod" + "videoweb/pub/models/cfg" + "videoweb/pub/models/commentlikemod" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/contactusmod" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/detailnavmod" + "videoweb/pub/models/esmod" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/homebannermod" + "videoweb/pub/models/hotnavmod" + "videoweb/pub/models/ipblackmod" + "videoweb/pub/models/ipdailystatmod" + "videoweb/pub/models/m3u8requestlogmod" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/oplog" + "videoweb/pub/models/playletbackflowstatmod" + "videoweb/pub/models/playletcategorymod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletfavoritemod" + "videoweb/pub/models/playlethomeconfigmod" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/questionnairemod" + "videoweb/pub/models/richtextcontentmod" + "videoweb/pub/models/seopushmod" + "videoweb/pub/models/syncfilemod" + "videoweb/pub/models/translationcategorymod" + "videoweb/pub/models/translationmod" + "videoweb/pub/models/userlogin" + "videoweb/pub/models/usermod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocollectionmediamod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videofavoritemod" + "videoweb/pub/mongos" + + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videolikemod" + "videoweb/pub/models/videotagmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func Init() { + log.Info("models init start..") + ctx := context.Background() + cfg.Init(ctx) + mwacc.Init(ctx) + oplog.Init(ctx) + m3u8requestlogmod.Init(ctx) + + videocategorymod.Init(ctx) + videoinformationmod.Init(ctx) + videofavoritemod.Init(ctx) + videolikemod.Init(ctx) + videotagmod.Init(ctx) + + translationcategorymod.Init(ctx) + translationmod.Init(ctx) + contenttagmod.Init(ctx) + playletcategorymod.Init(ctx) + playlethomeconfigmod.Init(ctx) + playlettagmod.Init(ctx) + playletthememod.Init(ctx) + playletmod.Init(ctx) + playletbackflowstatmod.Init(ctx) + playletchaptermod.Init(ctx) + playletlikemod.Init(ctx) + playletfavoritemod.Init(ctx) + initWatchHistory(ctx) + videocategorymod.Init(ctx) + videocreatermod.Init(ctx) + videotagmod.Init(ctx) + videoinformationmod.Init(ctx) + videocreatermod.Init(ctx) + syncfilemod.Init(ctx) + seopushmod.Init(ctx) + richtextcontentmod.Init(ctx) + announcementmod.Init(ctx) + commentmod.Init(ctx) + commentlikemod.Init(ctx) + videocollectionmediamod.Init(ctx) + ipblackmod.Init(ctx) + esmod.Init(ctx) + actress.Init(ctx) + contactusmod.Init(ctx) + detailnavmod.Init(ctx) + hotnavmod.Init(ctx) + homebannermod.Init(ctx) + eventmod.Init(ctx) + usermod.Init(ctx) + userlogin.Init(ctx) + questionnairemod.Init(ctx) + ipdailystatmod.Init(ctx) + log.Info("models init end") +} + +const ( + WatchHistoryTypePlaylet int64 = 1 + WatchHistoryTypeVideo int64 = 2 + WatchHistoryRetentionDays = 60 + watchHistoryCollectionName = "watch_history" + WatchHistoryCollectionName = watchHistoryCollectionName + WatchHistoryUserID = "userId" + WatchHistoryDataType = "dataType" + WatchHistoryContentID = "contentId" + WatchHistoryChapterID = "chapterId" + WatchHistoryEpisode = "episode" + WatchHistoryCreatedAt = "createdAt" + WatchHistoryUpdatedAt = "updatedAt" +) + +// WatchHistory 保存当前用户最近观看的内容 +type WatchHistory struct { + UserID int64 `bson:"userId"` + DataType int64 `bson:"dataType"` + ContentID int64 `bson:"contentId"` + ChapterID int64 `bson:"chapterId"` + Episode int64 `bson:"episode"` + CreatedAt time.Time `bson:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt"` +} + +var watchHistoryColl *mongos.Collection + +// initWatchHistory 初始化观看历史及 60 天 TTL 索引 +func initWatchHistory(ctx context.Context) { + watchHistoryColl = mongos.Shop.Coll(watchHistoryCollectionName) + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: WatchHistoryUserID, Value: 1}, {Key: WatchHistoryDataType, Value: 1}, {Key: WatchHistoryContentID, Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: WatchHistoryUserID, Value: 1}, {Key: WatchHistoryDataType, Value: 1}, {Key: WatchHistoryUpdatedAt, Value: -1}, {Key: "_id", Value: -1}}}, + {Keys: bson.D{{Key: WatchHistoryUpdatedAt, Value: 1}}, Options: options.Index().SetExpireAfterSeconds(WatchHistoryRetentionDays * 24 * 60 * 60)}, + } + if _, err := watchHistoryColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(models:initWatchHistory) Create Index failed! err:%+v:", err)) + } +} + +// TouchWatchHistory 从已验证的播放清单刷新历史 +func TouchWatchHistory(ctx context.Context, item WatchHistory) *srvdef.SrvErr { + filter := bson.M{WatchHistoryUserID: item.UserID, WatchHistoryDataType: item.DataType, WatchHistoryContentID: item.ContentID} + if item.DataType == WatchHistoryTypePlaylet { + sameChapter := bson.M{WatchHistoryUserID: item.UserID, WatchHistoryDataType: item.DataType, WatchHistoryContentID: item.ContentID, WatchHistoryChapterID: item.ChapterID} + result, err := watchHistoryColl.UpdateOne(ctx, sameChapter, bson.M{"$set": bson.M{WatchHistoryUpdatedAt: item.UpdatedAt, WatchHistoryEpisode: item.Episode}}) + if err != nil { + log.Warn("TouchWatchHistory failed", log.Any("filter", sameChapter), log.E(err)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount > 0 { + return nil + } + } + update := bson.M{ + "$set": bson.M{WatchHistoryChapterID: item.ChapterID, WatchHistoryEpisode: item.Episode, WatchHistoryUpdatedAt: item.UpdatedAt}, + "$setOnInsert": bson.M{WatchHistoryUserID: item.UserID, WatchHistoryDataType: item.DataType, WatchHistoryContentID: item.ContentID, WatchHistoryCreatedAt: item.CreatedAt}, + } + if _, err := watchHistoryColl.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)); err != nil { + if mongo.IsDuplicateKeyError(err) { + _, err = watchHistoryColl.UpdateOne(ctx, filter, bson.M{"$set": update["$set"]}) + if err == nil { + return nil + } + } + log.Warn("TouchWatchHistory failed", log.Any("filter", filter), log.E(err)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// AggregateWatchHistory 聚合查询观看历史 +func AggregateWatchHistory(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := watchHistoryColl.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("AggregateWatchHistory failed", log.Any("pipeline", pipeline), log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// DeleteWatchHistory 删除当前用户指定类型的观看历史 +func DeleteWatchHistory(ctx context.Context, userID, dataType int64) (int64, *srvdef.SrvErr) { + filter := bson.M{WatchHistoryUserID: userID, WatchHistoryDataType: dataType} + count, err := watchHistoryColl.DeleteMany(ctx, filter) + if err != nil { + log.Warn("DeleteWatchHistory failed", log.Any("filter", filter), log.E(err)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} diff --git a/pub/models/mwacc/acc.go b/pub/models/mwacc/acc.go new file mode 100644 index 0000000..0832450 --- /dev/null +++ b/pub/models/mwacc/acc.go @@ -0,0 +1,170 @@ +package mwacc + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/errors" + sErr "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 过滤查询数量 +func AccCountByFilter(ctx context.Context, filters primitive.M, opts ...*options.CountOptions) (c int64, err error) { + c, err = mwAccColl.Count(ctx, filters) + return +} + +// 过滤查询账号 +func FindOneAccByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOneOptions) (*MwAcc, error) { + var res MwAcc + err := mwAccColl.FindOne(ctx, &res, filters, opts...) + if err != nil { + log.Error("mwacc FindOneAccByFilter err", log.Any("filters", filters), log.E(err)) + } + return &res, err +} + +// 过滤更新 +func UpOneAccByFilter(ctx context.Context, query primitive.M, update primitive.M) (err error) { + _, err = mwAccColl.UpdateOne(ctx, query, primitive.M{"$set": update}) + return +} + +// 过滤删除角色 +func DelAccByFilter(ctx context.Context, filters primitive.M) (err error) { + _, err = mwAccColl.DeleteOne(ctx, filters) + if err != nil { + log.Error("mwacc DelAccByFilter err", log.Any("filters", filters), log.E(err)) + } + return +} + +// // 更新最后登录时间 +// func UpAccLastTimeById(ctx context.Context, id int64) (*mongo.UpdateResult, error) { +// ret, err := mwAccColl.UpdateOne(ctx, primitive.M{FN.MwAcc.Id: id}, primitive.M{"$set": primitive.M{FN.MwAcc.LastTime: time.Now()}}) +// if err != nil { +// log.Error("mvacc UpLastTimeById err", log.Any(FN.MwAcc.Id, id), log.E(err)) +// } +// return ret, err +// } + +// 绑定二维码 +func BindAccQurl(ctx context.Context, acc string) (ok bool, err error) { + + res, dataErr := FindOneAccByFilter(ctx, primitive.M{ + FN.MwAcc.Acc: acc, + }) + + if dataErr != nil || res.Id == 0 { + err = sErr.MakeWithMsg(sErr.Logo, "用户不存在!") + return false, err + } + + if res.IsEnb == false { + err = sErr.MakeWithMsg(sErr.UserPermission, "用户被禁用!") + return false, err + } + err = UpOneAccByFilter(ctx, primitive.M{ + FN.MwAcc.Acc: acc, + }, primitive.M{ + FN.MwAcc.IsBase32: true, + }) + + if err != nil { + return false, err + } + return true, err +} + +// 插入角色 +func AccAdd(ctx context.Context, insertReq *AccInsertDoc) (id int64, ok bool, err error) { + + insertReq.Id = int64(mongos.Lid.MwAcc.Get()) + insertReq.CreatedAt = time.Now() + + _, err = mwAccColl.InsertOne(ctx, insertReq) + if err != nil { + log.ZapLog.Error(fmt.Sprintf("InsertRole mongo error %v", err)) + err = errors.MakeWithErr(errors.DBInsert, err) + return 0, false, err + } + return insertReq.Id, true, nil +} + +// 获取角色列表 +func GetAccList(ctx context.Context, search primitive.M, sort []primitive.M) ( + total int64, data []*AccGetListRec, err error) { + + var countPipe []primitive.M + countPipe = append(countPipe, search, bson.M{"$count": "total"}) + var aggCount AggStatCount + err = mwAccColl.ObjectAggregate(ctx, &aggCount, countPipe) + if err != nil { + return 0, nil, err + } + total = aggCount.Total + + var pip []primitive.M + pip = append(pip, search) + pip = append(pip, sort...) + pip = append(pip, + primitive.M{ + "$lookup": primitive.M{ + "localField": FN.MwAcc.RoleId, + "from": TN.MwRole, + "foreignField": FN.MwRole.Id, + "as": "roles", + }, + }, + primitive.M{"$unwind": primitive.M{ + "path": "$roles", + "preserveNullAndEmptyArrays": true, + }, + }, + primitive.M{ + "$project": primitive.M{ + FN.MwAcc.Id: 1, + FN.MwAcc.UserName: 1, + FN.MwAcc.Acc: 1, + FN.MwAcc.Pwd: 1, + FN.MwAcc.Email: 1, + FN.MwAcc.RoleId: 1, + FN.MwAcc.RoleName: "$roles.name", + FN.MwAcc.Base32: 1, + FN.MwAcc.IsBase32: 1, + // mwacc.FN.MwAcc.LastTime: 1, + FN.MwAcc.IsEnb: 1, + FN.MwAcc.Feedback: 1, + FN.MwAcc.CreatedAt: 1, + FN.MwAcc.UpdatedAt: 1, + FN.MwAcc.LimitIP: 1, + }, + }) + + err = mwAccColl.Aggregate(ctx, &data, pip) + if err != nil { + err = errors.MakeWithErr(errors.DBQuery, err) + return + } + return +} + +// 过滤查询账号 +func FindManyAccByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOptions) ([]*MwAcc, *srvdef.SrvErr) { + var res []*MwAcc + err := mwAccColl.Find(ctx, &res, filters, opts...) + if err != nil { + log.Error("mwacc FindOneAccByFilter err", log.Any("filters", filters), log.E(err)) + return res, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + log.Info("success") + + return res, nil +} diff --git a/pub/models/mwacc/dbhelper.go b/pub/models/mwacc/dbhelper.go new file mode 100644 index 0000000..48d7d4a --- /dev/null +++ b/pub/models/mwacc/dbhelper.go @@ -0,0 +1,13 @@ +package mwacc + +import "videoweb/pub/mongos" + +var ( + mwAccColl *mongos.Collection = nil + mwRoleColl *mongos.Collection = nil + mwAccTokenColl *mongos.Collection = nil +) + +type AggStatCount struct { + Total int64 `bson:"total"` +} diff --git a/pub/models/mwacc/drop.go b/pub/models/mwacc/drop.go new file mode 100644 index 0000000..352a218 --- /dev/null +++ b/pub/models/mwacc/drop.go @@ -0,0 +1,66 @@ +package mwacc + +import ( + "context" + "fmt" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + mwAccColl = mongos.Shop.Coll(TN.MwAcc) + mwRoleColl = mongos.Shop.Coll(TN.MwRole) + mwAccTokenColl = mongos.Shop.Coll(TN.MwAccToken) + initIndex(ctx) + return +} + +// initIndex 初始化索引 +func initIndex(ctx context.Context) { + initAccIndex(ctx) + initRoleIndex(ctx) + initAccTokenIndex(ctx) +} + +func initAccIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.MwAcc.Id, 1}, + }, + }, + } + if _, err := mwAccColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(mwacc:initAccIndex) Create mwacc Index failed! err:%+v:", err)) + } +} + +func initRoleIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.MwRole.Id, 1}, + }, + // Options: options.Index().SetUnique(true), + }, + } + if _, err := mwRoleColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(mwacc:initRoleIndex) Create mwacc Index failed! err:%+v:", err)) + } +} +func initAccTokenIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.MwAccToken.Id, 1}, + }, + // Options: options.Index().SetUnique(true), + }, + } + if _, err := mwAccTokenColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(mwacc:initAccTokenIndex) Create mwacc Index failed! err:%+v:", err)) + } +} diff --git a/pub/models/mwacc/fun.go b/pub/models/mwacc/fun.go new file mode 100644 index 0000000..5b415da --- /dev/null +++ b/pub/models/mwacc/fun.go @@ -0,0 +1,61 @@ +package mwacc + +import ( + "strconv" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +func MakeSearch(keyWords string, status int64, page pubdef.Page) (searchs bson.M) { + var search []bson.M + if len(keyWords) > 0 { + userId, _ := strconv.ParseInt(keyWords, 10, 64) + search = append(search, bson.M{FN.MwAcc.Id: userId}) + search = append(search, bson.M{FN.MwAcc.UserName: bson.M{"$regex": keyWords}}) + search = append(search, bson.M{FN.MwAcc.Acc: bson.M{"$regex": keyWords}}) + search = append(search, bson.M{FN.MwAcc.Email: bson.M{"$regex": keyWords}}) + } + + match := bson.M{} + if len(search) > 0 { + match["$or"] = search + } + + if status > 0 { + if status == 1 { + match[FN.MwAcc.IsEnb] = true + } else { + match[FN.MwAcc.IsEnb] = false + } + + } + + searchs = bson.M{} + searchs["$match"] = match + return +} + +func MakeSort(pageNum int64, pageSize int64) (sort []bson.M) { + sort = append(sort, bson.M{ + "$sort": bson.M{ + FN.MwAcc.CreatedAt: -1, + }, + }) + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + skip := (pageNum - 1) * pageSize + sort = append(sort, bson.M{ + "$skip": skip, + }) + + sort = append(sort, bson.M{ + "$limit": pageSize, + }) + + return +} diff --git a/pub/models/mwacc/mwacc_gen.go b/pub/models/mwacc/mwacc_gen.go new file mode 100644 index 0000000..1c4987b --- /dev/null +++ b/pub/models/mwacc/mwacc_gen.go @@ -0,0 +1,130 @@ +// Code generated by gen_bson_tag. DO NOT EDIT. +package mwacc + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// CreateOneMwAcc 创建一个MwAcc +func CreateOneMwAcc(ctx context.Context, data *MwAcc) *srvdef.SrvErr { + _, err := mwAccColl.InsertOne(ctx, data) + if err != nil { + log.Error("mwacc:CreateOneMwAcc db.InsertOne failed", + log.Any("doc", data), + log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneMwAcc 查找一个MwAcc +func FindOneMwAcc(ctx context.Context, filter bson.M) ( + *MwAcc, *srvdef.SrvErr) { + var data *MwAcc + if err := mwAccColl.FindOneEx(ctx, &data, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("mwacc:FindOneMwAcc mwAccColl.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return data, nil +} + +// DelOneOneMwAcc 删除一个MwAcc +func DelOneOneMwAcc(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := mwAccColl.DeleteOne(ctx, filter) + if err != nil { + log.Warn("mwacc:DelOneOneMwAcc mwAccColl.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneMwAcc 更新一个MwAcc +func UpdateOneMwAcc(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := mwAccColl.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("mwacc:UpdateOneMwAcc mwAccColl.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// CountMwAccs 统计MwAcc数量 +func CountMwAccs(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := mwAccColl.Count(ctx, filter) + if err != nil { + log.Warn("mwacc:CountMwAccs mwAccColl.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// FindManyMwAccs 查找一批MwAcc +func FindManyMwAccs(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (MwAccList, *srvdef.SrvErr) { + data := make(MwAccList, 0) + if err := mwAccColl.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("mwacc:FindManyMwAccs mwAccColl.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManyMwAccsWithBind 用自己定义结构查找一批MwAcc数据 +func FindManyMwAccsWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := mwAccColl.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("mwacc:FindManyMwAccsWithBind mwAccColl.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateMwAccs 聚合查找一批MwAcc +func AggregateMwAccs(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := mwAccColl.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("mwacc:AggregateMwAccs mwAccColl.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateMwAccs 聚合查找一批MwAcc数据 +func ObjectAggregateMwAccs(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := mwAccColl.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("mwacc:AggregateMwAccs mwAccColl.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregateMwAccs 批量写入或者更新MwAcc数据 +func BulkWriteMwAccs(ctx context.Context, + docs []mongo.WriteModel, opts ...*options.BulkWriteOptions) *srvdef.SrvErr { + _, err := mwAccColl.BulkWrite(ctx, docs, opts...) + if err != nil { + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} diff --git a/pub/models/mwacc/role.go b/pub/models/mwacc/role.go new file mode 100644 index 0000000..92cd8a7 --- /dev/null +++ b/pub/models/mwacc/role.go @@ -0,0 +1,158 @@ +package mwacc + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/pageopt" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 角色聚集处理 +func RoleAggregate(ctx context.Context, filters []primitive.M, opts ...*options.AggregateOptions) (roles []*MwRole, err error) { + roles = make([]*MwRole, 0) + err = mwRoleColl.Aggregate(ctx, &roles, filters) + return +} + +// 过滤查询数量 +func RoleCountByFilter(ctx context.Context, filters primitive.M, opts ...*options.CountOptions) (c int64, err error) { + c, err = mwRoleColl.Count(ctx, filters) + return +} + +// 过滤查询角色 +func FindOneRoleByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOneOptions) (*MwRole, *srvdef.SrvErr) { + var res MwRole + err := mwRoleColl.FindOne(ctx, &res, filters, opts...) + if err != nil { + log.Error("mwacc FindOneRoleByFilter err", log.Any("filters", filters), log.E(err)) + return &res, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &res, nil +} + +// 过滤更新 +func UpOneRoleByFilter(ctx context.Context, query primitive.M, update primitive.M) *srvdef.SrvErr { + _, err := mwRoleColl.UpdateOne(ctx, query, update) + if err != nil { + log.Error("mwacc UpOneRoleByFilter: db.UpdateOne err", log.Any("update", update), log.E(err)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// 过滤删除角色 +func DelRoleByFilter(ctx context.Context, filters primitive.M) *srvdef.SrvErr { + _, err := mwRoleColl.DeleteOne(ctx, filters) + if err != nil { + log.Error("mwacc DelRoleByFilter err", log.Any("filters", filters), log.E(err)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return nil +} + +// 更新角色权限 +func UpRoleAuth(ctx context.Context, id int64, keys []string, dataRouter []*Router) (ok bool, err error) { + + _, err = mwRoleColl.UpdateOne(ctx, primitive.M{ + FN.MwRole.Id: id, + }, primitive.M{ + "$set": primitive.M{ + FN.MwRole.ApiMenus: keys, + FN.MwRole.Router: dataRouter, + }, + }) + + if err != nil { + log.Error("mvacc UpRoleAuth err", log.Any(FN.MwRole.Id, id), log.E(err)) + return false, err + } + return true, nil +} + +// 获取角色列表 +func GetRoleList(ctx context.Context, skip, limit int64, matchs ...pageopt.Matcher) (data []*RoleGetListRec, err error) { + data = make([]*RoleGetListRec, 0) + + err = mwRoleColl.Aggregate(ctx, &data, + []primitive.M{ + {"$match": pageopt.MergeM(matchs)}, + {"$sort": primitive.M{FN.MwRole.Id: -1}}, + {"$skip": skip}, + {"$limit": limit}, + {"$lookup": primitive.M{ + "localField": FN.MwRole.Id, + "from": TN.MwAcc, + "foreignField": FN.MwAcc.RoleId, + "as": "onlyRole", + }}, + {"$project": primitive.M{ + FN.MwRole.Id: 1, + FN.MwRole.Name: 1, + FN.MwRole.Desc: 1, + FN.MwRole.IsEnb: 1, + FN.MwRole.ApiMenus: 1, + FN.MwRole.CreatedAt: 1, + FN.MwRole.UpdatedAt: 1, + "account": primitive.M{ + "$size": "$onlyRole", + }, + }}, + }) + if err != nil && err.Error() != errors.NotFind { + log.ZapLog.Error(fmt.Sprintf("mwacc GetRoleList error %v", err)) + err = errors.MakeWithErr(errors.DBQuery, err) + return + } + return +} + +// 下拉列表 +func RoleDrop(ctx context.Context) (data []*RoleDropDoc, err error) { + + query := primitive.M{FN.MwRole.IsEnb: true} + + opt := options.Find().SetProjection(primitive.M{ + FN.MwRole.Id: 1, + FN.MwRole.Name: 1, + }) + + err = mwRoleColl.Find(ctx, &data, query, opt) + if err != nil { + err = errors.MakeWithErr(errors.DBQuery, err) + return + } + return +} + +// 插入角色 +func RoleAdd(ctx context.Context, name string, desc string, isEnb bool) (ok bool, err error) { + c, err := mwRoleColl.Count(ctx, primitive.M{FN.MwRole.Name: name}) + if c > 0 { + err = errors.MakeWithMsg(errors.DBInsert, "角色名称") + return false, err + } + + _, err = mwRoleColl.InsertOne(ctx, primitive.M{ + FN.MwRole.Id: mongos.Lid.MwRole.Get(), + FN.MwRole.Name: name, + FN.MwRole.Desc: desc, + FN.MwRole.IsEnb: isEnb, + FN.MwRole.CreatedAt: time.Now(), + }) + + if err != nil { + log.ZapLog.Error(fmt.Sprintf("InsertRole mongo error %v", err)) + err = errors.MakeWithErr(errors.DBInsert, err) + return false, err + } + + return true, nil +} diff --git a/pub/models/mwacc/tag.go b/pub/models/mwacc/tag.go new file mode 100755 index 0000000..28b8406 --- /dev/null +++ b/pub/models/mwacc/tag.go @@ -0,0 +1,88 @@ +package mwacc + +type TagMwRole struct { + ApiMenus string + CreatedAt string + Desc string + Id string + IsEnb string + Name string + Router string + UpdatedAt string +} +type TagMwAcc struct { + Acc string + Base32 string + Code string + CreatedAt string + Email string + Feedback string + Id string + IsBase32 string + IsEnb string + LastTime string + LimitIP string + Pwd string + Qurl string + RoleId string + RoleName string + Token string + UpdatedAt string + UserName string +} +type TagMwAccToken struct { + AccId string + CreatedAt string + Id string + Token string + UpdatedAt string +} + +var FN struct { + MwRole TagMwRole + MwAcc TagMwAcc + MwAccToken TagMwAccToken +} + +var TN struct { + MwRole string + MwAcc string + MwAccToken string +} + +func init() { + FN.MwRole.ApiMenus = "apiMenus" + FN.MwRole.CreatedAt = "createdAt" + FN.MwRole.Desc = "desc" + FN.MwRole.Id = "_id" + FN.MwRole.IsEnb = "isEnb" + FN.MwRole.Name = "name" + FN.MwRole.Router = "router" + FN.MwRole.UpdatedAt = "updatedAt" + FN.MwAcc.Acc = "acc" + FN.MwAcc.Base32 = "base32" + FN.MwAcc.Code = "code" + FN.MwAcc.CreatedAt = "createdAt" + FN.MwAcc.Email = "email" + FN.MwAcc.Feedback = "feedback" + FN.MwAcc.Id = "_id" + FN.MwAcc.IsBase32 = "isBase32" + FN.MwAcc.IsEnb = "isEnb" + FN.MwAcc.LastTime = "lastTime" + FN.MwAcc.LimitIP = "limitIP" + FN.MwAcc.Pwd = "pwd" + FN.MwAcc.Qurl = "qurl" + FN.MwAcc.RoleId = "roleId" + FN.MwAcc.RoleName = "roleName" + FN.MwAcc.Token = "token" + FN.MwAcc.UpdatedAt = "updatedAt" + FN.MwAcc.UserName = "userName" + FN.MwAccToken.AccId = "accId" + FN.MwAccToken.CreatedAt = "createdAt" + FN.MwAccToken.Id = "_id" + FN.MwAccToken.Token = "token" + FN.MwAccToken.UpdatedAt = "updatedAt" + TN.MwRole = "mw_role" + TN.MwAcc = "mw_acc" + TN.MwAccToken = "mw_acc_token" +} diff --git a/pub/models/mwacc/token.go b/pub/models/mwacc/token.go new file mode 100644 index 0000000..4bdd821 --- /dev/null +++ b/pub/models/mwacc/token.go @@ -0,0 +1,84 @@ +package mwacc + +import ( + "context" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 过滤更新 +func UpOneTokenByFilter(ctx context.Context, query primitive.M, update primitive.M) (err error) { + _, err = mwAccTokenColl.UpdateOne(ctx, query, primitive.M{"$set": update}) + return +} + +func SaveToken(ctx context.Context, accId int64, token string) (ok bool, err error) { + var booToken MwAccToken + + // 查找 + err = mwAccTokenColl.FindOne(ctx, &booToken, primitive.M{FN.MwAccToken.AccId: accId}) + + if booToken.AccId > 0 { + // 更新 + err = UpOneTokenByFilter(ctx, primitive.M{ + FN.MwAccToken.AccId: accId, + }, primitive.M{ + FN.MwAccToken.Token: token, + FN.MwAccToken.UpdatedAt: time.Now(), + }) + if err != nil { + return false, err + } + return true, nil + } + + if err != nil || booToken.AccId == 0 { + if _, err := mwAccTokenColl.InsertOne(ctx, + primitive.M{ + FN.MwAccToken.Id: mongos.Lid.MwAccToken.Get(), + FN.MwAccToken.AccId: accId, + FN.MwAccToken.Token: token, + FN.MwAccToken.CreatedAt: time.Now(), + }); err != nil { + return false, err + } + } + + return true, nil +} + +func CheckToken(ctx context.Context, token string) (ok bool, err error) { + var booToken MwAccToken + Terr := mwAccTokenColl.FindOne(ctx, &booToken, primitive.M{FN.MwAccToken.Token: token}) + if Terr != nil || booToken.AccId == 0 { + err = errors.MakeWithMsg(errors.NoToken, "token 不存在或已过期") + return false, err + } + return true, nil +} + +// // 检测Token +// func CheckToken(ctx context.Context, token string) (ok bool, err error) { +// var booToken WebToken +// Terr := tokenColl.FindOne(ctx, &booToken, primitive.M{"token": token}) +// if Terr != nil || booToken.Id == 0 { +// err = errors.MakeWithMsg(errors.NoToken, "token 不存在或已过期") +// return +// } +// return true, nil +// } + +// 删除token +func DeleteOneToken(ctx context.Context, filter bson.M) *srvdef.SrvErr { + if _, err := mwAccTokenColl.DeleteOne(ctx, filter); err != nil { + log.Warn("mwacc:DeleteOneToken db.DeleteOne failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return nil +} diff --git a/pub/models/mwacc/type.go b/pub/models/mwacc/type.go new file mode 100644 index 0000000..1f5e471 --- /dev/null +++ b/pub/models/mwacc/type.go @@ -0,0 +1,78 @@ +package mwacc + +import ( + "time" +) + +type Meta struct { + Title string `bson:"title"` + Icon string `bson:"icon"` + NoCache bool `bson:"noCache"` +} + +type Apiurl struct { + Url []string `bson:"url"` + Desc string `bson:"desc"` + Status bool `bson:"status"` + Method []string `bson:"method"` +} + +// 编辑返回去的数据 +type Router struct { + Path string `bson:"path"` + Component string `bson:"component"` + Redirect string `bson:"redirect"` + Name string `bson:"name"` + IsShow bool `bson:"isShow"` + Hidden bool `bson:"hidden"` + Meta Meta `bson:"meta"` + Children []Router `bson:"children"` + ApiUrl []Apiurl `bson:"apiUrl"` +} + +// 角色 +// @table:mw_role +type MwRole struct { + Id int64 `bson:"_id"` + Name string `bson:"name"` // 角色名称 + Desc string `bson:"desc"` // 描述 + IsEnb bool `bson:"isEnb"` // 是否启用 false:禁用 true:启用 + ApiMenus []string `bson:"apiMenus"` // 已有权限的api + Router []Router `bson:"router"` + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 +} + +// 账号 +// @table:mw_acc +type MwAcc struct { + Id int64 `bson:"_id"` // id + UserName string `bson:"userName"` // 用户名 + Acc string `bson:"acc"` // 账号 + Pwd string `bson:"pwd"` // 密码 + Email string `bson:"email"` // 邮箱 + RoleId int64 `bson:"roleId"` // 角色Id + RoleName string `bson:"roleName"` // 角色名 + Base32 string `bson:"base32"` // 二步签名 + IsBase32 bool `bson:"isBase32"` // 是否已签名 + Code string `bson:"code"` // 验证码 + Qurl []byte `bson:"qurl"` // 二维码链接 + Token string `bson:"token"` // token + LastTime time.Time `bson:"lastTime"` // 登录时间 + IsEnb bool `bson:"isEnb"` // 使能 + Feedback string `bson:"feedback"` // 禁用原因 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 + LimitIP []string `bson:"limitIP"` // 限制IP +} +type MwAccList []*MwAcc + +// 账号Token +// @table:mw_acc_token +type MwAccToken struct { + Id int64 `bson:"_id"` // id + AccId int64 `bson:"accId"` // 账号id + Token string `bson:"token"` // token + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 +} diff --git a/pub/models/mwacc/web.go b/pub/models/mwacc/web.go new file mode 100644 index 0000000..ec5f3fb --- /dev/null +++ b/pub/models/mwacc/web.go @@ -0,0 +1,53 @@ +package mwacc + +import ( + "time" +) + +type AccGetListRec struct { + Id int64 `bson:"_id"` // id + Username string `bson:"username"` // 用户名 + Acc string `bson:"acc"` // 用户名 + Pwd string `bson:"pwd"` // 密码 + Email string `bson:"email"` // 邮箱 + RoleId int64 `bson:"roleId"` // 角色Id + RoleName string `bson:"roleName"` // 角色名 + // Base32 string `bson:"base32"` // 二步签名 + IsBase32 bool `bson:"isBase32"` // 是否已签名 + IsEnb bool `bson:"isEnb"` // 使能 + Feedback string `bson:"feedback"` // 禁用原因 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 + LimitIP []string `bson:"limitIP"` // 允许访问的IP +} + +type RoleGetListRec struct { + Id int64 `bson:"_id"` // 角色Id + Name string `bson:"name"` // 角色名称 + Desc string `bson:"desc"` // 描述 + IsEnb bool `bson:"isEnb"` // 是否启用 false:禁用 true:启用 + ApiMenus []string `bson:"apiMenus"` // 已有权限的api + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 + AccCount int64 `bson:"accCount"` // 账户数量 +} + +type AccInsertDoc struct { + Id int64 `bson:"_id"` // id + UserName string `bson:"userName"` // 用户名 + Pwd string `bson:"pwd"` // 密码 + Acc string `bson:"acc"` // 用户名 + Email string `bson:"email"` // 邮箱 + RoleId int64 `bson:"roleId"` // 角色Id + Base32 string `bson:"base32"` // 二步签名 + IsBase32 bool `bson:"isBase32"` // 是否已签名 + IsEnb bool `bson:"isEnb"` // 使能 + LastTime time.Time `bson:"lastTime"` // 登录时间 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + LimitIP []string `bson:"limitIP"` // 允许访问的IP +} + +type RoleDropDoc struct { + Id int64 `bson:"_id"` // 角色Id + Name string `bson:"name"` // 角色名称 +} diff --git a/pub/models/oplog/dbhelper.go b/pub/models/oplog/dbhelper.go new file mode 100644 index 0000000..d19a38e --- /dev/null +++ b/pub/models/oplog/dbhelper.go @@ -0,0 +1,9 @@ +package oplog + +import ( + "videoweb/pub/mongos" +) + +var ( + opLogColl *mongos.Collection +) diff --git a/pub/models/oplog/oplog.go b/pub/models/oplog/oplog.go new file mode 100644 index 0000000..ecb33ec --- /dev/null +++ b/pub/models/oplog/oplog.go @@ -0,0 +1,175 @@ +package oplog + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + opLogColl = mongos.ShopLog.Coll(TN.OpLog) + initIndex(ctx) + return +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {FN.OpLog.ActId, 1}, + {FN.OpLog.Action, 1}, + {FN.OpLog.Path, 1}, + }, + }, + { + Keys: bson.D{ + {FN.OpLog.CreatedAt, -1}, + }, + }, + } + if _, err := opLogColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(oplog:initIndex) Create adLog Index failed! err:%+v:", err)) + } +} + +// 添加日志 +func AddLog(ctx context.Context, path string, desc string, actId int64, rawData, ip string) (err error) { + _, err = opLogColl.InsertOne(ctx, primitive.M{ + FN.OpLog.Id: mongos.Lid.MwOpLog.Get(), + FN.OpLog.Action: desc, + FN.OpLog.Path: path, + FN.OpLog.ActId: actId, + FN.OpLog.RawData: rawData, + FN.OpLog.CreatedAt: utils.Now(), + FN.OpLog.IP: ip, + }) + return +} + +type LogReq struct { + PageNum int64 `json:"pageNum"` // 页码 + PageSize int64 `json:"pageSize"` // + Path *string `json:"path"` // 请求接口 + Action *string `json:"action"` // 操作目的 + ActId *int64 `json:"actId"` // 操作人Id + StartTime *time.Time `json:"startTime"` // 开始时间 + EndTime *time.Time `json:"endTime"` // 开始时间 +} + +// 操作日志信息 +type OpLogInfo struct { + Path string `json:"path" bson:"path"` // 请求接口 + ActId int64 `json:"actId" bson:"actId"` // 操作人Id + Action string `json:"action" bson:"action"` // 操作目的 + Param string `json:"param" bson:"rawData"` // 操作内容 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 操作时间 + ActName string `json:"actName" bson:"actName"` // 操作人 +} + +type OpPageRes struct { + Total int64 `json:"total" bson:"total"` + List []*OpLogInfo `json:"list" bson:"list"` +} + +// 获取日志列表 +func PageLogList(ctx context.Context, req *LogReq) (res *OpPageRes, err error) { + filter := bson.M{} + if req.Path != nil && *req.Path != "" { + filter[FN.OpLog.Path] = bson.M{"$regex": req.Path} + } + if req.Action != nil && *req.Action != "" { + filter[FN.OpLog.Action] = req.Action + } + if req.ActId != nil && *req.ActId > 0 { + filter[FN.OpLog.ActId] = req.ActId + } + if req.StartTime != nil && req.EndTime != nil { + filter[FN.OpLog.CreatedAt] = bson.M{"$gte": req.StartTime, "$lt": req.EndTime} + } + res = new(OpPageRes) + res.Total, err = mongos.ShopLog.Coll(TN.OpLog).Count(ctx, filter) + if err != nil { + return + } + + sort := bson.M{FN.OpLog.CreatedAt: -1} + opt := &options.FindOptions{} + opt.SetSort(sort) + skip := (req.PageNum - 1) * req.PageSize + opt.SetSkip(skip) + opt.SetLimit(req.PageSize) + list := make(OpLogList, 0) + err = mongos.ShopLog.Coll(TN.OpLog).Find(ctx, &list, filter, opt) + if err != nil { + return + } + actIds := make([]int64, 0) + for _, v := range list { + actIds = append(actIds, v.ActId) + } + // err = opLogColl.Aggregate(ctx, &res.List, []bson.M{ + // {"$match": conf}, + // {"$sort": bson.M{FN.OpLog.CreatedAt: -1}}, + // {"$skip": (req.PageNum - 1) * req.PageSize}, + // {"$limit": req.PageSize}, + // {"$lookup": bson.M{ + // "localField": FN.OpLog.ActId, + // "from": mwacc.TN.MwAcc, + // "foreignField": mwacc.FN.MwAcc.Id, + // "as": "admins", + // }}, + // {"$unwind": "$admins"}, + // {"$project": bson.M{ + // FN.OpLog.Path: 1, + // FN.OpLog.Action: 1, + // FN.OpLog.ActId: 1, + // FN.OpLog.RawData: 1, + // FN.OpLog.CreatedAt: 1, + // "actName": "$admins.userName", + // }}, + // }) + return +} + +// CountOpLogs 计算某个查询的数量 +func CountOpLogs(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + total, err := mongos.ShopLog.Coll(TN.OpLog).Count(ctx, filter) + if err != nil { + log.Info("oplog:CountOpLogs db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return total, nil +} + +// InsertOneUser 插入一个用户 +func InsertOneUser(ctx context.Context, opLog *OpLog) *srvdef.SrvErr { + if _, err := mongos.ShopLog.Coll(TN.OpLog).InsertOne(ctx, opLog); err != nil { + log.Warn("oplog:InsertOneUser db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindManyOpLogs 查找一批数据 +func FindManyOpLogs(ctx context.Context, + filter bson.M, opts ...*options.FindOptions) ( + OpLogList, *srvdef.SrvErr) { + data := make(OpLogList, 0) + if err := mongos.ShopLog.Coll(TN.OpLog).Find(ctx, &data, filter, opts...); err != nil { + log.Warn("oplog:FindManyOpLogs db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} diff --git a/pub/models/oplog/tag.go b/pub/models/oplog/tag.go new file mode 100755 index 0000000..e2bc190 --- /dev/null +++ b/pub/models/oplog/tag.go @@ -0,0 +1,34 @@ +package oplog + +type TagOpLog struct { + ActId string + Action string + CreatedAt string + IP string + Id string + IsWhite string + OpType string + Path string + RawData string +} + +var FN struct { + OpLog TagOpLog +} + +var TN struct { + OpLog string +} + +func init() { + FN.OpLog.ActId = "actId" + FN.OpLog.Action = "action" + FN.OpLog.CreatedAt = "createdAt" + FN.OpLog.IP = "ip" + FN.OpLog.Id = "_id" + FN.OpLog.IsWhite = "isWhite" + FN.OpLog.OpType = "opType" + FN.OpLog.Path = "path" + FN.OpLog.RawData = "rawData" + TN.OpLog = "mw_op_log" +} diff --git a/pub/models/oplog/type.go b/pub/models/oplog/type.go new file mode 100644 index 0000000..c3348ef --- /dev/null +++ b/pub/models/oplog/type.go @@ -0,0 +1,22 @@ +package oplog + +import ( + "time" + "videoweb/pub/common" +) + +// 操作日志 +// @table:mw_op_log +type OpLog struct { + Id int64 `bson:"_id"` // id + Path string `bson:"path"` // 请求接口 + OpType common.ApiOpType `bson:"opType"` // 操作类型 + IsWhite bool `bson:"isWhite"` // 是否白名单 + ActId int64 `bson:"actId"` // 操作人Id + Action string `bson:"action"` // 操作目的 + RawData string `bson:"rawData"` // 原始数据 + CreatedAt time.Time `bson:"createdAt"` // 操作时间 + IP string `bson:"ip"` // 操作人IP +} + +type OpLogList []*OpLog diff --git a/pub/models/playletbackflowstatmod/stat.go b/pub/models/playletbackflowstatmod/stat.go new file mode 100644 index 0000000..58e4916 --- /dev/null +++ b/pub/models/playletbackflowstatmod/stat.go @@ -0,0 +1,172 @@ +package playletbackflowstatmod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化原创短剧 AI 内容回流日统计表和查询索引。 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletBackflowDailyStat) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletBackflowDailyStat.DataDate, Value: 1}, + {Key: FN.PlayletBackflowDailyStat.ReportStatus, Value: 1}, + {Key: FN.PlayletBackflowDailyStat.Id, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletBackflowDailyStat.DataDate, Value: 1}, + {Key: FN.PlayletBackflowDailyStat.PlayletId, Value: 1}, + }, + Options: options.Index().SetUnique(true).SetName("uniq_dataDate_playletId"), + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletbackflowstatmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// UpsertManyPlayletBackflowDailyStats 批量保存每天原创短剧回流数据快照。 +func UpsertManyPlayletBackflowDailyStats(ctx context.Context, docs PlayletBackflowDailyStatList) *srvdef.SrvErr { + if len(docs) == 0 { + return nil + } + now := time.Now() + writes := make([]mongo.WriteModel, 0, len(docs)) + for _, doc := range docs { + if doc == nil || doc.DataDate == "" || doc.PlayletId <= 0 { + continue + } + doc.UpdatedAt = now + doc.LastStatAt = now + update := bson.M{ + "$set": bson.M{ + FN.PlayletBackflowDailyStat.DataDate: doc.DataDate, + FN.PlayletBackflowDailyStat.PlayletId: doc.PlayletId, + FN.PlayletBackflowDailyStat.ChapterId: doc.ChapterId, + FN.PlayletBackflowDailyStat.CurrentEpisode: doc.CurrentEpisode, + FN.PlayletBackflowDailyStat.MediaCenterId: doc.MediaCenterId, + FN.PlayletBackflowDailyStat.MediaContentId: doc.MediaContentId, + FN.PlayletBackflowDailyStat.Title: doc.Title, + FN.PlayletBackflowDailyStat.PublishedData: doc.PublishedData, + FN.PlayletBackflowDailyStat.ScriptName: doc.ScriptName, + FN.PlayletBackflowDailyStat.DramaName: doc.DramaName, + FN.PlayletBackflowDailyStat.DramaNo: doc.DramaNo, + FN.PlayletBackflowDailyStat.VideoURL: doc.VideoURL, + FN.PlayletBackflowDailyStat.PlatformURL: doc.PlatformURL, + FN.PlayletBackflowDailyStat.Views: doc.Views, + FN.PlayletBackflowDailyStat.Clicks: doc.Clicks, + FN.PlayletBackflowDailyStat.Likes: doc.Likes, + FN.PlayletBackflowDailyStat.LikeRate: doc.LikeRate, + FN.PlayletBackflowDailyStat.CompletionRate: doc.CompletionRate, + FN.PlayletBackflowDailyStat.PaidRate: doc.PaidRate, + FN.PlayletBackflowDailyStat.Comments: doc.Comments, + FN.PlayletBackflowDailyStat.Shares: doc.Shares, + FN.PlayletBackflowDailyStat.CompletionRateScope: doc.CompletionRateScope, + FN.PlayletBackflowDailyStat.ReportStatus: doc.ReportStatus, + FN.PlayletBackflowDailyStat.ReportError: "", + FN.PlayletBackflowDailyStat.LastStatAt: doc.LastStatAt, + FN.PlayletBackflowDailyStat.UpdatedAt: doc.UpdatedAt, + }, + "$setOnInsert": bson.M{ + FN.PlayletBackflowDailyStat.Id: primitive.NewObjectID(), + FN.PlayletBackflowDailyStat.ReportRetryCount: int64(0), + FN.PlayletBackflowDailyStat.CreatedAt: now, + }, + } + model := mongo.NewUpdateOneModel(). + SetFilter(bson.M{ + FN.PlayletBackflowDailyStat.DataDate: doc.DataDate, + FN.PlayletBackflowDailyStat.PlayletId: doc.PlayletId, + }). + SetUpdate(update). + SetUpsert(true) + writes = append(writes, model) + } + if len(writes) == 0 { + return nil + } + if _, err := coll.BulkWrite(ctx, writes, options.BulkWrite().SetOrdered(false)); err != nil { + log.Warn("playletbackflowstatmod:UpsertManyPlayletBackflowDailyStats failed", log.E(err), log.Any("count", len(writes))) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManyPlayletBackflowDailyStats 查询原创短剧回流日统计。 +func FindManyPlayletBackflowDailyStats(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletBackflowDailyStatList, *srvdef.SrvErr) { + data := make(PlayletBackflowDailyStatList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("playletbackflowstatmod:FindManyPlayletBackflowDailyStats failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountPlayletBackflowDailyStats 统计原创短剧回流日统计条数。 +func CountPlayletBackflowDailyStats(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("playletbackflowstatmod:CountPlayletBackflowDailyStats failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// MarkPlayletBackflowDailyStatsReported 把一批统计记录标记为上报成功。 +func MarkPlayletBackflowDailyStatsReported(ctx context.Context, ids []primitive.ObjectID, reportAt time.Time) *srvdef.SrvErr { + if len(ids) == 0 { + return nil + } + _, err := coll.UpdateMany(ctx, bson.M{FN.PlayletBackflowDailyStat.Id: bson.M{"$in": ids}}, bson.M{"$set": bson.M{ + FN.PlayletBackflowDailyStat.ReportStatus: ReportStatusSuccess, + FN.PlayletBackflowDailyStat.ReportAt: reportAt, + FN.PlayletBackflowDailyStat.ReportError: "", + FN.PlayletBackflowDailyStat.UpdatedAt: time.Now(), + }}) + if err != nil { + log.Warn("playletbackflowstatmod:MarkPlayletBackflowDailyStatsReported failed", log.E(err), log.Any("count", len(ids))) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// MarkPlayletBackflowDailyStatsFailed 把一批统计记录标记为上报失败。 +func MarkPlayletBackflowDailyStatsFailed(ctx context.Context, ids []primitive.ObjectID, reportErr string) *srvdef.SrvErr { + if len(ids) == 0 { + return nil + } + _, err := coll.UpdateMany(ctx, bson.M{FN.PlayletBackflowDailyStat.Id: bson.M{"$in": ids}}, bson.M{ + "$set": bson.M{ + FN.PlayletBackflowDailyStat.ReportStatus: ReportStatusFailed, + FN.PlayletBackflowDailyStat.ReportError: reportErr, + FN.PlayletBackflowDailyStat.UpdatedAt: time.Now(), + }, + "$inc": bson.M{ + FN.PlayletBackflowDailyStat.ReportRetryCount: int64(1), + }, + }) + if err != nil { + log.Warn("playletbackflowstatmod:MarkPlayletBackflowDailyStatsFailed failed", log.E(err), log.Any("count", len(ids))) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} diff --git a/pub/models/playletbackflowstatmod/tag.go b/pub/models/playletbackflowstatmod/tag.go new file mode 100644 index 0000000..9614b34 --- /dev/null +++ b/pub/models/playletbackflowstatmod/tag.go @@ -0,0 +1,78 @@ +package playletbackflowstatmod + +// TagPlayletBackflowDailyStat 保存 playlet_backflow_daily_stat 集合字段名。 +type TagPlayletBackflowDailyStat struct { + Id string + DataDate string + PlayletId string + ChapterId string + CurrentEpisode string + MediaCenterId string + MediaContentId string + Title string + PublishedData string + ScriptName string + DramaName string + DramaNo string + VideoURL string + PlatformURL string + Views string + Clicks string + Likes string + LikeRate string + CompletionRate string + PaidRate string + Comments string + Shares string + CompletionRateScope string + ReportStatus string + ReportRetryCount string + ReportAt string + ReportError string + LastStatAt string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletBackflowDailyStat TagPlayletBackflowDailyStat +} + +var TN struct { + PlayletBackflowDailyStat string +} + +func init() { + FN.PlayletBackflowDailyStat.Id = "_id" + FN.PlayletBackflowDailyStat.DataDate = "dataDate" + FN.PlayletBackflowDailyStat.PlayletId = "playletId" + FN.PlayletBackflowDailyStat.ChapterId = "chapterId" + FN.PlayletBackflowDailyStat.CurrentEpisode = "currentEpisode" + FN.PlayletBackflowDailyStat.MediaCenterId = "mediaCenterId" + FN.PlayletBackflowDailyStat.MediaContentId = "mediaContentId" + FN.PlayletBackflowDailyStat.Title = "title" + FN.PlayletBackflowDailyStat.PublishedData = "publishedData" + FN.PlayletBackflowDailyStat.ScriptName = "scriptName" + FN.PlayletBackflowDailyStat.DramaName = "dramaName" + FN.PlayletBackflowDailyStat.DramaNo = "dramaNo" + FN.PlayletBackflowDailyStat.VideoURL = "videoUrl" + FN.PlayletBackflowDailyStat.PlatformURL = "platformUrl" + FN.PlayletBackflowDailyStat.Views = "views" + FN.PlayletBackflowDailyStat.Clicks = "clicks" + FN.PlayletBackflowDailyStat.Likes = "likes" + FN.PlayletBackflowDailyStat.LikeRate = "likeRate" + FN.PlayletBackflowDailyStat.CompletionRate = "completionRate" + FN.PlayletBackflowDailyStat.PaidRate = "paidRate" + FN.PlayletBackflowDailyStat.Comments = "comments" + FN.PlayletBackflowDailyStat.Shares = "shares" + FN.PlayletBackflowDailyStat.CompletionRateScope = "completionRateScope" + FN.PlayletBackflowDailyStat.ReportStatus = "reportStatus" + FN.PlayletBackflowDailyStat.ReportRetryCount = "reportRetryCount" + FN.PlayletBackflowDailyStat.ReportAt = "reportAt" + FN.PlayletBackflowDailyStat.ReportError = "reportError" + FN.PlayletBackflowDailyStat.LastStatAt = "lastStatAt" + FN.PlayletBackflowDailyStat.CreatedAt = "createdAt" + FN.PlayletBackflowDailyStat.UpdatedAt = "updatedAt" + + TN.PlayletBackflowDailyStat = "playlet_backflow_daily_stat" +} diff --git a/pub/models/playletbackflowstatmod/type.go b/pub/models/playletbackflowstatmod/type.go new file mode 100644 index 0000000..19039e2 --- /dev/null +++ b/pub/models/playletbackflowstatmod/type.go @@ -0,0 +1,55 @@ +package playletbackflowstatmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// ReportStatus 是 AI 内容中台数据回流上报状态。 +type ReportStatus int64 + +const ( + ReportStatusPending ReportStatus = 0 // 待上报 + ReportStatusSuccess ReportStatus = 1 // 上报成功 + ReportStatusFailed ReportStatus = 2 // 上报失败 +) + +// PlayletBackflowDailyStat @table: playlet_backflow_daily_stat +// +// PlayletBackflowDailyStat 保存原创短剧每天回流给 AI 内容中台的数据快照。 +type PlayletBackflowDailyStat struct { + Id primitive.ObjectID `json:"id" bson:"_id,omitempty"` // MongoDB ObjectID + DataDate string `json:"dataDate" bson:"dataDate"` // 指标日期,格式 DD/MM/YYYY + PlayletId int64 `json:"playletId" bson:"playletId"` // 业务短剧 ID + ChapterId int64 `json:"chapterId" bson:"chapterId"` // 上报使用的章节 ID + CurrentEpisode int64 `json:"currentEpisode" bson:"currentEpisode"` // 上报使用的章节序号 + MediaCenterId int64 `json:"mediaCenterId" bson:"mediaCenterId"` // 媒资库资源 ID + MediaContentId int64 `json:"mediaContentId" bson:"mediaContentId"` // 上报剧集的媒资章节 ID + Title string `json:"title" bson:"title"` // 本地短剧标题 + PublishedData string `json:"publishedData" bson:"publishedData"` // 中台字段:视频上线日期 + ScriptName string `json:"scriptName" bson:"scriptName"` // 中台字段:剧本名称 + DramaName string `json:"dramaName" bson:"dramaName"` // 中台字段:短剧名称 + DramaNo string `json:"dramaNo" bson:"dramaNo"` // 中台字段:集数 + VideoURL string `json:"videoUrl" bson:"videoUrl"` // 中台字段:成片下载/存储链接 + PlatformURL string `json:"platformUrl" bson:"platformUrl"` // 中台字段:产品内播放页链接 + Views int64 `json:"views" bson:"views"` // 浏览量 + Clicks int64 `json:"clicks" bson:"clicks"` // 播放量 + Likes int64 `json:"likes" bson:"likes"` // 点赞量 + LikeRate string `json:"likeRate" bson:"likeRate"` // 点赞率 + CompletionRate string `json:"completionRate" bson:"completionRate"` // 完播率 + PaidRate string `json:"paidRate" bson:"paidRate"` // 付费率 + Comments int64 `json:"comments" bson:"comments"` // 评论量 + Shares int64 `json:"shares" bson:"shares"` // 分享量 + CompletionRateScope string `json:"completionRateScope" bson:"completionRateScope"` // 完播率口径 + ReportStatus ReportStatus `json:"reportStatus" bson:"reportStatus"` // 上报状态 + ReportRetryCount int64 `json:"reportRetryCount" bson:"reportRetryCount"` // 上报失败次数 + ReportAt time.Time `json:"reportAt" bson:"reportAt"` // 最近一次上报成功时间 + ReportError string `json:"reportError" bson:"reportError"` // 最近一次上报失败原因 + LastStatAt time.Time `json:"lastStatAt" bson:"lastStatAt"` // 最近一次计算时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +// PlayletBackflowDailyStatList 是原创短剧日回流统计列表。 +type PlayletBackflowDailyStatList []*PlayletBackflowDailyStat diff --git a/pub/models/playletcategorymod/category.go b/pub/models/playletcategorymod/category.go new file mode 100644 index 0000000..550ed96 --- /dev/null +++ b/pub/models/playletcategorymod/category.go @@ -0,0 +1,121 @@ +package playletcategorymod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_category 集合和后台常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletCategory) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletCategory.Status, Value: 1}, + {Key: FN.PlayletCategory.Rank, Value: -1}, + {Key: FN.PlayletCategory.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletCategory.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletCategory.CreatedAt, Value: -1}, + {Key: FN.PlayletCategory.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletcategorymod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOnePlayletCategory 插入一条短剧分类数据 +func InsertOnePlayletCategory(ctx context.Context, doc *PlayletCategory) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletCategory.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("playletcategorymod:InsertOnePlayletCategory failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOnePlayletCategory 删除一条短剧分类数据 +func DelOnePlayletCategory(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playletcategorymod:DelOnePlayletCategory failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOnePlayletCategory 查询一条短剧分类数据 +func FindOnePlayletCategory(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*PlayletCategory, *srvdef.SrvErr) { + var doc PlayletCategory + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("playletcategorymod:FindOnePlayletCategory failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyPlayletCategory 按条件查询多条短剧分类数据 +func FindManyPlayletCategory(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletCategoryList, *srvdef.SrvErr) { + data := make(PlayletCategoryList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("playletcategorymod:FindManyPlayletCategory failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountPlayletCategory 统计满足条件的短剧分类数量 +func CountPlayletCategory(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("playletcategorymod:CountPlayletCategory failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOnePlayletCategory 更新一条短剧分类数据 +func UpdateOnePlayletCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("playletcategorymod:UpdateOnePlayletCategory failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/playletcategorymod/tag.go b/pub/models/playletcategorymod/tag.go new file mode 100644 index 0000000..964a7fd --- /dev/null +++ b/pub/models/playletcategorymod/tag.go @@ -0,0 +1,44 @@ +package playletcategorymod + +// TagPlayletCategory 保存 playlet_category 集合字段名 +type TagPlayletCategory struct { + Id string + Name string + Slug string + Icon string + Status string + Rank string + Remark string + SeoInfo struct { + Description string + Keyworld string + Title string + } + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletCategory TagPlayletCategory +} + +var TN struct { + PlayletCategory string +} + +func init() { + FN.PlayletCategory.Id = "_id" + FN.PlayletCategory.Name = "name" + FN.PlayletCategory.Slug = "slug" + FN.PlayletCategory.Icon = "icon" + FN.PlayletCategory.Status = "status" + FN.PlayletCategory.Rank = "rank" + FN.PlayletCategory.Remark = "remark" + FN.PlayletCategory.SeoInfo.Description = "seoInfo.webDescription" + FN.PlayletCategory.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.PlayletCategory.SeoInfo.Title = "seoInfo.webTitle" + FN.PlayletCategory.CreatedAt = "createdAt" + FN.PlayletCategory.UpdatedAt = "updatedAt" + + TN.PlayletCategory = "playlet_category" +} diff --git a/pub/models/playletcategorymod/type.go b/pub/models/playletcategorymod/type.go new file mode 100644 index 0000000..0188755 --- /dev/null +++ b/pub/models/playletcategorymod/type.go @@ -0,0 +1,25 @@ +package playletcategorymod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// PlayletCategory @table: playlet_category +// +// PlayletCategory 短剧分类集合模型 +type PlayletCategory struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Name string `json:"name" bson:"name"` // 分类名称 + Slug string `json:"slug" bson:"slug"` // slug + Icon string `json:"icon" bson:"icon"` // 分类图标 + Status bool `json:"status" bson:"status"` // 启用状态 + Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前 + Remark string `json:"remark" bson:"remark"` // 备注 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletCategoryList 短剧分类列表 +type PlayletCategoryList []*PlayletCategory diff --git a/pub/models/playletchaptermod/chapter.go b/pub/models/playletchaptermod/chapter.go new file mode 100644 index 0000000..3547da2 --- /dev/null +++ b/pub/models/playletchaptermod/chapter.go @@ -0,0 +1,265 @@ +package playletchaptermod + +import ( + "context" + "fmt" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_chapter 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletChapter) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletChapter.PlayletId, Value: 1}, + {Key: FN.PlayletChapter.CurrentEpisode, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapter.PlayletId, Value: 1}, + {Key: FN.PlayletChapter.MediaContentId, Value: 1}, + }, + Options: options.Index(). + SetUnique(true). + SetPartialFilterExpression(bson.M{ + FN.PlayletChapter.MediaContentId: bson.M{"$gt": 0}, + }), + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapter.PlayletId, Value: 1}, + {Key: FN.PlayletChapter.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapter.CreatedAt, Value: -1}, + {Key: FN.PlayletChapter.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapter.WatermarkStatus, Value: 1}, + {Key: FN.PlayletChapter.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapter.MediaSourceId, Value: 1}, + }, + Options: options.Index(). + SetPartialFilterExpression(bson.M{ + FN.PlayletChapter.MediaSourceId: bson.M{"$gt": ""}, + }), + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletchaptermod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOnePlayletChapter 插入一条章节数据 +func InsertOnePlayletChapter(ctx context.Context, doc *PlayletChapter) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletChapter.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("playletchaptermod:InsertOnePlayletChapter failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// InsertManyPlayletChapter 批量插入章节数据 +func InsertManyPlayletChapter(ctx context.Context, docs PlayletChapterList) *srvdef.SrvErr { + data := make(PlayletChapterList, 0, len(docs)) + now := time.Now() + for _, doc := range docs { + if doc == nil { + continue + } + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletChapter.Get() + } + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + data = append(data, doc) + } + if len(data) == 0 { + return nil + } + if _, err := coll.InsertMany(ctx, &data); err != nil { + log.Warn("playletchaptermod:InsertManyPlayletChapter failed", log.Any("count", len(data)), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOnePlayletChapter 删除一条章节数据 +func DelOnePlayletChapter(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playletchaptermod:DelOnePlayletChapter failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DelManyPlayletChapter 按条件批量删除章节数据 +func DelManyPlayletChapter(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteMany(ctx, filter) + if err != nil { + log.Warn("playletchaptermod:DelManyPlayletChapter failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOnePlayletChapter 查询一条章节数据 +func FindOnePlayletChapter(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*PlayletChapter, *srvdef.SrvErr) { + var doc PlayletChapter + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("playletchaptermod:FindOnePlayletChapter failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyPlayletChapter 按条件查询多条章节数据 +func FindManyPlayletChapter(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletChapterList, *srvdef.SrvErr) { + data := make(PlayletChapterList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("playletchaptermod:FindManyPlayletChapter failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountPlayletChapter 统计满足条件的章节数量 +func CountPlayletChapter(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("playletchaptermod:CountPlayletChapter failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOnePlayletChapter 更新一条章节数据 +func UpdateOnePlayletChapter(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("playletchaptermod:UpdateOnePlayletChapter failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// IsLinkedMedia 是否短剧媒资:显式标记或已挂到剧集 +func IsLinkedMedia(ctx context.Context, isPlaylet bool, ids ...string) bool { + if isPlaylet { + return true + } + return ExistsByMediaIDs(ctx, ids...) +} + +// ExistsByMediaIDs 判断媒资 ID 是否属于短剧剧集(mediaSourceId / mediaTaskId) +func ExistsByMediaIDs(ctx context.Context, ids ...string) bool { + clean := uniqueNonEmptyStrings(ids) + if len(clean) == 0 { + return false + } + item, _ := FindOnePlayletChapter(ctx, bson.M{ + "$or": []bson.M{ + {FN.PlayletChapter.MediaSourceId: bson.M{"$in": clean}}, + {FN.PlayletChapter.MediaTaskId: bson.M{"$in": clean}}, + }, + }, options.FindOne().SetProjection(bson.M{FN.PlayletChapter.Id: 1})) + return item != nil +} + +// ListLinkedMediaIDs 返回短剧剧集关联的媒资 ID,用于排除短剧视频 +func ListLinkedMediaIDs(ctx context.Context) []string { + list, err := FindManyPlayletChapter(ctx, bson.M{ + "$or": []bson.M{ + {FN.PlayletChapter.MediaSourceId: bson.M{"$gt": ""}}, + {FN.PlayletChapter.MediaTaskId: bson.M{"$gt": ""}}, + }, + }, options.Find().SetProjection(bson.M{ + FN.PlayletChapter.MediaSourceId: 1, + FN.PlayletChapter.MediaTaskId: 1, + })) + if err != nil || len(list) == 0 { + return nil + } + ids := make([]string, 0, len(list)*2) + for _, item := range list { + if item == nil { + continue + } + ids = append(ids, item.MediaSourceId, item.MediaTaskId) + } + return uniqueNonEmptyStrings(ids) +} + +func uniqueNonEmptyStrings(ids []string) []string { + if len(ids) == 0 { + return nil + } + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// UpdateManyPlayletChapter 批量更新章节数据 +func UpdateManyPlayletChapter(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("批量更新短剧章节数据失败", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/playletchaptermod/tag.go b/pub/models/playletchaptermod/tag.go new file mode 100644 index 0000000..6ec9dac --- /dev/null +++ b/pub/models/playletchaptermod/tag.go @@ -0,0 +1,72 @@ +package playletchaptermod + +// TagPlayletChapter 保存 playlet_chapter 集合字段名 +type TagPlayletChapter struct { + Id string + PlayletId string + MediaContentId string + CurrentEpisode string + Title string + Md5 string + Hash string + Height string + Width string + Ratio string + VideoUrl string + WatermarkVideoUrl string + MediaSize string + MediaTime string + CountComment string + CountBuy string + CountBrowse string + CountCollect string + CountLike string + CountDisLike string + PayType string + SingleCoins string + WatermarkStatus string + MediaSourceId string + MediaTaskId string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletChapter TagPlayletChapter +} + +var TN struct { + PlayletChapter string +} + +func init() { + FN.PlayletChapter.Id = "_id" + FN.PlayletChapter.PlayletId = "playletId" + FN.PlayletChapter.MediaContentId = "mediaContentId" + FN.PlayletChapter.CurrentEpisode = "currentEpisode" + FN.PlayletChapter.Title = "title" + FN.PlayletChapter.Md5 = "md5" + FN.PlayletChapter.Hash = "hash" + FN.PlayletChapter.Height = "height" + FN.PlayletChapter.Width = "width" + FN.PlayletChapter.Ratio = "ratio" + FN.PlayletChapter.VideoUrl = "videoUrl" + FN.PlayletChapter.WatermarkVideoUrl = "watermarkVideoUrl" + FN.PlayletChapter.MediaSize = "mediaSize" + FN.PlayletChapter.MediaTime = "mediaTime" + FN.PlayletChapter.CountComment = "countComment" + FN.PlayletChapter.CountBuy = "countBuy" + FN.PlayletChapter.CountBrowse = "countBrowse" + FN.PlayletChapter.CountCollect = "countCollect" + FN.PlayletChapter.CountLike = "countLike" + FN.PlayletChapter.CountDisLike = "countDisLike" + FN.PlayletChapter.PayType = "payType" + FN.PlayletChapter.SingleCoins = "singleCoins" + FN.PlayletChapter.WatermarkStatus = "watermarkStatus" + FN.PlayletChapter.MediaSourceId = "mediaSourceId" + FN.PlayletChapter.MediaTaskId = "mediaTaskId" + FN.PlayletChapter.CreatedAt = "createdAt" + FN.PlayletChapter.UpdatedAt = "updatedAt" + + TN.PlayletChapter = "playlet_chapter" +} diff --git a/pub/models/playletchaptermod/type.go b/pub/models/playletchaptermod/type.go new file mode 100644 index 0000000..ac7d980 --- /dev/null +++ b/pub/models/playletchaptermod/type.go @@ -0,0 +1,59 @@ +package playletchaptermod + +import "time" + +// PayType 支付方式 +type PayType int32 + +const ( + PayTypeFree PayType = 0 // 免费 + PayTypeCoin PayType = 1 // 金币 + PayTypeVip PayType = 2 // VIP +) + +// WatermarkStatus 水印状态 +type WatermarkStatus int32 + +const ( + WatermarkStatusPending WatermarkStatus = 0 // 未处理(待 mediafile/sync) + WatermarkStatusRegistered WatermarkStatus = 1 // 已登记(原片已登记,待 task/add) + WatermarkStatusProcessing WatermarkStatus = 2 // 处理中(已提交加水印任务,待回写) + WatermarkStatusDone WatermarkStatus = 3 // 已完成 + WatermarkStatusFailed WatermarkStatus = 4 // 处理失败 +) + +// PlayletChapter @table: playlet_chapter +// +// PlayletChapter 连载短剧章节集合模型 +type PlayletChapter struct { + Id int64 `json:"id" bson:"_id,omitempty"` + PlayletId int64 `json:"playletId" bson:"playletId"` // 短剧ID + MediaContentId uint `json:"mediaContentId" bson:"mediaContentId"` // 媒资中心章节ID + CurrentEpisode int64 `json:"currentEpisode" bson:"currentEpisode"` // 第几集 + Title string `json:"title" bson:"title"` // 章节名 + Md5 string `json:"md5" bson:"md5"` // 媒体md5 + Hash string `json:"hash" bson:"hash"` // 媒体hash + Height int64 `json:"height" bson:"height"` // 高 + Width int64 `json:"width" bson:"width"` // 宽 + Ratio float64 `json:"ratio" bson:"ratio"` // 宽高比 + VideoUrl string `json:"videoUrl" bson:"videoUrl"` // 原片视频地址(加水印后保持不变,方便换水印) + WatermarkVideoUrl string `json:"watermarkVideoUrl" bson:"watermarkVideoUrl"` // 加水印后的视频地址 + MediaSize int64 `json:"mediaSize" bson:"mediaSize"` // 资源大小 + MediaTime int64 `json:"mediaTime" bson:"mediaTime"` // 影片长度(秒) + CountComment int64 `json:"countComment" bson:"countComment"` // 评论数 + CountBuy int64 `json:"countBuy" bson:"countBuy"` // 购买数 + CountBrowse int64 `json:"countBrowse" bson:"countBrowse"` // 浏览数 + CountCollect int64 `json:"countCollect" bson:"countCollect"` // 收藏数 + CountLike int64 `json:"countLike" bson:"countLike"` // 喜欢数 + CountDisLike int64 `json:"countDisLike" bson:"countDisLike"` // 不喜欢数 + PayType PayType `json:"payType" bson:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins" bson:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + WatermarkStatus WatermarkStatus `json:"watermarkStatus" bson:"watermarkStatus"` // 水印状态 0-未处理 1-已登记 2-处理中 3-已完成 4-处理失败 + MediaSourceId string `json:"mediaSourceId" bson:"mediaSourceId"` // media切片服原片ID + MediaTaskId string `json:"mediaTaskId" bson:"mediaTaskId"` // media切片服水印任务ID + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletChapterList 章节列表 +type PlayletChapterList []*PlayletChapter diff --git a/pub/models/playletfavoritemod/favorite.go b/pub/models/playletfavoritemod/favorite.go new file mode 100644 index 0000000..c560f2f --- /dev/null +++ b/pub/models/playletfavoritemod/favorite.go @@ -0,0 +1,100 @@ +package playletfavoritemod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const CollectionName = "playlet_favorite" + +const ( + FieldID = "_id" + FieldUserID = "userId" + FieldPlayletID = "playletId" + FieldCreatedAt = "createdAt" + FieldUpdatedAt = "updatedAt" +) + +type PlayletFavorite struct { + ID int64 `bson:"_id,omitempty"` + UserID int64 `bson:"userId"` + PlayletID int64 `bson:"playletId"` + CreatedAt time.Time `bson:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt"` +} + +var coll *mongos.Collection + +// Init 初始化短剧收藏集合及查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(CollectionName) + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: FieldUserID, Value: 1}, {Key: FieldPlayletID, Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: FieldUserID, Value: 1}, {Key: FieldCreatedAt, Value: -1}, {Key: FieldID, Value: -1}}}, + {Keys: bson.D{{Key: FieldPlayletID, Value: 1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletfavoritemod:Init) Create Index failed! err:%+v:", err)) + } +} + +// Upsert 按用户和短剧写入收藏关系并返回是否新增 +func Upsert(ctx context.Context, userID, playletID int64, now time.Time) (bool, *srvdef.SrvErr) { + filter := bson.M{FieldUserID: userID, FieldPlayletID: playletID} + update := bson.M{"$set": bson.M{FieldUpdatedAt: now}, "$setOnInsert": bson.M{ + FieldID: mongos.Lid.PlayletFavorite.Get(), FieldUserID: userID, FieldPlayletID: playletID, FieldCreatedAt: now, + }} + result, err := coll.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) + if err != nil { + log.Warn("playletfavoritemod:Upsert failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.UpsertedCount > 0, nil +} + +// Delete 删除收藏关系并返回是否实际删除 +func Delete(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + deleted, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playletfavoritemod:Delete failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return deleted > 0, nil +} + +// Count 统计收藏关系数量 +func Count(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("playletfavoritemod:Count failed", log.Any("filter", filter), log.E(err)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// FindMany 查询收藏关系列表 +func FindMany(ctx context.Context, filter bson.M, opts ...*options.FindOptions) ([]*PlayletFavorite, *srvdef.SrvErr) { + list := make([]*PlayletFavorite, 0) + if err := coll.Find(ctx, &list, filter, opts...); err != nil { + log.Warn("playletfavoritemod:FindMany failed", log.Any("filter", filter), log.E(err)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return list, nil +} + +// Aggregate 聚合收藏关系和短剧数据 +func Aggregate(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("playletfavoritemod:Aggregate failed", log.Any("pipeline", pipeline), log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/playlethomeconfigmod/config.go b/pub/models/playlethomeconfigmod/config.go new file mode 100644 index 0000000..5d81f66 --- /dev/null +++ b/pub/models/playlethomeconfigmod/config.go @@ -0,0 +1,168 @@ +package playlethomeconfigmod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_home_config 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletHomeConfig) + backfillSlugs(ctx) + initIndex(ctx) +} + +// backfillSlugs 为历史配置生成稳定路径标识 +func backfillSlugs(ctx context.Context) { + var docs PlayletHomeConfigList + filter := bson.M{"$or": []bson.M{{FN.PlayletHomeConfig.Slug: bson.M{"$exists": false}}, {FN.PlayletHomeConfig.Slug: nil}, {FN.PlayletHomeConfig.Slug: ""}}} + if err := coll.Find(ctx, &docs, filter, options.Find().SetProjection(bson.M{FN.PlayletHomeConfig.Id: 1})); err != nil { + panic(fmt.Sprintf("(playlethomeconfigmod:backfillSlugs) Find failed! err:%+v", err)) + } + for _, doc := range docs { + if doc == nil { + continue + } + if _, err := coll.UpdateOne(ctx, bson.M{FN.PlayletHomeConfig.Id: doc.Id, "$or": filter["$or"]}, bson.M{"$set": bson.M{FN.PlayletHomeConfig.Slug: fmt.Sprintf("home-config-%d", doc.Id)}}); err != nil { + panic(fmt.Sprintf("(playlethomeconfigmod:backfillSlugs) UpdateOne failed! id:%d err:%+v", doc.Id, err)) + } + } +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletHomeConfig.Enabled, Value: 1}, + {Key: FN.PlayletHomeConfig.ModuleType, Value: 1}, + {Key: FN.PlayletHomeConfig.Rank, Value: -1}, + {Key: FN.PlayletHomeConfig.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletHomeConfig.CreatedAt, Value: -1}, + {Key: FN.PlayletHomeConfig.Id, Value: -1}, + }, + }, + { + Keys: bson.D{{Key: FN.PlayletHomeConfig.Slug, Value: 1}}, + Options: options.Index().SetUnique(true).SetName("slug_unique"), + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playlethomeconfigmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOnePlayletHomeConfig 插入一条首页配置 +func InsertOnePlayletHomeConfig(ctx context.Context, doc *PlayletHomeConfig) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletHomeConfig.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + normalizePlayletHomeConfig(doc) + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + if mongo.IsDuplicateKeyError(err) { + return srvdef.New(srvdef.ErrParamError, "slug已存在", "") + } + log.Warn("插入首页配置失败", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +func normalizePlayletHomeConfig(doc *PlayletHomeConfig) { + if doc == nil { + return + } + if doc.ModuleType != ModuleTypeTopic { + doc.ModuleType = ModuleTypePlaylet + } + if doc.DataType != DataTypeVideo { + doc.DataType = DataTypeAll + } + if doc.Limit <= 0 { + doc.Limit = DefaultLimit + } + if doc.Limit > MaxLimit { + doc.Limit = MaxLimit + } +} + +// DelManyPlayletHomeConfig 删除多条首页配置 +func DelManyPlayletHomeConfig(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteMany(ctx, filter) + if err != nil { + log.Warn("删除首页配置失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOnePlayletHomeConfig 查询一条首页配置 +func FindOnePlayletHomeConfig(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*PlayletHomeConfig, *srvdef.SrvErr) { + var doc PlayletHomeConfig + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("查询首页配置失败", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + normalizePlayletHomeConfig(&doc) + return &doc, nil +} + +// FindManyPlayletHomeConfig 按条件查询多条首页配置 +func FindManyPlayletHomeConfig(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletHomeConfigList, *srvdef.SrvErr) { + data := make(PlayletHomeConfigList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("查询首页配置列表失败", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + for _, item := range data { + normalizePlayletHomeConfig(item) + } + return data, nil +} + +// CountPlayletHomeConfig 统计满足条件的首页配置数量 +func CountPlayletHomeConfig(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("统计首页配置失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOnePlayletHomeConfig 更新一条首页配置 +func UpdateOnePlayletHomeConfig(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return srvdef.New(srvdef.ErrParamError, "slug已存在", "") + } + log.Warn("更新首页配置失败", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/playlethomeconfigmod/tag.go b/pub/models/playlethomeconfigmod/tag.go new file mode 100644 index 0000000..3d3c7cd --- /dev/null +++ b/pub/models/playlethomeconfigmod/tag.go @@ -0,0 +1,52 @@ +package playlethomeconfigmod + +// TagPlayletHomeConfig 保存 playlet_home_config 集合字段名 +type TagPlayletHomeConfig struct { + Id string + Slug string + Title string + Desc string + Label string + Style string + CardBackgroundImage string + ModuleType string + DataType string + RecommendType string + CategoryId string + TopicIds string + Rank string + Enabled string + Limit string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletHomeConfig TagPlayletHomeConfig +} + +var TN struct { + PlayletHomeConfig string +} + +func init() { + FN.PlayletHomeConfig.Id = "_id" + FN.PlayletHomeConfig.Slug = "slug" + FN.PlayletHomeConfig.Title = "title" + FN.PlayletHomeConfig.Desc = "desc" + FN.PlayletHomeConfig.Label = "label" + FN.PlayletHomeConfig.Style = "style" + FN.PlayletHomeConfig.CardBackgroundImage = "cardBackgroundImage" + FN.PlayletHomeConfig.ModuleType = "moduleType" + FN.PlayletHomeConfig.DataType = "dataType" + FN.PlayletHomeConfig.RecommendType = "recommendType" + FN.PlayletHomeConfig.CategoryId = "categoryId" + FN.PlayletHomeConfig.TopicIds = "topicIds" + FN.PlayletHomeConfig.Rank = "rank" + FN.PlayletHomeConfig.Enabled = "enabled" + FN.PlayletHomeConfig.Limit = "limit" + FN.PlayletHomeConfig.CreatedAt = "createdAt" + FN.PlayletHomeConfig.UpdatedAt = "updatedAt" + + TN.PlayletHomeConfig = "playlet_home_config" +} diff --git a/pub/models/playlethomeconfigmod/type.go b/pub/models/playlethomeconfigmod/type.go new file mode 100644 index 0000000..b394820 --- /dev/null +++ b/pub/models/playlethomeconfigmod/type.go @@ -0,0 +1,60 @@ +package playlethomeconfigmod + +import "time" + +const ( + RecommendTypeUpdated int64 = 1 // 按更新 + RecommendTypeLike int64 = 2 // 按点赞 + RecommendTypeBrowse int64 = 3 // 按观看 + RecommendTypeHeat int64 = 4 // 按热度 + RecommendTypeCategory int64 = 5 // 按分类 + DefaultLimit int64 = 30 + MaxLimit int64 = 100 +) + +const ( + DataTypeAll int64 = 1 // 全部上架短剧 + DataTypeVideo int64 = 2 // 全部上架视频 +) + +const ( + ModuleTypePlaylet int64 = 1 // 列表,具体为短剧或视频由 DataType 决定 + ModuleTypeTopic int64 = 2 // 专题 +) + +const ( + StyleA = "A" + StyleB = "B" + StyleC = "C" + StyleD = "D" + StyleE = "E" + StyleF = "F" + StyleG = "G" + StyleH = "H" +) + +// PlayletHomeConfig @table: playlet_home_config +// +// PlayletHomeConfig 短剧首页配置表 +type PlayletHomeConfig struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Slug string `json:"slug" bson:"slug"` // 页面路径标识 + Title string `json:"title" bson:"title"` // 模块标题 + Desc string `json:"desc" bson:"desc"` // 模块描述 + Label string `json:"label" bson:"label"` // 样式名称 + Style string `json:"style" bson:"style"` // 样式 A-H + CardBackgroundImage string `json:"cardBackgroundImage" bson:"cardBackgroundImage"` // 卡片背景图 + ModuleType int64 `json:"moduleType" bson:"moduleType"` // 模块类型 1列表 2专题,短剧或视频由 DataType 决定 + DataType int64 `json:"dataType" bson:"dataType"` // 数据类型 1全部上架短剧 2全部上架视频 + RecommendType int64 `json:"recommendType" bson:"recommendType"` // 推荐类型 1按更新 2按点赞 3按观看 4按热度 5按分类 + CategoryId int64 `json:"categoryId" bson:"categoryId"` // 按分类推荐时使用的分类ID + TopicIds []int64 `json:"topicIds" bson:"topicIds"` // 专题模块关联的题材或视频分类ID + Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前 + Enabled bool `json:"enabled" bson:"enabled"` // 是否启用 + Limit int64 `json:"limit" bson:"limit"` // 展示条数 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletHomeConfigList 首页配置列表 +type PlayletHomeConfigList []*PlayletHomeConfig diff --git a/pub/models/playletlikemod/playlet_like.go b/pub/models/playletlikemod/playlet_like.go new file mode 100644 index 0000000..980470f --- /dev/null +++ b/pub/models/playletlikemod/playlet_like.go @@ -0,0 +1,83 @@ +package playletlikemod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_like 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletLike) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: FN.PlayletLike.UserID, Value: 1}, {Key: FN.PlayletLike.PlayletID, Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: FN.PlayletLike.PlayletID, Value: 1}}}, + {Keys: bson.D{{Key: FN.PlayletLike.CreatedAt, Value: -1}, {Key: FN.PlayletLike.PlayletID, Value: 1}}}, + {Keys: bson.D{{Key: FN.PlayletLike.UserID, Value: 1}, {Key: FN.PlayletLike.CreatedAt, Value: -1}, {Key: FN.PlayletLike.Id, Value: -1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletlikemod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// UpsertOnePlayletLike 按用户ID和短剧ID写入点赞关系,返回是否新增点赞 +func UpsertOnePlayletLike(ctx context.Context, userId, playletId int64, now time.Time) (bool, *srvdef.SrvErr) { + filter := bson.M{FN.PlayletLike.UserID: userId, FN.PlayletLike.PlayletID: playletId} + update := bson.M{ + "$set": bson.M{FN.PlayletLike.UpdatedAt: now}, + "$setOnInsert": bson.M{ + FN.PlayletLike.Id: mongos.Lid.PlayletLike.Get(), + FN.PlayletLike.UserID: userId, + FN.PlayletLike.PlayletID: playletId, + FN.PlayletLike.CreatedAt: now, + }, + } + result, err := coll.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) + if err != nil { + log.Warn("playletlikemod:UpsertOnePlayletLike db.UpdateOne failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.UpsertedCount > 0, nil +} + +// DeleteOnePlayletLike 删除一条短剧点赞关系,返回是否实际删除 +func DeleteOnePlayletLike(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + result, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playletlikemod:DeleteOnePlayletLike db.DeleteOne failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return result > 0, nil +} + +// CountPlayletLike 统计短剧点赞关系数量 +func CountPlayletLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("playletlikemod:CountPlayletLike db.Count failed", log.Any("filter", filter), log.E(err)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// AggregatePlayletLike 聚合查询短剧点赞关系 +func AggregatePlayletLike(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("playletlikemod:AggregatePlayletLike db.Aggregate failed", log.Any("pipeline", pipeline), log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/playletlikemod/tag.go b/pub/models/playletlikemod/tag.go new file mode 100644 index 0000000..1b220e1 --- /dev/null +++ b/pub/models/playletlikemod/tag.go @@ -0,0 +1,28 @@ +package playletlikemod + +// TagPlayletLike 保存 playlet_like 集合字段名 +type TagPlayletLike struct { + Id string + UserID string + PlayletID string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletLike TagPlayletLike +} + +var TN struct { + PlayletLike string +} + +func init() { + FN.PlayletLike.Id = "_id" + FN.PlayletLike.UserID = "userId" + FN.PlayletLike.PlayletID = "playletId" + FN.PlayletLike.CreatedAt = "createdAt" + FN.PlayletLike.UpdatedAt = "updatedAt" + + TN.PlayletLike = "playlet_like" +} diff --git a/pub/models/playletlikemod/type.go b/pub/models/playletlikemod/type.go new file mode 100644 index 0000000..b754994 --- /dev/null +++ b/pub/models/playletlikemod/type.go @@ -0,0 +1,15 @@ +package playletlikemod + +import "time" + +// PlayletLike 短剧点赞关系 +type PlayletLike struct { + Id int64 `json:"id" bson:"_id,omitempty"` + UserID int64 `json:"userId" bson:"userId"` // 用户ID + PlayletID int64 `json:"playletId" bson:"playletId"` // 短剧ID + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` +} + +// PlayletLikeList 短剧点赞列表 +type PlayletLikeList []*PlayletLike diff --git a/pub/models/playletmod/category_bind.go b/pub/models/playletmod/category_bind.go new file mode 100644 index 0000000..f6584a7 --- /dev/null +++ b/pub/models/playletmod/category_bind.go @@ -0,0 +1,173 @@ +package playletmod + +import ( + "context" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" +) + +const categoryRankField = "_categoryRank" + +// PlayletCategoryBind 短剧绑定的分类及该分类下的排序 +type PlayletCategoryBind struct { + Id int64 `json:"id" bson:"id"` // 分类ID + Rank int64 `json:"rank" bson:"rank"` // 该分类下的排序,越大越靠前 +} + +// NormalizeCategoryBinds 去重并丢弃无效分类ID,保留首次出现的排序 +func NormalizeCategoryBinds(binds []PlayletCategoryBind) []PlayletCategoryBind { + result := make([]PlayletCategoryBind, 0, len(binds)) + seen := make(map[int64]struct{}, len(binds)) + for _, bind := range binds { + if bind.Id <= 0 { + continue + } + if _, ok := seen[bind.Id]; ok { + continue + } + seen[bind.Id] = struct{}{} + result = append(result, PlayletCategoryBind{Id: bind.Id, Rank: bind.Rank}) + } + return result +} + +// CategoryIdsFromBinds 从分类绑定中提取分类ID +func CategoryIdsFromBinds(binds []PlayletCategoryBind) []int64 { + result := make([]int64, 0, len(binds)) + for _, bind := range binds { + if bind.Id <= 0 { + continue + } + result = append(result, bind.Id) + } + return result +} + +// CategoryBindsFromIds 仅有分类ID时补默认排序0 +func CategoryBindsFromIds(ids []int64) []PlayletCategoryBind { + result := make([]PlayletCategoryBind, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, PlayletCategoryBind{Id: id}) + } + return result +} + +// MergeCategoryBindsByIds 按新分类ID集合保留旧排序,新增分类rank为0 +func MergeCategoryBindsByIds(exist []PlayletCategoryBind, ids []int64) []PlayletCategoryBind { + rankByID := make(map[int64]int64, len(exist)) + for _, bind := range exist { + if bind.Id <= 0 { + continue + } + rankByID[bind.Id] = bind.Rank + } + result := make([]PlayletCategoryBind, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, PlayletCategoryBind{Id: id, Rank: rankByID[id]}) + } + return result +} + +// CategoryRankOf 取指定分类下的排序,未绑定返回0 +func CategoryRankOf(binds []PlayletCategoryBind, categoryId int64) int64 { + if categoryId <= 0 { + return 0 + } + for _, bind := range binds { + if bind.Id == categoryId { + return bind.Rank + } + } + return 0 +} + +// EffectiveCategoryBinds 历史数据只有categoryIds时补默认绑定,便于编辑回显 +func EffectiveCategoryBinds(item *Playlet) []PlayletCategoryBind { + if item == nil { + return make([]PlayletCategoryBind, 0) + } + if len(item.CategoryBinds) > 0 { + return item.CategoryBinds + } + return CategoryBindsFromIds(item.CategoryIds) +} + +// CategoryRankAddFields 聚合取出当前分类的排序字段,供分类列表排序 +func CategoryRankAddFields(categoryId int64) bson.M { + return bson.M{ + categoryRankField: bson.M{ + "$let": bson.M{ + "vars": bson.M{ + "hit": bson.M{ + "$first": bson.M{ + "$filter": bson.M{ + "input": bson.M{"$ifNull": bson.A{"$categoryBinds", bson.A{}}}, + "as": "b", + "cond": bson.M{"$eq": bson.A{"$$b.id", categoryId}}, + }, + }, + }, + }, + "in": bson.M{"$ifNull": bson.A{"$$hit.rank", 0}}, + }, + }, + } +} + +// CategoryRankSort 分类短剧排序:当前分类rank降序,再按上架时间和ID +func CategoryRankSort() bson.D { + return bson.D{ + {Key: categoryRankField, Value: -1}, + {Key: "onlineAt", Value: -1}, + {Key: "_id", Value: -1}, + } +} + +// CategoryRankAdminSort 后台按分类查询时的排序:当前分类rank降序 +func CategoryRankAdminSort() bson.D { + return bson.D{ + {Key: categoryRankField, Value: -1}, + {Key: "_id", Value: -1}, + } +} + +// FindManyPlayletByCategoryRank 按指定分类的排序分页查询短剧 +func FindManyPlayletByCategoryRank(ctx context.Context, filter bson.M, categoryId, skip, limit int64, sort bson.D, projection bson.D) (PlayletList, *srvdef.SrvErr) { + pipeline := []bson.M{ + {"$match": filter}, + {"$addFields": CategoryRankAddFields(categoryId)}, + {"$sort": sort}, + {"$skip": skip}, + {"$limit": limit}, + {"$unset": categoryRankField}, + } + if len(projection) > 0 { + project := bson.M{} + for _, field := range projection { + project[field.Key] = field.Value + } + pipeline = append(pipeline, bson.M{"$project": project}) + } + list := make(PlayletList, 0) + if err := AggregatePlaylet(ctx, &list, pipeline); err != nil { + return nil, err + } + return list, nil +} diff --git a/pub/models/playletmod/playlet.go b/pub/models/playletmod/playlet.go new file mode 100644 index 0000000..b8937da --- /dev/null +++ b/pub/models/playletmod/playlet.go @@ -0,0 +1,342 @@ +package playletmod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.Playlet) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Playlet.ThemeIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.ThemeIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CountCollect, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.ThemeIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.ThemeIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.IsRecommend, Value: 1}, + {Key: FN.Playlet.RecommendRank, Value: -1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.ThemeIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CountBrowse, Value: -1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.CategoryIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.TagIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.TagIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.IsRecommend, Value: 1}, + {Key: FN.Playlet.RecommendRank, Value: -1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.TagIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CountBrowse, Value: -1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.CategoryIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.RecommendRank, Value: -1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.CategoryIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.IsRecommend, Value: 1}, + {Key: FN.Playlet.RecommendRank, Value: -1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.CategoryIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.CategoryIds, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CountBrowse, Value: -1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.IsFeatured, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.ShowOnBanner, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.TopRank, Value: -1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.IsRecommend, Value: 1}, + {Key: FN.Playlet.RecommendRank, Value: -1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CountCollect, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CountBrowse, Value: -1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.HeatValue, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.OnlineAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.UpdatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.MediaCenterId, Value: 1}, + }, + Options: options.Index(). + SetUnique(true). + SetPartialFilterExpression(bson.M{ + FN.Playlet.MediaCenterId: bson.M{"$gt": 0}, + }), + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOnePlaylet 插入一条短剧数据 +func InsertOnePlaylet(ctx context.Context, doc *Playlet) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.Playlet.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("playletmod:InsertOnePlaylet failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOnePlaylet 删除一条短剧数据 +func DelOnePlaylet(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playletmod:DelOnePlaylet failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOnePlaylet 查询一条短剧数据 +func FindOnePlaylet(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*Playlet, *srvdef.SrvErr) { + var doc Playlet + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("playletmod:FindOnePlaylet failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyPlaylet 按条件查询多条短剧数据 +func FindManyPlaylet(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletList, *srvdef.SrvErr) { + data := make(PlayletList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("playletmod:FindManyPlaylet failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountPlaylet 统计满足条件的短剧数量 +func CountPlaylet(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("playletmod:CountPlaylet failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// AggregatePlaylet 聚合查询短剧数据 +func AggregatePlaylet(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline, opts...); err != nil { + log.Warn("playletmod:AggregatePlaylet failed", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// UpdateOnePlaylet 更新一条短剧数据 +func UpdateOnePlaylet(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("playletmod:UpdateOnePlaylet failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// FindOneAndUpdatePlaylet 更新一条短剧数据并返回更新后的数据 +func FindOneAndUpdatePlaylet(ctx context.Context, filter bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) (*Playlet, *srvdef.SrvErr) { + var doc Playlet + if err := coll.FindOneAndUpdate(ctx, &doc, filter, update, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("更新并查询短剧数据失败", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// UpdateManyPlaylet 批量更新短剧数据 +func UpdateManyPlaylet(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("批量更新短剧数据失败", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// BulkWritePlaylet 批量写入短剧更新操作。 +func BulkWritePlaylet(ctx context.Context, docs []mongo.WriteModel, opts ...*options.BulkWriteOptions) *srvdef.SrvErr { + if len(docs) == 0 { + return nil + } + result, err := coll.BulkWrite(ctx, docs, opts...) + if err != nil { + log.Warn("批量写入短剧数据失败", log.E(err)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/playletmod/tag.go b/pub/models/playletmod/tag.go new file mode 100644 index 0000000..a30ba35 --- /dev/null +++ b/pub/models/playletmod/tag.go @@ -0,0 +1,134 @@ +package playletmod + +// TagPlaylet 保存 playlet 集合字段名 +type TagPlaylet struct { + Id string + MediaCenterId string + MediaCreatedAt string + MediaUpdatedAt string + MediaSyncTime string + Status string + IsFeatured string + IsOriginal string + ShowOriginal string + MediaBusiness string + IsRecommend string + ShowOnBanner string + Banner string + ReviewAt string + OnlineAt string + CategoryIds string + CategoryBinds string + ThemeIds string + Title string + Slug string + Desc string + Anchor string + Cover string + CompressedCover string + Preview string + TagIds string + OldTags string + ContentType string + PayType string + SingleCoins string + TotalEpisode string + FreeEpisodeCount string + UpdateStatus string + TopRank string + RecommendRank string + CountComment string + CountBuy string + CountBrowse string + VirtualCountBrowse string + WeekWatchs string + MonthWatchs string + YearWatchs string + WatchUpdatedAt string + HeatValue string + VirtualHeatValue string + CountCollect string + CountLike string + VirtualCountLike string + CountDisLike string + SeoInfo struct { + Description string + Keyworld string + Title string + } + IndexNow string + OperatorId string + OperatorName string + CreatedAt string + UpdatedAt string +} + +var FN struct { + Playlet TagPlaylet +} + +var TN struct { + Playlet string +} + +func init() { + FN.Playlet.Id = "_id" + FN.Playlet.MediaCenterId = "mediaCenterId" + FN.Playlet.MediaCreatedAt = "mediaCreatedAt" + FN.Playlet.MediaUpdatedAt = "mediaUpdatedAt" + FN.Playlet.MediaSyncTime = "mediaSyncTime" + FN.Playlet.Status = "status" + FN.Playlet.IsFeatured = "isFeatured" + FN.Playlet.IsOriginal = "isOriginal" + FN.Playlet.ShowOriginal = "showOriginal" + FN.Playlet.MediaBusiness = "mediaBusiness" + FN.Playlet.IsRecommend = "isRecommend" + FN.Playlet.ShowOnBanner = "showOnBanner" + FN.Playlet.Banner = "banner" + FN.Playlet.ReviewAt = "reviewAt" + FN.Playlet.OnlineAt = "onlineAt" + FN.Playlet.CategoryIds = "categoryIds" + FN.Playlet.CategoryBinds = "categoryBinds" + FN.Playlet.ThemeIds = "themeIds" + FN.Playlet.Title = "title" + FN.Playlet.Slug = "slug" + FN.Playlet.Desc = "desc" + FN.Playlet.Anchor = "anchor" + FN.Playlet.Cover = "cover" + FN.Playlet.CompressedCover = "compressedCover" + FN.Playlet.Preview = "preview" + FN.Playlet.TagIds = "tagIds" + FN.Playlet.OldTags = "oldTags" + FN.Playlet.ContentType = "contentType" + FN.Playlet.PayType = "payType" + FN.Playlet.SingleCoins = "singleCoins" + FN.Playlet.TotalEpisode = "totalEpisode" + FN.Playlet.FreeEpisodeCount = "freeEpisodeCount" + FN.Playlet.UpdateStatus = "updateStatus" + FN.Playlet.TopRank = "topRank" + FN.Playlet.RecommendRank = "recommendRank" + FN.Playlet.CountComment = "countComment" + FN.Playlet.CountBuy = "countBuy" + FN.Playlet.CountBrowse = "countBrowse" + FN.Playlet.VirtualCountBrowse = "virtualCountBrowse" + FN.Playlet.WeekWatchs = "weekWatchs" + FN.Playlet.MonthWatchs = "monthWatchs" + FN.Playlet.YearWatchs = "yearWatchs" + FN.Playlet.WatchUpdatedAt = "watchUpdatedAt" + FN.Playlet.HeatValue = "heatValue" + FN.Playlet.VirtualHeatValue = "virtualHeatValue" + FN.Playlet.CountCollect = "countCollect" + FN.Playlet.CountLike = "countLike" + FN.Playlet.VirtualCountLike = "virtualCountLike" + FN.Playlet.CountDisLike = "countDisLike" + FN.Playlet.SeoInfo.Description = "seoInfo.webDescription" + FN.Playlet.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.Playlet.SeoInfo.Title = "seoInfo.webTitle" + FN.Playlet.IndexNow = "indexNow" + FN.Playlet.OperatorId = "operatorId" + FN.Playlet.OperatorName = "operatorName" + FN.Playlet.CreatedAt = "createdAt" + FN.Playlet.UpdatedAt = "updatedAt" + + TN.Playlet = "playlet" +} diff --git a/pub/models/playletmod/type.go b/pub/models/playletmod/type.go new file mode 100644 index 0000000..8750ec0 --- /dev/null +++ b/pub/models/playletmod/type.go @@ -0,0 +1,143 @@ +package playletmod + +import ( + "strings" + "time" + "videoweb/pub/pubdef" +) + +// ContentType 内容类型(与媒资 media_type 对应) +type ContentType int32 + +const ( + ContentTypeTheater ContentType = 0 // 短剧(对应 media_type=5) + ContentTypeAlbum ContentType = 1 // 普通短剧/影集(对应 media_type=6) + ContentTypeComic ContentType = 2 // 漫画短剧(对应 media_type=4) + ContentTypeAnime ContentType = 3 // 动漫(对应 media_type=1) + ContentTypeManga ContentType = 4 // 漫画(对应 media_type=2) +) + +// Status 上架状态 +type Status int32 + +const ( + StatusDown Status = 0 // 下架 + StatusUp Status = 1 // 上架 +) + +// PayType 支付方式(剧级默认,连载上架后新增剧集沿用) +type PayType int32 + +const ( + PayTypeFree PayType = 0 // 免费 + PayTypeCoin PayType = 1 // 金币 + PayTypeVip PayType = 2 // VIP +) + +// Playlet @table: playlet +// +// Playlet 连载短剧集合模型 +type Playlet struct { + Id int64 `json:"id" bson:"_id,omitempty"` + MediaCenterId uint `json:"mediaCenterId" bson:"mediaCenterId"` // 媒资中心资源ID + MediaCreatedAt time.Time `json:"mediaCreatedAt" bson:"mediaCreatedAt"` // 媒资中心创建时间 + MediaUpdatedAt time.Time `json:"mediaUpdatedAt" bson:"mediaUpdatedAt"` // 媒资中心更新时间 + MediaSyncTime int64 `json:"mediaSyncTime" bson:"mediaSyncTime"` // 媒资中心增量同步时间点 + Status Status `json:"status" bson:"status"` // 状态: 0下架 1上架 + IsFeatured bool `json:"isFeatured" bson:"isFeatured"` // 是否爆剧(true-是,false-否) + IsOriginal bool `json:"isOriginal" bson:"isOriginal"` // 是否原创:后台新增为true,媒资导入为false + ShowOriginal bool `json:"showOriginal" bson:"showOriginal"` // 页面是否展示原创标识 + MediaBusiness string `json:"mediaBusiness" bson:"mediaBusiness"` // 媒资业务来源,按媒资标题前缀记录,如 CGDJ;空表示未标记 + IsRecommend bool `json:"isRecommend" bson:"isRecommend"` // 是否推荐 true-推荐,false-不推荐 + ShowOnBanner bool `json:"showOnBanner" bson:"showOnBanner"` // 是否显示在首页banner + Banner string `json:"banner" bson:"banner"` // banner图 + ReviewAt time.Time `json:"reviewAt" bson:"reviewAt"` // 审核时间 + OnlineAt time.Time `json:"onlineAt" bson:"onlineAt"` // 上架时间 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合 + CategoryBinds []PlayletCategoryBind `json:"categoryBinds" bson:"categoryBinds"` // 各分类下的排序 + ThemeIds []int64 `json:"themeIds" bson:"themeIds"` // 题材ID集合 + Title string `json:"title" bson:"title"` // 标题 + Slug string `json:"slug" bson:"slug"` // slug + Desc string `json:"desc" bson:"desc"` // 简介 + Anchor string `json:"anchor" bson:"anchor"` // 作者/演员 + Cover string `json:"cover" bson:"cover"` // 封面原图 + CompressedCover string `json:"compressedCover" bson:"compressedCover"` // 压缩封面,压缩成功才有值 + Preview string `json:"preview" bson:"preview"` // 预告 + TagIds []int64 `json:"tagIds" bson:"tagIds"` // 标签ID + OldTags []string `json:"oldTags" bson:"oldTags"` // 媒资库原标签名称 + ContentType ContentType `json:"contentType" bson:"contentType"` // 内容类型: 0短剧 1普通短剧/影集 2漫画短剧 3动漫 4漫画 + PayType PayType `json:"payType" bson:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins" bson:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + TotalEpisode int64 `json:"totalEpisode" bson:"totalEpisode"` // 总集数 + FreeEpisodeCount int64 `json:"freeEpisodeCount" bson:"freeEpisodeCount"` // 官网开放集数 1至1000表示开放第1集到第N集 -1表示开放全集 0或字段缺失按2集 + UpdateStatus int `json:"updateStatus" bson:"updateStatus"` // 更新状态 1-连载中 2-已完结 + TopRank int64 `json:"topRank" bson:"topRank"` // 置顶排名 + RecommendRank int64 `json:"recommendRank" bson:"recommendRank"` // 推荐排序 + CountComment int64 `json:"countComment" bson:"countComment"` // 评论数 + CountBuy int64 `json:"countBuy" bson:"countBuy"` // 购买数 + CountBrowse int64 `json:"countBrowse" bson:"countBrowse"` // 浏览数 + VirtualCountBrowse int64 `json:"virtualCountBrowse" bson:"virtualCountBrowse"` // 后台虚拟观看数 + WeekWatchs int64 `json:"weekWatchs" bson:"weekWatchs"` // 本周观看次数(北京时间) + MonthWatchs int64 `json:"monthWatchs" bson:"monthWatchs"` // 本月观看次数(北京时间) + YearWatchs int64 `json:"yearWatchs" bson:"yearWatchs"` // 本年观看次数(北京时间) + WatchUpdatedAt time.Time `json:"watchUpdatedAt" bson:"watchUpdatedAt"` // 最近一次观看计数时间 + HeatValue int64 `json:"heatValue" bson:"heatValue"` // 热度值 + VirtualHeatValue int64 `json:"virtualHeatValue" bson:"virtualHeatValue"` // 后台虚拟热度值 + CountCollect int64 `json:"countCollect" bson:"countCollect"` // 收藏数 + CountLike int64 `json:"countLike" bson:"countLike"` // 喜欢数 + VirtualCountLike int64 `json:"virtualCountLike" bson:"virtualCountLike"` // 虚拟喜欢数,后台配置 + CountDisLike int64 `json:"countDisLike" bson:"countDisLike"` // 不喜欢数 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 搜索引擎推送标记 0:待提交 1:已入队/已提交 + OperatorId int64 `json:"operatorId" bson:"operatorId"` // 最后操作人ID(后台账号) + OperatorName string `json:"operatorName" bson:"operatorName"` // 最后操作人名称 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// EffectiveFreeEpisodeCount 历史短剧未配置时官网默认开放前2集 +func (p *Playlet) EffectiveFreeEpisodeCount() int64 { + if p == nil || p.FreeEpisodeCount == 0 { + return 2 + } + return p.FreeEpisodeCount +} + +// IsOfficialEpisodeOpen 判断剧集是否在官网开放范围内 +func (p *Playlet) IsOfficialEpisodeOpen(episode int64) bool { + if p == nil || p.Status != StatusUp || episode <= 0 { + return false + } + count := p.EffectiveFreeEpisodeCount() + return count == -1 || episode <= count +} + +// PlayletList 短剧列表 +type PlayletList []*Playlet + +// DisplayCountBrowse 返回真实观看数与虚拟观看数之和 +func (p *Playlet) DisplayCountBrowse() int64 { + if p == nil { + return 0 + } + return p.CountBrowse + p.VirtualCountBrowse +} + +// DisplayHeatValue 返回原热度与虚拟热度之和 +func (p *Playlet) DisplayHeatValue() int64 { + if p == nil { + return 0 + } + return p.HeatValue + p.VirtualHeatValue +} + +// DisplayCover App 展示用封面:压缩图有值则优先,否则回退原图 +func (p *Playlet) DisplayCover() string { + if p == nil { + return "" + } + if cover := strings.TrimSpace(p.CompressedCover); cover != "" { + return cover + } + return strings.TrimSpace(p.Cover) +} diff --git a/pub/models/playlettagmod/playlettag.go b/pub/models/playlettagmod/playlettag.go new file mode 100644 index 0000000..f9dcd2d --- /dev/null +++ b/pub/models/playlettagmod/playlettag.go @@ -0,0 +1,121 @@ +package playlettagmod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_tag 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletTag) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletTag.Status, Value: 1}, + {Key: FN.PlayletTag.Rank, Value: -1}, + {Key: FN.PlayletTag.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletTag.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletTag.CreatedAt, Value: -1}, + {Key: FN.PlayletTag.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playlettagmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOnePlayletTag 插入一条标签数据 +func InsertOnePlayletTag(ctx context.Context, doc *PlayletTag) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletTag.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("playlettagmod:InsertOnePlayletTag failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOnePlayletTag 删除一条标签数据 +func DelOnePlayletTag(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playlettagmod:DelOnePlayletTag failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOnePlayletTag 查询一条标签数据 +func FindOnePlayletTag(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*PlayletTag, *srvdef.SrvErr) { + var doc PlayletTag + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("playlettagmod:FindOnePlayletTag failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyPlayletTag 按条件查询多条标签数据 +func FindManyPlayletTag(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletTagList, *srvdef.SrvErr) { + data := make(PlayletTagList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("playlettagmod:FindManyPlayletTag failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountPlayletTag 统计满足条件的标签数量 +func CountPlayletTag(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("playlettagmod:CountPlayletTag failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOnePlayletTag 更新一条标签数据 +func UpdateOnePlayletTag(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("playlettagmod:UpdateOnePlayletTag failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/playlettagmod/tag.go b/pub/models/playlettagmod/tag.go new file mode 100644 index 0000000..9751ed3 --- /dev/null +++ b/pub/models/playlettagmod/tag.go @@ -0,0 +1,38 @@ +package playlettagmod + +// TagPlayletTag 保存 playlet_tag 集合字段名 +type TagPlayletTag struct { + Id string + Name string + Status string + Rank string + SeoInfo struct { + Description string + Keyworld string + Title string + } + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletTag TagPlayletTag +} + +var TN struct { + PlayletTag string +} + +func init() { + FN.PlayletTag.Id = "_id" + FN.PlayletTag.Name = "name" + FN.PlayletTag.Status = "status" + FN.PlayletTag.Rank = "rank" + FN.PlayletTag.SeoInfo.Description = "seoInfo.webDescription" + FN.PlayletTag.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.PlayletTag.SeoInfo.Title = "seoInfo.webTitle" + FN.PlayletTag.CreatedAt = "createdAt" + FN.PlayletTag.UpdatedAt = "updatedAt" + + TN.PlayletTag = "playlet_tag" +} diff --git a/pub/models/playlettagmod/type.go b/pub/models/playlettagmod/type.go new file mode 100644 index 0000000..960fb80 --- /dev/null +++ b/pub/models/playlettagmod/type.go @@ -0,0 +1,22 @@ +package playlettagmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// PlayletTag @table: playlet_tag +// +// PlayletTag 短剧标签集合模型 +type PlayletTag struct { + Id int64 `json:"id" bson:"_id,omitempty"` // ID + Name string `json:"name" bson:"name"` // 标签名称 + Status bool `json:"status" bson:"status"` // 上架状态 + Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletTagList 标签列表 +type PlayletTagList []*PlayletTag diff --git a/pub/models/playletthememod/tag.go b/pub/models/playletthememod/tag.go new file mode 100644 index 0000000..fb8e962 --- /dev/null +++ b/pub/models/playletthememod/tag.go @@ -0,0 +1,42 @@ +package playletthememod + +// TagPlayletTheme 保存 playlet_theme 集合字段名 +type TagPlayletTheme struct { + Id string + Name string + Slug string + Icon string + Status string + Rank string + SeoInfo struct { + Description string + Keyworld string + Title string + } + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletTheme TagPlayletTheme +} + +var TN struct { + PlayletTheme string +} + +func init() { + FN.PlayletTheme.Id = "_id" + FN.PlayletTheme.Name = "name" + FN.PlayletTheme.Slug = "slug" + FN.PlayletTheme.Icon = "icon" + FN.PlayletTheme.Status = "status" + FN.PlayletTheme.Rank = "rank" + FN.PlayletTheme.SeoInfo.Description = "seoInfo.webDescription" + FN.PlayletTheme.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.PlayletTheme.SeoInfo.Title = "seoInfo.webTitle" + FN.PlayletTheme.CreatedAt = "createdAt" + FN.PlayletTheme.UpdatedAt = "updatedAt" + + TN.PlayletTheme = "playlet_theme" +} diff --git a/pub/models/playletthememod/theme.go b/pub/models/playletthememod/theme.go new file mode 100644 index 0000000..bba101d --- /dev/null +++ b/pub/models/playletthememod/theme.go @@ -0,0 +1,121 @@ +package playletthememod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_theme 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletTheme) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletTheme.Status, Value: 1}, + {Key: FN.PlayletTheme.Rank, Value: -1}, + {Key: FN.PlayletTheme.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletTheme.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletTheme.CreatedAt, Value: -1}, + {Key: FN.PlayletTheme.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletthememod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOnePlayletTheme 插入一条题材数据 +func InsertOnePlayletTheme(ctx context.Context, doc *PlayletTheme) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletTheme.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("playletthememod:InsertOnePlayletTheme failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOnePlayletTheme 删除一条题材数据 +func DelOnePlayletTheme(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("playletthememod:DelOnePlayletTheme failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOnePlayletTheme 查询一条题材数据 +func FindOnePlayletTheme(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*PlayletTheme, *srvdef.SrvErr) { + var doc PlayletTheme + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("playletthememod:FindOnePlayletTheme failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyPlayletTheme 按条件查询多条题材数据 +func FindManyPlayletTheme(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletThemeList, *srvdef.SrvErr) { + data := make(PlayletThemeList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("playletthememod:FindManyPlayletTheme failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountPlayletTheme 统计满足条件的题材数量 +func CountPlayletTheme(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("playletthememod:CountPlayletTheme failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOnePlayletTheme 更新一条题材数据 +func UpdateOnePlayletTheme(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("playletthememod:UpdateOnePlayletTheme failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/playletthememod/type.go b/pub/models/playletthememod/type.go new file mode 100644 index 0000000..8d08d59 --- /dev/null +++ b/pub/models/playletthememod/type.go @@ -0,0 +1,24 @@ +package playletthememod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// PlayletTheme @table: playlet_theme +// +// PlayletTheme 短剧题材集合模型 +type PlayletTheme struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Name string `json:"name" bson:"name"` // 题材名称 + Slug string `json:"slug" bson:"slug"` // slug + Icon string `json:"icon" bson:"icon"` // 图标 + Status bool `json:"status" bson:"status"` // 上架状态 + Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` +} + +// PlayletThemeList 题材列表 +type PlayletThemeList []*PlayletTheme diff --git a/pub/models/questionnairemod/questionnaire.go b/pub/models/questionnairemod/questionnaire.go new file mode 100644 index 0000000..004a2ff --- /dev/null +++ b/pub/models/questionnairemod/questionnaire.go @@ -0,0 +1,88 @@ +package questionnairemod + +import ( + "context" + "fmt" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.Questionnaire) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Questionnaire.Status, Value: 1}, + {Key: FN.Questionnaire.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Questionnaire.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(questionnaire:Init) Create Index failed! err:%+v", err)) + } +} + +// InsertOne 插入一条问卷 +func InsertOne(ctx context.Context, doc *QuestionnaireModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, doc); err != nil { + log.Warn("questionnairemod:InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOne 查找一条问卷 +func FindOne(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*QuestionnaireModel, *srvdef.SrvErr) { + var doc QuestionnaireModel + if err := db.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err == mongo.ErrNoDocuments { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + log.Warn("questionnairemod:FindOne failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &doc, nil +} + +// FindMany 查找多条问卷 +func FindMany(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (QuestionnaireModelList, *srvdef.SrvErr) { + data := make(QuestionnaireModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("questionnairemod:FindMany failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// Count 统计问卷数量 +func Count(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("questionnairemod:Count failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOne 更新一条问卷,返回 MatchedCount +func UpdateOne(ctx context.Context, filter bson.M, update bson.M) (int64, *srvdef.SrvErr) { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("questionnairemod:UpdateOne failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.MatchedCount, nil +} diff --git a/pub/models/questionnairemod/tag.go b/pub/models/questionnairemod/tag.go new file mode 100644 index 0000000..ccd97e6 --- /dev/null +++ b/pub/models/questionnairemod/tag.go @@ -0,0 +1,64 @@ +package questionnairemod + +// 问卷状态枚举 +const ( + StatusPending = "pending" //待处理 + StatusHandled = "processed" //已处理 + StatusRejected = "reject" //拒绝 +) + +// 附件类型枚举 +const ( + AttachTypeImage = "图片" + AttachTypeVideo = "视频" +) + +type TagQuestionnaire struct { + Id string + UserId string + QuestionType string + Frequency string + Province string + City string + Operator string + NetworkType string + DeviceType string + DeviceModel string + Description string + Contact string + Status string + Remark string + Attachments string + CreatedAt string + UpdatedAt string +} + +var FN struct { + Questionnaire TagQuestionnaire +} + +var TN struct { + Questionnaire string +} + +func init() { + FN.Questionnaire.Id = "_id" + FN.Questionnaire.UserId = "userId" + FN.Questionnaire.QuestionType = "questionType" + FN.Questionnaire.Frequency = "frequency" + FN.Questionnaire.Province = "province" + FN.Questionnaire.City = "city" + FN.Questionnaire.Operator = "operator" + FN.Questionnaire.NetworkType = "networkType" + FN.Questionnaire.DeviceType = "deviceType" + FN.Questionnaire.DeviceModel = "deviceModel" + FN.Questionnaire.Description = "description" + FN.Questionnaire.Contact = "contact" + FN.Questionnaire.Status = "status" + FN.Questionnaire.Remark = "remark" + FN.Questionnaire.Attachments = "attachments" + FN.Questionnaire.CreatedAt = "createdAt" + FN.Questionnaire.UpdatedAt = "updatedAt" + + TN.Questionnaire = "questionnaires" +} diff --git a/pub/models/questionnairemod/type.go b/pub/models/questionnairemod/type.go new file mode 100644 index 0000000..b38f7c1 --- /dev/null +++ b/pub/models/questionnairemod/type.go @@ -0,0 +1,37 @@ +package questionnairemod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Attachment 内嵌附件文档 +type Attachment struct { + FileType string `json:"fileType" bson:"fileType"` // 附件类型(img / video) + Url string `json:"url" bson:"url"` // 附件URL地址 +} + +// QuestionnaireModel 问卷调查主文档 +// @table: questionnaires +type QuestionnaireModel struct { + Id primitive.ObjectID `json:"id" bson:"_id,omitempty"` // 唯一标识(MongoDB自动生成) + UserId int64 `json:"userId" bson:"userId"` // App端登录用户ID,0表示匿名 + QuestionType string `json:"questionType" bson:"questionType"` // 问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题 + Frequency string `json:"frequency" bson:"frequency"` // 出现频率:每次都会、经常出现、偶尔出现、仅现一次 + Province string `json:"province" bson:"province"` // 所在省份(必填) + City string `json:"city" bson:"city"` // 所在城市(选填) + Operator string `json:"operator" bson:"operator"` // 运营商:电信、联通、移动、广电 + NetworkType string `json:"networkType" bson:"networkType"` // 网络类型:WI-FI、5G、4G、其他 + DeviceType string `json:"deviceType" bson:"deviceType"` // 设备类型:iPhone、Android、iPad、其他 + DeviceModel string `json:"deviceModel" bson:"deviceModel"` // 设备型号(选填) + Description string `json:"description" bson:"description"` // 问题描述(必填) + Contact string `json:"contact" bson:"contact"` // 联系方式(选填) + Status string `json:"status" bson:"status"` // 处理状态:待处理、已处理、拒绝 + Remark string `json:"remark" bson:"remark"` // 备注,处理问卷时填写(选填) + Attachments []Attachment `json:"attachments" bson:"attachments"` // 附件列表,最多3个 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间(自动生成) + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间(自动更新) +} + +type QuestionnaireModelList []*QuestionnaireModel diff --git a/pub/models/richtextcontentmod/richtextcontent.go b/pub/models/richtextcontentmod/richtextcontent.go new file mode 100644 index 0000000..4a0990a --- /dev/null +++ b/pub/models/richtextcontentmod/richtextcontent.go @@ -0,0 +1,160 @@ +package richtextcontentmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.RichTextContentModel) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.RichTextContentModel.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.RichTextContentModel.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.RichTextContentModel.Status, Value: 1}, + {Key: FN.RichTextContentModel.Sort, Value: -1}, + {Key: FN.RichTextContentModel.UpdatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(RichTextContentModelModel:initRichTextContentModelModelIndex) Create RichTextContentModelModel Index failed! err:%+v:", err)) + } +} + +// InsertOneRichTextContentModelModel 插入定时任务 +func InsertOneRichTextContentModelModel(ctx context.Context, RichTextContentModelModel *RichTextContentModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, RichTextContentModelModel); err != nil { + log.Warn("RichTextContentModelModel:InsertOneRichTextContentModelModel db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneRichTextContentModelModel 插入定时任务 +func FindOneRichTextContentModelModel(ctx context.Context, filter bson.M) ( + *RichTextContentModel, *srvdef.SrvErr) { + var RichTextContentModelModel RichTextContentModel + if err := db.FindOneEx(ctx, &RichTextContentModelModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("RichTextContentModelModel:FindOneRichTextContentModelModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &RichTextContentModelModel, nil +} + +// DelOneRichTextContentModelModel 插入定时任务 +func DelOneRichTextContentModelModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("RichTextContentModelModel:DelOneRichTextContentModelModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneRichTextContentModelModel 插入定时任务 +func UpdateOneRichTextContentModelModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("RichTextContentModelModel:UpdateOneRichTextContentModelModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpsertOneRichTextContentModelModel 更新插入一个记录 +func UpsertOneRichTextContentModelModel(ctx context.Context, filter bson.M, upsertDoc bson.M) *srvdef.SrvErr { + _, err := db.UpsertOne(ctx, filter, upsertDoc) + if err != nil { + log.Info("RichTextContentModelModel:UpsertOneRichTextContentModelModel db.UpsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// CountRichTextContentModelModels 统计用户数量 +func CountRichTextContentModelModels(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("RichTextContentModelModel:CountRichTextContentModelModels db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateManyRichTextContentModelModel 更新满足条件的用户 +func UpdateManyRichTextContentModelModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("RichTextContentModelModel:UpdateManyRichTextContentModelModel db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManyRichTextContentModelModel 查找一批用户数据 +func FindManyRichTextContentModelModel(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (RichTextContentModelList, *srvdef.SrvErr) { + data := make(RichTextContentModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("RichTextContentModelModel:FindManyRichTextContentModelModel db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManyRichTextContentModelModelWithBind 用自己定义结构查找一批用户数据 +func FindManyRichTextContentModelModelWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("RichTextContentModelModel:FindManyRichTextContentModelModelWithBind db.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateRichTextContentModelModels 聚合查找一批用户信息 +func AggregateRichTextContentModelModels(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("feedback:AggregateRichTextContentModelModels db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/richtextcontentmod/tag.go b/pub/models/richtextcontentmod/tag.go new file mode 100755 index 0000000..8598aa1 --- /dev/null +++ b/pub/models/richtextcontentmod/tag.go @@ -0,0 +1,40 @@ +package richtextcontentmod + +type TagRichTextContentModel struct { + Content string + CreatedAt string + Desc string + Id string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Sort string + Status string + Title string + UpdatedAt string +} + +var FN struct { + RichTextContentModel TagRichTextContentModel +} + +var TN struct { + RichTextContentModel string +} + +func init() { + FN.RichTextContentModel.Content = "content" + FN.RichTextContentModel.CreatedAt = "createdAt" + FN.RichTextContentModel.Desc = "desc" + FN.RichTextContentModel.Id = "_id" + FN.RichTextContentModel.SeoInfo.Description = "seoInfo.webDescription" + FN.RichTextContentModel.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.RichTextContentModel.SeoInfo.Title = "seoInfo.webTitle" + FN.RichTextContentModel.Sort = "sort" + FN.RichTextContentModel.Status = "status" + FN.RichTextContentModel.Title = "title" + FN.RichTextContentModel.UpdatedAt = "updatedAt" + TN.RichTextContentModel = "rich_text_content" +} diff --git a/pub/models/richtextcontentmod/type.go b/pub/models/richtextcontentmod/type.go new file mode 100644 index 0000000..bdc8223 --- /dev/null +++ b/pub/models/richtextcontentmod/type.go @@ -0,0 +1,22 @@ +package richtextcontentmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// RichTextContentModel 富文本内容 +// @table:rich_text_content +type RichTextContentModel struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 标题(内部) + Content string `json:"content" bson:"content"` // 内容 + Status bool `json:"status" bson:"status"` // 是否启用 + Sort int64 `json:"sort" bson:"sort"` // 排序值 + Desc string `json:"desc" bson:"desc"` // 标题(app) + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `bson:"createdAt" json:"createdAt" ` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt" ` // 更新时间 +} + +type RichTextContentModelList []*RichTextContentModel diff --git a/pub/models/seopushmod/push.go b/pub/models/seopushmod/push.go new file mode 100644 index 0000000..6ed088c --- /dev/null +++ b/pub/models/seopushmod/push.go @@ -0,0 +1,116 @@ +package seopushmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.SeoPushTask) + indexes := []mongo.IndexModel{ + { + // 同一个页面的同一种动作只保留一条任务,重复发布时复用并重置状态 + Keys: bson.D{ + {Key: FN.SeoPushTask.Url, Value: 1}, + {Key: FN.SeoPushTask.Action, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.SeoPushTask.GoogleStatus, Value: 1}, + {Key: FN.SeoPushTask.IndexNowStatus, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.SeoPushTask.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(SeoPushTask:Init) Create SeoPushTask Index failed! err:%+v:", err)) + } +} + +// UpsertPendingSeoPushTask 登记一条待推送任务,已存在则重置为待推送状态 +func UpsertPendingSeoPushTask(ctx context.Context, task *SeoPushTask) *srvdef.SrvErr { + filter := bson.M{ + FN.SeoPushTask.Url: task.Url, + FN.SeoPushTask.Action: task.Action, + } + update := bson.M{ + "$set": bson.M{ + FN.SeoPushTask.VideoId: task.VideoId, + FN.SeoPushTask.GoogleStatus: PushStatusPending, + FN.SeoPushTask.GoogleRetry: 0, + FN.SeoPushTask.GoogleErr: "", + FN.SeoPushTask.IndexNowStatus: PushStatusPending, + FN.SeoPushTask.IndexNowRetry: 0, + FN.SeoPushTask.IndexNowErr: "", + FN.SeoPushTask.UpdatedAt: task.UpdatedAt, + }, + "$setOnInsert": bson.M{ + FN.SeoPushTask.CreatedAt: task.CreatedAt, + }, + } + if _, err := db.UpsertOne(ctx, filter, update); err != nil { + log.Warn("seopushmod:UpsertPendingSeoPushTask db.UpsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManySeoPushTasks 查找一批推送任务 +func FindManySeoPushTasks(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (SeoPushTaskList, *srvdef.SrvErr) { + data := make(SeoPushTaskList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("seopushmod:FindManySeoPushTasks db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// UpdateOneSeoPushTask 更新一条推送任务 +func UpdateOneSeoPushTask(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + if _, err := db.UpdateOne(ctx, filter, update); err != nil { + log.Warn("seopushmod:UpdateOneSeoPushTask db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// DelManySeoPushTask 删除一批推送任务 +func DelManySeoPushTask(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("seopushmod:DelManySeoPushTask db.DeleteMany failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// CountSeoPushTasks 统计推送任务数量 +func CountSeoPushTasks(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("seopushmod:CountSeoPushTasks db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} diff --git a/pub/models/seopushmod/tag.go b/pub/models/seopushmod/tag.go new file mode 100644 index 0000000..cac28f7 --- /dev/null +++ b/pub/models/seopushmod/tag.go @@ -0,0 +1,51 @@ +package seopushmod + +type TagSeoPushTask struct { + ID string + Url string + VideoId string + Action string + + GoogleStatus string + GoogleRetry string + GoogleErr string + GoogleAt string + + IndexNowStatus string + IndexNowRetry string + IndexNowErr string + IndexNowAt string + + CreatedAt string + UpdatedAt string +} + +var FN struct { + SeoPushTask TagSeoPushTask +} + +var TN struct { + SeoPushTask string +} + +func init() { + FN.SeoPushTask.ID = "_id" + FN.SeoPushTask.Url = "url" + FN.SeoPushTask.VideoId = "videoId" + FN.SeoPushTask.Action = "action" + + FN.SeoPushTask.GoogleStatus = "googleStatus" + FN.SeoPushTask.GoogleRetry = "googleRetry" + FN.SeoPushTask.GoogleErr = "googleErr" + FN.SeoPushTask.GoogleAt = "googleAt" + + FN.SeoPushTask.IndexNowStatus = "indexNowStatus" + FN.SeoPushTask.IndexNowRetry = "indexNowRetry" + FN.SeoPushTask.IndexNowErr = "indexNowErr" + FN.SeoPushTask.IndexNowAt = "indexNowAt" + + FN.SeoPushTask.CreatedAt = "createdAt" + FN.SeoPushTask.UpdatedAt = "updatedAt" + + TN.SeoPushTask = "seo_push_task" +} diff --git a/pub/models/seopushmod/type.go b/pub/models/seopushmod/type.go new file mode 100644 index 0000000..d8e8e1d --- /dev/null +++ b/pub/models/seopushmod/type.go @@ -0,0 +1,47 @@ +package seopushmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// SeoPushTask 搜索引擎推送任务(Google Indexing API / IndexNow) +// 发布/更新/删除时即时推送一次,本表用于失败兜底重试。 +// @table:seo_push_task +type SeoPushTask struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Url string `json:"url" bson:"url"` // 页面完整地址 + VideoId int64 `json:"videoId" bson:"videoId"` // 关联资源ID + Action string `json:"action" bson:"action"` // 推送动作 updated/deleted + + GoogleStatus int64 `json:"googleStatus" bson:"googleStatus"` // Google 推送状态 + GoogleRetry int64 `json:"googleRetry" bson:"googleRetry"` // Google 已重试次数 + GoogleErr string `json:"googleErr" bson:"googleErr"` // Google 最近一次错误 + GoogleAt time.Time `json:"googleAt" bson:"googleAt"` // Google 最近一次推送时间 + + IndexNowStatus int64 `json:"indexNowStatus" bson:"indexNowStatus"` // IndexNow 推送状态 + IndexNowRetry int64 `json:"indexNowRetry" bson:"indexNowRetry"` // IndexNow 已重试次数 + IndexNowErr string `json:"indexNowErr" bson:"indexNowErr"` // IndexNow 最近一次错误 + IndexNowAt time.Time `json:"indexNowAt" bson:"indexNowAt"` // IndexNow 最近一次推送时间 + + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +type SeoPushTaskList []*SeoPushTask + +// 推送动作 +const ( + ActionUpdated = "updated" // 发布或更新,对应 Google URL_UPDATED + ActionDeleted = "deleted" // 删除,对应 Google URL_DELETED +) + +// 推送状态 +const ( + PushStatusPending = int64(0) // 待推送 + PushStatusSuccess = int64(1) // 推送成功 +) + +// MaxPushRetry 单个通道最大重试次数,达到上限后不再被补推任务捞取 +const MaxPushRetry = int64(5) diff --git a/pub/models/skdrecord/record.go b/pub/models/skdrecord/record.go new file mode 100644 index 0000000..b93294a --- /dev/null +++ b/pub/models/skdrecord/record.go @@ -0,0 +1,153 @@ +package skdrecord + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.SkdRecord) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.SkdRecord.Job, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.SkdRecord.CreatedAt, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(SkdRecord:initSkdRecordIndex) Create SkdRecord Index failed! err:%+v:", err)) + } +} + +// InsertOneSkdRecord 插入定时任务 +func InsertOneSkdRecord(ctx context.Context, SkdRecord *SkdRecord) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, SkdRecord); err != nil { + log.Warn("SkdRecord:InsertOneSkdRecord db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneSkdRecord 插入定时任务 +func FindOneSkdRecord(ctx context.Context, filter bson.M) ( + *SkdRecord, *srvdef.SrvErr) { + var SkdRecord SkdRecord + if err := db.FindOneEx(ctx, &SkdRecord, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("SkdRecord:FindOneSkdRecord db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrSkdRecordNotExsit, "", "") + } + return &SkdRecord, nil +} + +// DelOneSkdRecord 插入定时任务 +func DelOneSkdRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("SkdRecord:DelOneSkdRecord db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneSkdRecord 插入定时任务 +func UpdateOneSkdRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("SkdRecord:UpdateOneSkdRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrSkdRecordNotExsit, "", "") + } + return nil +} + +// UpsertOneSkdRecord 更新插入一个记录 +func UpsertOneSkdRecord(ctx context.Context, filter bson.M, upsertDoc bson.M) *srvdef.SrvErr { + _, err := db.UpsertOne(ctx, filter, upsertDoc) + if err != nil { + log.Info("SkdRecord:UpsertOneSkdRecord db.UpsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// CountSkdRecords 统计用户数量 +func CountSkdRecords(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("SkdRecord:CountSkdRecords db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateManySkdRecord 更新满足条件的用户 +func UpdateManySkdRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("SkdRecord:UpdateManySkdRecord db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManySkdRecord 查找一批用户数据 +func FindManySkdRecord(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (SkdRecordList, *srvdef.SrvErr) { + data := make(SkdRecordList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("SkdRecord:FindManySkdRecord db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManySkdRecordWithBind 用自己定义结构查找一批用户数据 +func FindManySkdRecordWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("SkdRecord:FindManySkdRecordWithBind db.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateSkdRecords 聚合查找一批用户信息 +func AggregateSkdRecords(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("feedback:AggregateSkdRecords db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/skdrecord/tag.go b/pub/models/skdrecord/tag.go new file mode 100755 index 0000000..0344cef --- /dev/null +++ b/pub/models/skdrecord/tag.go @@ -0,0 +1,30 @@ +package skdrecord + +type TagSkdRecord struct { + CreatedAt string + FirstRecordTime string + ID string + Item string + Job string + RecordTime string + UpdatedAt string +} + +var FN struct { + SkdRecord TagSkdRecord +} + +var TN struct { + SkdRecord string +} + +func init() { + FN.SkdRecord.CreatedAt = "createdAt" + FN.SkdRecord.FirstRecordTime = "firstRecordTime" + FN.SkdRecord.ID = "_id" + FN.SkdRecord.Item = "item" + FN.SkdRecord.Job = "job" + FN.SkdRecord.RecordTime = "recordTime" + FN.SkdRecord.UpdatedAt = "updatedAt" + TN.SkdRecord = "skd_record" +} diff --git a/pub/models/skdrecord/type.go b/pub/models/skdrecord/type.go new file mode 100644 index 0000000..bbd641c --- /dev/null +++ b/pub/models/skdrecord/type.go @@ -0,0 +1,27 @@ +package skdrecord + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 表名 +// SkdRecord 获取充值记录 +// @table:skd_record +type SkdRecord struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Job string `bson:"job"` // 工作 + Item string `bson:"item"` // 统计项 + RecordTime time.Time `bson:"recordTime"` // 提交时间 + FirstRecordTime time.Time `bson:"firstRecordTime"` // 第一次提交时间 + CreatedAt time.Time `bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt"` // 更新时间 +} + +const ( + GameRecordJob = "GameRecordJob" // 按天统计访问信息 + LoufengUpdate = "LoufengUpdate" // 楼风更新信息 +) + +type SkdRecordList []*SkdRecord diff --git a/pub/models/syncfilemod/sync.go b/pub/models/syncfilemod/sync.go new file mode 100644 index 0000000..60577bb --- /dev/null +++ b/pub/models/syncfilemod/sync.go @@ -0,0 +1,118 @@ +package syncfilemod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.SyncRecord) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.SyncRecord.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(SyncRecord:InitSyncRecord) Create SyncRecord Index failed! err:%+v:", err)) + } +} + +// CountSyncRecord 统计活动数量 +func CountSyncRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("SyncRecordmod:CountSyncRecord db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// InsertOneSyncRecord 插入一条活动 +func InsertOneSyncRecord(ctx context.Context, SyncRecord *SyncRecord) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, SyncRecord); err != nil { + log.Info("SyncRecordmod:InsertOneSyncRecord db.InsertOne failed", + log.E(err), log.Any("SyncRecord", SyncRecord)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneSyncRecord 删除一个活动 +func DelOneSyncRecord(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("SyncRecordmod:DelOneSyncRecord db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneSyncRecord 更新一个SyncRecord +func UpdateOneSyncRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("SyncRecordmod:UpdateOneSyncRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("SyncRecordmod:UpdateOneSyncRecord db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ObjectNotExist, "", "") + } + return nil +} + +// UpsertOneSyncRecord 更新一个 SyncRecord,不存在则新建 +func UpsertOneSyncRecord(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + if _, err := db.UpsertOne(ctx, filter, update); err != nil { + log.Warn("SyncRecordmod:UpsertOneSyncRecord db.UpsertOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManySyncRecord 查找一批活动数据 +func FindManySyncRecord(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (SyncRecordList, *srvdef.SrvErr) { + data := make(SyncRecordList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("SyncRecordmod:FindManyAcivities db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindOneSyncRecord 查找一个媒体 +func FindOneSyncRecord(ctx context.Context, filter bson.M) ( + *SyncRecord, *srvdef.SrvErr) { + var SyncRecord SyncRecord + if err := db.FindOneEx(ctx, &SyncRecord, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("SyncRecord:FindOneSyncRecord db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ObjectDayStatDoesNotExist, "", "") + } + return &SyncRecord, nil +} diff --git a/pub/models/syncfilemod/tag.go b/pub/models/syncfilemod/tag.go new file mode 100755 index 0000000..b9da6f4 --- /dev/null +++ b/pub/models/syncfilemod/tag.go @@ -0,0 +1,30 @@ +package syncfilemod + +type TagSyncRecord struct { + CreatedAt string + ID string + Job string + RecordId string + SuccessAt string + UpdatedAt string + RecordTime string +} + +var FN struct { + SyncRecord TagSyncRecord +} + +var TN struct { + SyncRecord string +} + +func init() { + FN.SyncRecord.CreatedAt = "createdAt" + FN.SyncRecord.ID = "_id" + FN.SyncRecord.Job = "job" + FN.SyncRecord.RecordId = "recordId" + FN.SyncRecord.SuccessAt = "successAt" + FN.SyncRecord.UpdatedAt = "updatedAt" + FN.SyncRecord.RecordTime = "recordTime" + TN.SyncRecord = "sync_record" +} \ No newline at end of file diff --git a/pub/models/syncfilemod/type.go b/pub/models/syncfilemod/type.go new file mode 100644 index 0000000..9abe355 --- /dev/null +++ b/pub/models/syncfilemod/type.go @@ -0,0 +1,30 @@ +package syncfilemod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// 资源同步 +// @table:sync_record +type SyncRecord struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Job string `json:"job" bson:"job"` // 工作 + RecordId int64 `json:"recordId" bson:"recordId"` // 用户ID + RecordTime time.Time `json:"recordTime" bson:"recordTime"` // 记录时间 + SuccessAt time.Time `json:"successAt" bson:"successAt"` // 成功时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} +type SyncRecordList []*SyncRecord + +const ( + VideoJob = "videoJob" // 视频同步 + PlayletWatermarkJob = "playletWatermarkJob" // 短剧水印同步 + SitemapDirtyJob = "sitemapDirtyJob" // sitemap 内容变更标记(web 写入,app 消费) +) + +var SyncJobDescribe = map[string]string{ + SitemapDirtyJob: "sitemap内容变更标记", +} diff --git a/pub/models/translationcategorymod/category.go b/pub/models/translationcategorymod/category.go new file mode 100644 index 0000000..6f1b04e --- /dev/null +++ b/pub/models/translationcategorymod/category.go @@ -0,0 +1,120 @@ +package translationcategorymod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.TranslationCategory) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.TranslationCategory.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.TranslationCategory.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.TranslationCategory.Status, Value: 1}, + {Key: FN.TranslationCategory.CategoryType, Value: 1}, + {Key: FN.TranslationCategory.Rank, Value: -1}, + {Key: FN.TranslationCategory.Id, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initTranslationCategoryIndex) Create TranslationCategory Index failed! err:%+v:", err)) + } + +} + +// InsertOneTranslationCategory 插入一个导航分类 +func InsertOneTranslationCategory(ctx context.Context, qa *TranslationCategory) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("TranslationCategorymod:InsertOneTranslationCategory db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneTranslationCategory 删除一个导航分类 +func DelOneTranslationCategory(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("TranslationCategorymod:DelOneTranslationCategory db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneTranslationCategory 查找一个导航分类 +func FindOneTranslationCategory(ctx context.Context, filter bson.M) ( + *TranslationCategory, *srvdef.SrvErr) { + var TranslationCategory TranslationCategory + if err := db.FindOneEx(ctx, &TranslationCategory, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("TranslationCategorymod:FindOneTranslationCategory db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &TranslationCategory, nil +} + +func FindManyTranslationCategorys(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (TranslationCategoryList, *srvdef.SrvErr) { + data := make(TranslationCategoryList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyTranslationCategorys", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountTranslationCategorys 统计导航分类数量 +func CountTranslationCategorys(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountTranslationCategorys db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneTranslationCategory 更新一个导航分类 +func UpdateOneTranslationCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneTranslationCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneTranslationCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/translationcategorymod/tag.go b/pub/models/translationcategorymod/tag.go new file mode 100755 index 0000000..9bf402d --- /dev/null +++ b/pub/models/translationcategorymod/tag.go @@ -0,0 +1,34 @@ +package translationcategorymod + +type TagTranslationCategory struct { + CategoryType string + CreatedAt string + Id string + Name string + Rank string + Status string + UpdatedAt string + UrlValue string + Icon string +} + +var FN struct { + TranslationCategory TagTranslationCategory +} + +var TN struct { + TranslationCategory string +} + +func init() { + FN.TranslationCategory.CategoryType = "categoryType" + FN.TranslationCategory.CreatedAt = "createdAt" + FN.TranslationCategory.Id = "_id" + FN.TranslationCategory.Name = "name" + FN.TranslationCategory.Rank = "rank" + FN.TranslationCategory.Status = "status" + FN.TranslationCategory.UpdatedAt = "updatedAt" + FN.TranslationCategory.UrlValue = "urlValue" + FN.TranslationCategory.Icon = "icon" + TN.TranslationCategory = "translation_category" +} diff --git a/pub/models/translationcategorymod/type.go b/pub/models/translationcategorymod/type.go new file mode 100644 index 0000000..d49c172 --- /dev/null +++ b/pub/models/translationcategorymod/type.go @@ -0,0 +1,20 @@ +package translationcategorymod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @table:translation_category +type TranslationCategory struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + Icon string `json:"icon" bson:"icon"` // Icon + Status bool `json:"status" bson:"status"` // 上架状态 + CategoryType pubdef.TranslationType `json:"categoryType" bson:"categoryType"` // 导航类型 1:url 2:二级导航 3:底部导航 + UrlValue string `json:"urlValue" bson:"urlValue"` // 跳转URL + Rank int64 `json:"rank" bson:"rank"` // 排序 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type TranslationCategoryList []*TranslationCategory diff --git a/pub/models/translationmod/tag.go b/pub/models/translationmod/tag.go new file mode 100755 index 0000000..671d058 --- /dev/null +++ b/pub/models/translationmod/tag.go @@ -0,0 +1,36 @@ +package translationmod + +type TagTranslationInfo struct { + CreatedAt string + HtmlId string + Id string + Link string + Name string + Rank string + Status string + TransCategoryId string + UpdatedAt string + IsFriendAds string +} + +var FN struct { + TranslationInfo TagTranslationInfo +} + +var TN struct { + TranslationInfo string +} + +func init() { + FN.TranslationInfo.CreatedAt = "createdAt" + FN.TranslationInfo.HtmlId = "htmlId" + FN.TranslationInfo.Id = "_id" + FN.TranslationInfo.Link = "link" + FN.TranslationInfo.Name = "name" + FN.TranslationInfo.Rank = "rank" + FN.TranslationInfo.Status = "status" + FN.TranslationInfo.TransCategoryId = "transCategoryId" + FN.TranslationInfo.UpdatedAt = "updatedAt" + FN.TranslationInfo.IsFriendAds = "isFriendAds" + TN.TranslationInfo = "translation_info" +} diff --git a/pub/models/translationmod/translation.go b/pub/models/translationmod/translation.go new file mode 100644 index 0000000..9f838bc --- /dev/null +++ b/pub/models/translationmod/translation.go @@ -0,0 +1,120 @@ +package translationmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.TranslationInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.TranslationInfo.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.TranslationInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.TranslationInfo.Status, Value: 1}, + {Key: FN.TranslationInfo.TransCategoryId, Value: 1}, + {Key: FN.TranslationInfo.Rank, Value: -1}, + {Key: FN.TranslationInfo.Id, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initTranslationInfoIndex) Create TranslationInfo Index failed! err:%+v:", err)) + } + +} + +// InsertOneTranslationInfo 插入一个渠道统计 +func InsertOneTranslationInfo(ctx context.Context, qa *TranslationInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("TranslationInfomod:InsertOneTranslationInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneTranslationInfo 删除一个渠道统计 +func DelOneTranslationInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("TranslationInfomod:DelOneTranslationInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneTranslationInfo 查找一个渠道统计 +func FindOneTranslationInfo(ctx context.Context, filter bson.M) ( + *TranslationInfo, *srvdef.SrvErr) { + var TranslationInfo TranslationInfo + if err := db.FindOneEx(ctx, &TranslationInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("TranslationInfomod:FindOneTranslationInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &TranslationInfo, nil +} + +func FindManyTranslationInfos(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (TranslationInfoList, *srvdef.SrvErr) { + data := make(TranslationInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyTranslationInfos", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountTranslationInfos 统计渠道统计数量 +func CountTranslationInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountTranslationInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneTranslationInfo 更新一个渠道统计 +func UpdateOneTranslationInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneTranslationInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneTranslationInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/translationmod/type.go b/pub/models/translationmod/type.go new file mode 100644 index 0000000..f0270ef --- /dev/null +++ b/pub/models/translationmod/type.go @@ -0,0 +1,20 @@ +package translationmod + +import ( + "time" +) + +// @table:translation_info +type TranslationInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + TransCategoryId int64 `json:"transCategoryId" bson:"transCategoryId"` // 导航分类 + IsFriendAds bool `json:"isFriendAds" bson:"isFriendAds"` // 是否是友链广告位 + HtmlId int64 `json:"htmlId" bson:"htmlId"` // 富文本ID + Link string `json:"link" bson:"link"` // 外部跳转 + Rank int64 `json:"rank" bson:"rank"` // 排序 + Status bool `json:"status" bson:"status"` // 上架状态 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type TranslationInfoList []*TranslationInfo diff --git a/pub/models/userlogin/tag.go b/pub/models/userlogin/tag.go new file mode 100755 index 0000000..aaef1ff --- /dev/null +++ b/pub/models/userlogin/tag.go @@ -0,0 +1,26 @@ +package userlogin + +type TagUserLogin struct { + CreatedAt string + Date string + Id string + UserId string + ContinuousLoginCount string +} + +var FN struct { + UserLogin TagUserLogin +} + +var TN struct { + UserLogin string +} + +func init() { + FN.UserLogin.CreatedAt = "createdAt" + FN.UserLogin.Date = "date" + FN.UserLogin.Id = "_id" + FN.UserLogin.UserId = "user_id" + FN.UserLogin.ContinuousLoginCount = "continuousLoginCount" + TN.UserLogin = "user_login" +} diff --git a/pub/models/userlogin/type.go b/pub/models/userlogin/type.go new file mode 100644 index 0000000..c4744e6 --- /dev/null +++ b/pub/models/userlogin/type.go @@ -0,0 +1,17 @@ +package userlogin + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// @table:user_login +type UserLogin struct { + Id primitive.ObjectID `json:"id" bson:"_id"` //ID + Date string `json:"date" bson:"date"` //签到日期,格式:pubdef.DateISO + UserId int64 `json:"user_id" bson:"user_id"` //用户ID + ContinuousLoginCount int `json:"continuousLoginCount" bson:"continuousLoginCount"` //连续登陆次数 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` //创建时间 +} +type UserLoginList []*UserLogin diff --git a/pub/models/userlogin/userlogin.go b/pub/models/userlogin/userlogin.go new file mode 100644 index 0000000..2ac0052 --- /dev/null +++ b/pub/models/userlogin/userlogin.go @@ -0,0 +1,108 @@ +package userlogin + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.UserLogin) + // 初始化索引 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.UserLogin.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.UserLogin.UserId, Value: 1}, + {Key: FN.UserLogin.Date, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(userlogin:InitUserLogin) Create UserLogin Index failed! err:%+v:", err)) + } +} + +// InsertUserLogin 插入一个登陆记录 +func InsertUserLogin(ctx context.Context, login *UserLogin) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, login); err != nil { + log.Warn("userlogin:InsertUserLogin db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// AggregateLogin 聚合查找一批登陆记录 +func AggregateLogin(ctx context.Context, + pipeline []bson.M) (*UserLogin, *srvdef.SrvErr) { + var login UserLogin + if err := db.ObjectAggregate(ctx, &login, pipeline); err != nil { + log.Warn("userlogin:AggregateLogin db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &login, nil +} + +// FindOneLogin 查找一个登陆记录 +func FindOneLogin(ctx context.Context, filter bson.M) ( + *UserLogin, *srvdef.SrvErr) { + var login UserLogin + if err := db.FindOneEx(ctx, &login, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("User:FindOneUser db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, nil + } + return &login, nil +} + +// CountLogins 统计登陆记录数量 +func CountLogin(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("userlogin:CountLogins db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// FindManyClickStats 查找一批数据 +func FindManyLogin(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) (UserLoginList, *srvdef.SrvErr) { + data := make(UserLoginList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("userlogin:FindManyLogins db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// DelOneLogin 删除一个用户登陆记录 +func DelOneLogin(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("userloginsetup:DelOneSetup db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} diff --git a/pub/models/usermod/def.go b/pub/models/usermod/def.go new file mode 100644 index 0000000..7fcee9e --- /dev/null +++ b/pub/models/usermod/def.go @@ -0,0 +1,18 @@ +package usermod + +import ( + "videoweb/pub/pubdef" +) + +func (user *User) GetID() int64 { + return user.Id +} + +func (user *User) GetStatus() pubdef.UserStatus { + return user.Status +} + +// CheckCoinEnough 检查金币是否足够扣除 +func (user *User) CheckCoinEnough(need int64) bool { + return user.Recharge >= need +} diff --git a/pub/models/usermod/tag.go b/pub/models/usermod/tag.go new file mode 100755 index 0000000..59a3862 --- /dev/null +++ b/pub/models/usermod/tag.go @@ -0,0 +1,99 @@ +package usermod + +type TagUser struct { + Id string // ID + Status string // 状态 + Region string // 地区 + RegIp string // 注册IP + LastIp string // 上次登陆IP + Backgroud string // 背景图 + Avatar string // 头像 + NickName string // 昵称 + Introduction string // 简介 + Gender string // 性别 0-未知,1-男,2-女 + LoginType string // 登陆方式 + Age string // 年龄 + LastLoginAt string // 上次登陆时间 + InviteCode string // 邀请码 + DevID string // 设备唯一标示 + Mac string // 设备唯一mac地址 + DevV2 string // 设备唯一设备ID + Device string // 设备 + SysType string // 系统类型 ios android + AppVer string // app版本 + Invites string // 我邀请的人的个数(我邀请他人) + AgentInviteCode string // 被邀请的inviteCode + Password string // 登陆密码 + Recharge string // 当前充值余额,金币个数 + RechargeTotal string // 累计充值额,金币个数 + ConsumptionTotal string // 金币总消费数量 + ModifyUserNameAt string // 上一次修改用户时间 + Auth string // 是否认证 + ConsecutiveLogin string // 连续登陆天数 + DistrictCode string // 商区码 + IsRegister string // 是否已注册 + Score string // 积分 + ScoreTotal string // 累计积分 + ScoreLvName string // 积分等级名称 + ScoreInvites string // 邀请人获得的积分累计 + RegisterTime string // + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 + CurrentVipExpire string // 当前vip过期时间 + SysMsgAt string // 获取系统消息时间 + OriVer string // 注册时的app版本 + AccountNo string // 账号 +} + +var FN struct { + User TagUser +} +var TN struct { + User string +} + +func init() { + FN.User.Id = "_id" + FN.User.Status = "status" + FN.User.Region = "region" + FN.User.RegIp = "regIp" + FN.User.LastIp = "lastIp" + FN.User.Backgroud = "backgroud" + FN.User.Avatar = "avatar" + FN.User.NickName = "nickName" + FN.User.Introduction = "introduction" + FN.User.Gender = "gender" + FN.User.LoginType = "loginType" + FN.User.Age = "age" + FN.User.LastLoginAt = "lastLoginAt" + FN.User.InviteCode = "inviteCode" + FN.User.DevID = "devID" + FN.User.Mac = "mac" + FN.User.DevV2 = "devV2" + FN.User.Device = "device" + FN.User.SysType = "sysType" + FN.User.AppVer = "appVer" + FN.User.Invites = "invites" + FN.User.AgentInviteCode = "agentInviteCode" + FN.User.Password = "password" + FN.User.Recharge = "recharge" + FN.User.RechargeTotal = "rechargeTotal" + FN.User.ConsumptionTotal = "consumptionTotal" + FN.User.ModifyUserNameAt = "modifyUserNameAt" + FN.User.Auth = "auth" + FN.User.ConsecutiveLogin = "consecutiveLogin" + FN.User.DistrictCode = "districtCode" + FN.User.IsRegister = "isRegister" + FN.User.Score = "score" + FN.User.ScoreTotal = "scoreTotal" + FN.User.ScoreLvName = "scoreLvName" + FN.User.ScoreInvites = "scoreInvites" + FN.User.RegisterTime = "registerTime" + FN.User.CreatedAt = "createdAt" + FN.User.UpdatedAt = "updatedAt" + FN.User.CurrentVipExpire = "currentVipExpire" + FN.User.SysMsgAt = "sysMsgAt" + FN.User.OriVer = "oriVer" + FN.User.AccountNo = "accountNo" + TN.User = "user" +} diff --git a/pub/models/usermod/type.go b/pub/models/usermod/type.go new file mode 100644 index 0000000..2b858a3 --- /dev/null +++ b/pub/models/usermod/type.go @@ -0,0 +1,82 @@ +//go:generate gen at -t table -s User +//go:generate gen bt +//go:generate gen mm +//go:generate gen g update +package usermod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// NormalizeDate 将时间归一化为当天北京时间零点 +func NormalizeDate(t time.Time) time.Time { + loc, _ := time.LoadLocation("Asia/Shanghai") + t = t.In(loc) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} + +type TagStat struct { + //标签名字 + Tag string `json:"tag" bson:"tag"` + Count int64 `json:"count" bson:"count"` +} + +type TagStatList []*TagStat + +// @table:user +// +//go:generate fox mongo --tag +type User struct { + Id int64 `json:"id" bson:"_id"` // ID + AccountNo string `json:"accountNo" bson:"accountNo"` // 账号 + Status pubdef.UserStatus `json:"status" bson:"status"` // 状态 + Region string `json:"region" bson:"region"` // 地区 + RegIp string `json:"regIp" bson:"regIp"` // 注册IP + LastIp string `json:"lastIp" bson:"lastIp"` // 上次登陆IP + Backgroud string `json:"backgroud" bson:"backgroud"` // 背景图 + Avatar string `json:"avatar" bson:"avatar"` // 头像 + NickName string `json:"nickName" bson:"nickName"` // 昵称 + Introduction string `json:"introduction" bson:"introduction"` // 简介 + Gender int64 `json:"gender" bson:"gender"` // 性别 0-未知,1-男,2-女 + LoginType pubdef.LoginType `json:"loginType" bson:"loginType"` // 登陆方式 + Age int64 `json:"age" bson:"age"` // 年龄 + LastLoginAt time.Time `json:"lastLoginAt" bson:"lastLoginAt"` // 上次登陆时间 + InviteCode string `json:"inviteCode" bson:"inviteCode"` // 邀请码 + DevID string `json:"devID" bson:"devID"` // 设备唯一标示 + Mac string `json:"mac" bson:"mac"` // 设备唯一mac地址 + DevV2 string `json:"devV2" bson:"devV2"` // 设备唯一设备ID + Device string `json:"device" bson:"device"` // 设备 + SysType string `json:"sysType" bson:"sysType"` // 系统类型 ios android + AppVer string `json:"appVer" bson:"appVer"` // app版本 + Invites int64 `json:"invites" bson:"invites"` // 我邀请的人的个数(我邀请他人) + AgentInviteCode string `json:"agentInviteCode" bson:"agentInviteCode"` // 被邀请的inviteCode + Password string `json:"password" bson:"password"` // 登陆密码 + Recharge int64 `json:"recharge" bson:"recharge"` // 当前充值余额,金币个数 + RechargeTotal int64 `json:"rechargeTotal" bson:"rechargeTotal"` // 累计充值额,金币个数 + ConsumptionTotal int64 `json:"consumptionTotal" bson:"consumptionTotal"` // 金币总消费数量 + ModifyUserNameAt time.Time `json:"modifyUserNameAt" bson:"modifyUserNameAt"` // 上一次修改用户时间 + Auth bool `json:"auth" bson:"auth"` // 是否认证 + ConsecutiveLogin int64 `json:"consecutiveLogin" bson:"consecutiveLogin"` // 连续登陆天数 + DistrictCode string `json:"districtCode" bson:"districtCode"` // 商区码 + IsRegister bool `json:"isRegister" bson:"isRegister"` // 是否已注册 + Score int64 `json:"score" bson:"score"` // 积分 + ScoreTotal int64 `json:"scoreTotal" bson:"scoreTotal"` // 累计积分 + ScoreLvName string `json:"scoreLvName" bson:"scoreLvName"` // 积分等级名称 + ScoreInvites int64 `json:"scoreInvites" bson:"scoreInvites"` // 邀请人获得的积分累计 + RegisterTime time.Time `json:"registerTime" bson:"registerTime"` // + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CurrentVipExpire time.Time `json:"currentVipExpire" bson:"currentVipExpire"` // 当前vip过期时间 + SysMsgAt time.Time `json:"sysMsgAt" bson:"sysMsgAt"` // 获取系统消息时间 + OriVer string `json:"oriVer" bson:"oriVer"` // 注册时的app版本 + +} + +type UserList []*User +type UserMap map[int64]*User + +// CheckCoinNumber 检查金币数量是否足够 +func (user *User) CheckCoinNumber(needDeduct int64) bool { + return user.Recharge >= needDeduct +} diff --git a/pub/models/usermod/type_update.go b/pub/models/usermod/type_update.go new file mode 100644 index 0000000..aa2edb8 --- /dev/null +++ b/pub/models/usermod/type_update.go @@ -0,0 +1,55 @@ +// Code generated by gen g update. DO NOT EDIT. +package usermod + +import ( + "time" + "videoweb/pub/pubdef" +) + +type UserUpdate struct { + Status *pubdef.UserStatus `json:"status" bson:"status,omitempty"` // 状态 + Region *string `json:"region" bson:"region,omitempty"` // 地区 + RegIp *string `json:"regIp" bson:"regIp,omitempty"` // 注册IP + LastIp *string `json:"lastIp" bson:"lastIp,omitempty"` // 上次登陆IP + Backgroud *string `json:"backgroud" bson:"backgroud,omitempty"` // 背景图 + Avatar *string `json:"avatar" bson:"avatar,omitempty"` // 头像 + NickName *string `json:"nickName" bson:"nickName,omitempty"` // 昵称 + Introduction *string `json:"introduction" bson:"introduction,omitempty"` // 简介 + Gender *int64 `json:"gender" bson:"gender,omitempty"` // 性别 0-未知,1-男,2-女 + LoginType *pubdef.LoginType `json:"loginType" bson:"loginType,omitempty"` // 登陆方式 + Age *int64 `json:"age" bson:"age,omitempty"` // 年龄 + LastLoginAt *time.Time `json:"lastLoginAt" bson:"lastLoginAt,omitempty"` // 上次登陆时间 + InviteCode *string `json:"inviteCode" bson:"inviteCode,omitempty"` // 邀请码 + DevID *string `json:"devID" bson:"devID,omitempty"` // 设备唯一标示 + Mac *string `json:"mac" bson:"mac,omitempty"` // 设备唯一mac地址 + DevV2 *string `json:"devV2" bson:"devV2,omitempty"` // 设备唯一设备ID + Device *string `json:"device" bson:"device,omitempty"` // 设备 + SysType *string `json:"sysType" bson:"sysType,omitempty"` // 系统类型 ios android + AppVer *string `json:"appVer" bson:"appVer,omitempty"` // app版本 + Invites *int64 `json:"invites" bson:"invites,omitempty"` // 我邀请的人的个数(我邀请他人) + AgentInviteCode *string `json:"agentInviteCode" bson:"agentInviteCode,omitempty"` // 被邀请的inviteCode + Password *string `json:"password" bson:"password,omitempty"` // 登陆密码 + Recharge *int64 `json:"recharge" bson:"recharge,omitempty"` // 当前充值余额,金币个数 + RechargeTotal *int64 `json:"rechargeTotal" bson:"rechargeTotal,omitempty"` // 累计充值额,金币个数 + ConsumptionTotal *int64 `json:"consumptionTotal" bson:"consumptionTotal,omitempty"` // 金币总消费数量 + ModifyUserNameAt *time.Time `json:"modifyUserNameAt" bson:"modifyUserNameAt,omitempty"` // 上一次修改用户时间 + Auth *bool `json:"auth" bson:"auth,omitempty"` // 是否认证 + ConsecutiveLogin *int64 `json:"consecutiveLogin" bson:"consecutiveLogin,omitempty"` // 连续登陆天数 + DistrictCode *string `json:"districtCode" bson:"districtCode,omitempty"` // 商区码 + IsRegister *bool `json:"isRegister" bson:"isRegister,omitempty"` // 是否已注册 + Score *int64 `json:"score" bson:"score,omitempty"` // 积分 + ScoreTotal *int64 `json:"scoreTotal" bson:"scoreTotal,omitempty"` // 累计积分 + ScoreLvName *string `json:"scoreLvName" bson:"scoreLvName,omitempty"` // 积分等级名称 + ScoreInvites *int64 `json:"scoreInvites" bson:"scoreInvites,omitempty"` // 邀请人获得的积分累计 + RegisterTime *time.Time `json:"registerTime" bson:"registerTime,omitempty"` // + UpdatedAt *time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` // 更新时间 + CurrentVipExpire *time.Time `json:"currentVipExpire" bson:"currentVipExpire,omitempty"` // 当前vip过期时间 + SysMsgAt *time.Time `json:"sysMsgAt" bson:"sysMsgAt,omitempty"` // 获取系统消息时间 + ShowCardId *int64 `json:"showCardId" bson:"showCardId,omitempty"` // 最新会员卡id + ShowCardName *string `json:"showCardName" bson:"showCardName,omitempty"` // 最新会员卡名称 + ShowCardExpiredAt *time.Time `json:"showCardExpiredAt" bson:"showCardExpiredAt,omitempty"` // 最新会员卡过期时间 + OriVer *string `json:"oriVer" bson:"oriVer,omitempty"` // 注册时的app版本 + AccountNo *string `json:"accountNo" bson:"accountNo,omitempty"` // 账号 + Email *string `json:"email" bson:"email,omitempty"` // 邮箱 + MerchantExpiredAt *time.Time `json:"merchantExpiredAt" bson:"merchantExpiredAt,omitempty"` // 商家/兼职有效期 +} diff --git a/pub/models/usermod/user.go b/pub/models/usermod/user.go new file mode 100644 index 0000000..50bb6df --- /dev/null +++ b/pub/models/usermod/user.go @@ -0,0 +1,350 @@ +package usermod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var db *mongos.Collection + +func Init(ctx context.Context) { + db = mongos.Shop.Coll(TN.User) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.User.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.AgentInviteCode, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.InviteCode, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.CreatedAt, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.DevID, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.Mac, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.DevV2, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.Recharge, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.DistrictCode, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.NickName, Value: "text"}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(User:initUserIndex) Create User Index failed! err:%+v:", err)) + } +} + +// InsertOneUser 插入一个用户 +func InsertOneUser(ctx context.Context, user *User) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, user); err != nil { + log.Warn("User:InsertOneUser db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneUser 查找一个用户 +func FindOneUser(ctx context.Context, filter bson.M) (*User, *srvdef.SrvErr) { + var userInfo User + if err := db.FindOneEx(ctx, &userInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("User:FindOneUser db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + return &userInfo, nil +} + +// DelOneUser 删除一个用户 +func DelOneUser(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("User:DelOneUser db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// UpdateOneUser 更新一个用户 +func UpdateOneUser(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("user:UpdateOneUser db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + return nil +} + +// CountUsers 统计用户数量 +func CountUsers(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("User:CountUsers db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateManyUser 更新满足条件的用户 +func UpdateManyUser(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("user:UpdateManyUser db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// FindManyUser 查找一批用户数据 +func FindManyUser(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (UserList, *srvdef.SrvErr) { + data := make(UserList, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("User:FindManyUser db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManyUserWithBind 用自定义结构查找一批用户数据 +func FindManyUserWithBind(ctx context.Context, bind interface{}, filter bson.M, opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("User:FindManyUserWithBind db.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// AggregateUsers 聚合查找一批用户信息 +func AggregateUsers(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline, opts...); err != nil { + log.Warn("User:AggregateUsers db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// ObjectAggregate 聚合查找 +func ObjectAggregate(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("User:ObjectAggregate db.ObjectAggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// UpdateRecharge 更新用户充值金额(正数为充值,负数为消费) +func UpdateRecharge(ctx context.Context, userId, recharge, rechargeTotal int64) (*User, *srvdef.SrvErr) { + userInfo := &User{} + incDoc := bson.M{ + FN.User.Recharge: recharge, + FN.User.RechargeTotal: rechargeTotal, + } + if recharge < 0 { + incDoc[FN.User.ConsumptionTotal] = -recharge + } + err := db.FindOneAndUpdate(ctx, &userInfo, bson.M{ + FN.User.Id: userId, + FN.User.Recharge: bson.M{"$gte": -recharge}, + }, bson.M{ + "$inc": incDoc, + "$set": bson.M{FN.User.UpdatedAt: time.Now()}, + }) + if err != nil { + log.Error("UpdateRecharge:FindOneAndUpdate failed", log.E(err)) + return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "") + } + if userInfo.Id <= 0 { + return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + return userInfo, nil +} + +// UpdateScore 更新用户积分(正数加分,负数扣分) +func UpdateScore(ctx context.Context, userId, score int64) (*User, *srvdef.SrvErr) { + userInfo := &User{} + incDoc := bson.M{FN.User.Score: score} + filter := bson.M{FN.User.Id: userId} + if score > 0 { + incDoc[FN.User.ScoreTotal] = score + } else { + filter[FN.User.Score] = bson.M{"$gte": -score} + } + err := db.FindOneAndUpdate(ctx, &userInfo, filter, bson.M{ + "$inc": incDoc, + "$set": bson.M{FN.User.UpdatedAt: time.Now()}, + }) + if err != nil { + log.Warn("usermod:UpdateScore FindOneAndUpdate failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "") + } + if userInfo.Id <= 0 { + return nil, srvdef.New(srvdef.DBUpdate, "为0", "") + } + return userInfo, nil +} + +type OnAddScore func(userId int64, add int64) + +// UpdateScoreWithEvent 更新用户积分并触发回调 +func UpdateScoreWithEvent(ctx context.Context, userId, score int64, onAdd OnAddScore) (*User, *srvdef.SrvErr) { + userInfo := &User{} + incDoc := bson.M{FN.User.Score: score} + filter := bson.M{FN.User.Id: userId} + if score > 0 { + incDoc[FN.User.ScoreTotal] = score + } + err := db.FindOneAndUpdate(ctx, &userInfo, filter, bson.M{ + "$inc": incDoc, + "$set": bson.M{FN.User.UpdatedAt: time.Now()}, + }) + if err != nil { + log.Warn("usermod:UpdateScoreWithEvent FindOneAndUpdate failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "") + } + if userInfo.Id <= 0 { + return nil, srvdef.New(srvdef.DBUpdate, "为0", "") + } + if score > 0 && onAdd != nil { + onAdd(userInfo.Id, score) + } + return userInfo, nil +} + +// UserPay 扣除用户充值金币 +func UserPay(ctx context.Context, userId int64, amount int64) (*User, *srvdef.SrvErr) { + if amount <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "扣除金额不能小于0", "扣除金额不能小于0") + } + userInfo := &User{} + err := db.FindOneAndUpdate(ctx, &userInfo, bson.M{ + FN.User.Id: userId, + FN.User.Recharge: bson.M{"$gte": amount}, + }, bson.M{ + "$inc": bson.M{ + FN.User.Recharge: -amount, + FN.User.ConsumptionTotal: amount, + }, + "$set": bson.M{FN.User.UpdatedAt: time.Now()}, + }) + if err != nil { + return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "") + } + if userInfo.Id <= 0 { + return nil, srvdef.New(srvdef.DBUpdate, "余额不足或用户不存在", "") + } + return userInfo, nil +} + +// FindOneUpdateAndReturnNew 更新用户数据并返回新的用户数据 +func FindOneUpdateAndReturnNew(ctx context.Context, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) (*User, *srvdef.SrvErr) { + userInfo := &User{} + err := db.FindOneAndUpdate(ctx, &userInfo, query, update) + if err != nil { + return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "") + } + if userInfo.Id <= 0 { + return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + return userInfo, nil +} + +// GetMapUsers 获取 map 结构的 users +func GetMapUsers(ctx context.Context, ids []int64) (UserMap, *srvdef.SrvErr) { + mapUsers := make(UserMap) + if len(ids) == 0 { + return mapUsers, nil + } + listUsers, serr := FindManyUser(ctx, bson.M{FN.User.Id: bson.M{"$in": ids}}) + if serr != nil { + return mapUsers, serr + } + for _, user := range listUsers { + mapUsers[user.Id] = user + } + return mapUsers, nil +} + +// FindManyUserByQuery 从 user 表中查找一批数据(自定义 bind) +func FindManyUserByQuery(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, query, opts...); err != nil { + log.Warn("user:FindManyUserByQuery db.Find failed", + log.E(err), log.Any("filter", query)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// InsertManyUser 插入一批用户 +func InsertManyUser(ctx context.Context, users *UserList) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, users); err != nil { + log.Warn("User:InsertManyUser db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// StatcenterSyncList 按创建时间增量拉取用户列表(数据统计同步用) +func StatcenterSyncList(ctx context.Context, createAt time.Time, size int64) (UserList, error) { + opt := (&options.FindOptions{}) + opt.SetLimit(size) + opt.SetSort(bson.M{FN.User.Id: 1}) + userList := UserList{} + err := db.Find(ctx, &userList, bson.M{FN.User.CreatedAt: bson.M{"$gt": createAt}}, opt) + if err != nil { + return nil, err + } + return userList, nil +} diff --git a/pub/models/usermod/user_gen.go b/pub/models/usermod/user_gen.go new file mode 100644 index 0000000..f10b001 --- /dev/null +++ b/pub/models/usermod/user_gen.go @@ -0,0 +1,324 @@ +// Code generated by gen mm. DO NOT EDIT. +package usermod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +/* + +var ( + collUser *mongos.Collection +) + +type getIndexeser interface { + GetIndexes() []mongo.IndexModel +} + +func Init(ctx context.Context) { + collUser = mongos.Shop.Coll(TN.User) + + + // create indexes for collection 'user's + if v, ok := interface{}(&User{}).(getIndexeser); ok { + if indexes := v.GetIndexes(); len(indexes) > 0 { + if _, err := collUser.CreateIndex(ctx, indexes); err != nil { + log.Fatal("collUser.CreateIndex failed", log.E(err)) + return + } + } + } +} +*/ +/* +// InsertOneUser 创建一个User +func InsertOneUser(ctx context.Context, data *User) *srvdef.SrvErr { + _, err := db.InsertOne(ctx, data) + if err != nil { + log.Error("usermod:InsertOneUser db.InsertOne failed", + log.Any("doc", data), + log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} +*/ + +// InsertManyUsers 创建多个User +func InsertManyUsers(ctx context.Context, data []*User, opts ...*options.InsertManyOptions) *srvdef.SrvErr { + _, err := db.InsertMany(ctx, &data) + if err != nil { + log.Error("usermod:InsertManyUsers db.InsertMany failed", + log.Any("doc", data), + log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +/* +// FindOneUser 查找一个User +func FindOneUser(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *User, *srvdef.SrvErr) { + var data *User + if err := db.FindOneEx(ctx, &data, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("usermod:FindOneUser db.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return data, nil +} +*/ +// FindOneUserById 根据Id查找一个User +func FindOneUserById(ctx context.Context, id int64, opts ...*options.FindOneOptions) ( + *User, *srvdef.SrvErr) { + filter := bson.M{ + FN.User.Id: id, + } + var data *User + if err := db.FindOneEx(ctx, &data, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("usermod:FindOneUser db.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return data, nil +} + +// FindOneUserWithBind 用自定义结构查找一个User +func FindOneUserWithBind(ctx context.Context, bind interface{}, filter bson.M, opts ...*options.FindOneOptions) *srvdef.SrvErr { + if err := db.FindOneEx(ctx, bind, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("usermod:FindOneUserWithBind db.FindOneEx failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// FindOneUserAndUpdate 更新查找一个User +func FindOneUserAndUpdate(ctx context.Context, filter bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) ( + *User, *srvdef.SrvErr) { + var data User + err := db.FindOneAndUpdate(ctx, &data, filter, update, opts...) + if err != nil { + log.Warn("usermod:FindOneUserAndUpdate db.FindOneAndUpdate failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return &data, nil +} + +/* + +// DelOneUser 删除一个User +func DelOneUser(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("usermod:DelOneUser db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +*/ + +// DelOneUserById 根据Id删除一个User +func DelOneUserById(ctx context.Context, id int64, opts ...*options.FindOneOptions) ( + int64, *srvdef.SrvErr) { + filter := bson.M{ + FN.User.Id: id, + } + return DelOneUser(ctx, filter) +} + +// DelManyUsers 删除多个User +func DelManyUsers(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("usermod:DelManyUsers} db.DeleteMany failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} /* +// UpdateOneUser 更新一个User +func UpdateOneUser(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("usermod:UpdateOneUser db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +}*/ +// MustUpdateOneUser 更新一个User +func MustUpdateOneUser(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("usermod:UpdateOneUser db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateOneUserWithResult 更新一个User +func UpdateOneUserWithResult(ctx context.Context, filter bson.M, update bson.M, opts ...*options.UpdateOptions) (*mongo.UpdateResult, *srvdef.SrvErr) { + result, err := db.UpdateOne(ctx, filter, update, opts...) + if err != nil { + log.Warn("usermod:UpdateOneUserWithResult db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return result, nil +} + +// UpdateManyUsers 更新多个User +func UpdateManyUsers(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("usermod:UpdateManyUsers db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount <= 0 { + log.Warn("usermod:UpdateManyUsers db.UpdateMany failed", + log.E(err), log.Any("filter", filter)) + } + return nil +} + +/* + +// CountUsers 统计User数量 +func CountUsers(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("usermod:CountUsers db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +*/ + +// FindManyUsers 查找一批User +func FindManyUsers(ctx context.Context, filter bson.M, + opts ...*options.FindOptions) ([]*User, *srvdef.SrvErr) { + data := make([]*User, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("usermod:FindManyUsers db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// FindManyUsers 查找一批User +func FindManyUsersAsInt64Map(ctx context.Context, filter bson.M, keyFn func(*User) int64, opts ...*options.FindOptions) (map[int64]*User, *srvdef.SrvErr) { + data := make([]*User, 0) + if err := db.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("usermod:FindManyUsers db.Find failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + result := make(map[int64]*User) + for _, item := range data { + key := keyFn(item) + if _, ok := result[key]; !ok { + result[key] = item + } + } + return result, nil +} + +// FindManyUsersWithBind 用自己定义结构查找一批User数据 +func FindManyUsersWithBind(ctx context.Context, bind interface{}, filter bson.M, + opts ...*options.FindOptions) *srvdef.SrvErr { + if err := db.Find(ctx, bind, filter, opts...); err != nil { + log.Warn("usermod:FindManyUsersWithBind db.Find failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +/* + +// AggregateUsers 聚合查找一批User +func AggregateUsers(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("usermod:AggregateUsers db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +*/ + +// ObjectAggregateUsers 聚合查找一批User数据 +func ObjectAggregateUsers(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil { + log.Warn("usermod:ObjectAggregateUsers db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +// BulkWriteUsers 批量写入或者更新User数据 +func BulkWriteUsers(ctx context.Context, + docs []mongo.WriteModel, opts ...*options.BulkWriteOptions) *srvdef.SrvErr { + _, err := db.BulkWrite(ctx, docs, opts...) + if err != nil { + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// UpsertOneUser UpsertOneUser +func UpsertOneUser(ctx context.Context, filter bson.M, update bson.M) (*mongo.UpdateResult, *srvdef.SrvErr) { + result, err := db.UpsertOne(ctx, filter, update) + if err != nil { + log.Warn("usermod:UpsertOneUser db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result, nil +} + +// DistinctUser 查找指定字段的不同值 +func DistinctUser(ctx context.Context, filter bson.M, fieldName string) ([]interface{}, *srvdef.SrvErr) { + result, err := db.Distince(ctx, filter, fieldName) + if err != nil { + log.Warn("usermod:DistinctUser db.Distince failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return result, nil +} diff --git a/pub/models/utils/dbhelper.go b/pub/models/utils/dbhelper.go new file mode 100644 index 0000000..d4b585b --- /dev/null +++ b/pub/models/utils/dbhelper.go @@ -0,0 +1 @@ +package utils diff --git a/pub/models/utils/dbop.go b/pub/models/utils/dbop.go new file mode 100644 index 0000000..05c75d3 --- /dev/null +++ b/pub/models/utils/dbop.go @@ -0,0 +1,11 @@ +package utils + +import ( + "context" +) + +// Init 初始化数据 +func Init(ctx context.Context) { + //initIndex(ctx) + return +} diff --git a/pub/models/utils/tag.go b/pub/models/utils/tag.go new file mode 100755 index 0000000..bee5413 --- /dev/null +++ b/pub/models/utils/tag.go @@ -0,0 +1,30 @@ +package utils + +type TagGenerateId struct { + BusinessId string + CreateAt string + Desc string + Id string + MaxId string + Step string + UpdateAt string +} + +var FN struct { + GenerateId TagGenerateId +} + +var TN struct { + GenerateId string +} + +func init() { + FN.GenerateId.BusinessId = "businessId" + FN.GenerateId.CreateAt = "createAt" + FN.GenerateId.Desc = "desc" + FN.GenerateId.Id = "_id" + FN.GenerateId.MaxId = "maxId" + FN.GenerateId.Step = "step" + FN.GenerateId.UpdateAt = "updateAt" + TN.GenerateId = "generate_id" +} diff --git a/pub/models/utils/type.go b/pub/models/utils/type.go new file mode 100644 index 0000000..394afbc --- /dev/null +++ b/pub/models/utils/type.go @@ -0,0 +1,18 @@ +package utils + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// @table:generate_id +type GenerateId struct { + Id primitive.ObjectID `bson:"_id"` + UpdateAt time.Time `bson:"updateAt"` + BusinessId string `bson:"businessId"` + MaxId int64 `bson:"maxId"` + Step int64 `bson:"step"` + Desc string `bson:"desc"` + CreateAt time.Time `bson:"createAt"` +} diff --git a/pub/models/videocategorymod/category.go b/pub/models/videocategorymod/category.go new file mode 100644 index 0000000..f3bd22c --- /dev/null +++ b/pub/models/videocategorymod/category.go @@ -0,0 +1,144 @@ +package videocategorymod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoCategory) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCategory.Status, Value: 1}, + }, + }, + { + Keys: bson.D{{Key: FN.VideoCategory.Name, Value: 1}}, + Options: options.Index(). + SetName("uniq_video_category_name"). + SetUnique(true). + SetPartialFilterExpression(bson.M{FN.VideoCategory.Name: bson.M{"$type": "string", "$gt": ""}}), + }, + { + Keys: bson.D{ + {Key: FN.VideoCategory.Status, Value: 1}, + {Key: FN.VideoCategory.Rank, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCategory.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoCategoryIndex) Create VideoCategory Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoCategory 插入一个渠道统计 +func InsertOneVideoCategory(ctx context.Context, qa *VideoCategory) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + if mongo.IsDuplicateKeyError(err) { + log.Warn("VideoCategorymod:InsertOneVideoCategory duplicate key", log.E(err)) + return srvdef.New(srvdef.ErrDbInputExist, "视频分类已存在") + } + log.Warn("VideoCategorymod:InsertOneVideoCategory db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoCategory 删除一个渠道统计 +func DelOneVideoCategory(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoCategorymod:DelOneVideoCategory db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoCategory 查找一个渠道统计 +func FindOneVideoCategory(ctx context.Context, filter bson.M) ( + *VideoCategory, *srvdef.SrvErr) { + var VideoCategory VideoCategory + if err := db.FindOneEx(ctx, &VideoCategory, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoCategorymod:FindOneVideoCategory db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoCategory, nil +} + +func FindManyVideoCategorys(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCategoryList, *srvdef.SrvErr) { + data := make(VideoCategoryList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoCategorys", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoCategorys 统计渠道统计数量 +func CountVideoCategorys(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoCategorys db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoCategory 更新一个渠道统计 +func UpdateOneVideoCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func UpdateManyVideoCategory(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoCategory db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/videocategorymod/tag.go b/pub/models/videocategorymod/tag.go new file mode 100755 index 0000000..36aa5c2 --- /dev/null +++ b/pub/models/videocategorymod/tag.go @@ -0,0 +1,54 @@ +package videocategorymod + +type TagVideoCategory struct { + CreatedAt string + Desc string + Id string + DisplayPosition string + IndexNow string + Name string + Slug string + Rank string + SortType string + Tags string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Status string + Title string + UpdatedAt string + TranslationCategoryId string + DisplayMode string +} + +var FN struct { + VideoCategory TagVideoCategory +} + +var TN struct { + VideoCategory string +} + +func init() { + FN.VideoCategory.CreatedAt = "createdAt" + FN.VideoCategory.Desc = "desc" + FN.VideoCategory.Id = "_id" + FN.VideoCategory.DisplayPosition = "displayPosition" + FN.VideoCategory.IndexNow = "indexNow" + FN.VideoCategory.Name = "name" + FN.VideoCategory.Slug = "slug" + FN.VideoCategory.Rank = "rank" + FN.VideoCategory.SortType = "sortType" + FN.VideoCategory.Tags = "tags" + FN.VideoCategory.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoCategory.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoCategory.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoCategory.Status = "status" + FN.VideoCategory.Title = "title" + FN.VideoCategory.UpdatedAt = "updatedAt" + FN.VideoCategory.TranslationCategoryId = "translationCategoryId" + FN.VideoCategory.DisplayMode = "displayMode" + TN.VideoCategory = "video_category" +} diff --git a/pub/models/videocategorymod/type.go b/pub/models/videocategorymod/type.go new file mode 100644 index 0000000..3ff2116 --- /dev/null +++ b/pub/models/videocategorymod/type.go @@ -0,0 +1,28 @@ +package videocategorymod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// VideoCategory 视频分类集合 +// @table:video_category +type VideoCategory struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + Slug string `json:"slug" bson:"slug"` // slug + Title string `json:"title" bson:"title"` // APP显示 + Desc string `json:"desc" bson:"desc"` // 描述 + Status bool `json:"status" bson:"status"` // 上架状态 + DisplayPosition pubdef.CategoryDisplayPosition `json:"displayPosition" bson:"displayPosition"` // 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + SortType pubdef.CategorySortType `json:"sortType" bson:"sortType"` // 排序方式 + Tags []int64 `json:"tags" bson:"tags"` // 关联视频标签 + Rank int64 `json:"rank" bson:"rank"` // 排序 + TranslationCategoryId int64 `json:"translationCategoryId" bson:"translationCategoryId"` // 导航分类 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + DisplayMode pubdef.DisplayMode `json:"displayMode" bson:"displayMode"` // 展示方式 0:默认 1:卡片层叠轮播 2:头像列表 3:图文列表 4:大封面列表 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoCategoryList []*VideoCategory diff --git a/pub/models/videocollectionmediamod/tag.go b/pub/models/videocollectionmediamod/tag.go new file mode 100755 index 0000000..4c3f916 --- /dev/null +++ b/pub/models/videocollectionmediamod/tag.go @@ -0,0 +1,30 @@ +package videocollectionmediamod + +type TagVideoCollectionMediaModel struct { + ID string + CollectionID string + VideoId string + Status string + Rank string + CreatedAt string + UpdatedAt string +} + +var FN struct { + VideoCollectionMediaModel TagVideoCollectionMediaModel +} + +var TN struct { + VideoCollectionMediaModel string +} + +func init() { + FN.VideoCollectionMediaModel.ID = "_id" + FN.VideoCollectionMediaModel.CollectionID = "collectionId" + FN.VideoCollectionMediaModel.VideoId = "videoId" + FN.VideoCollectionMediaModel.Status = "status" + FN.VideoCollectionMediaModel.Rank = "rank" + FN.VideoCollectionMediaModel.CreatedAt = "createdAt" + FN.VideoCollectionMediaModel.UpdatedAt = "updatedAt" + TN.VideoCollectionMediaModel = "video_collection_media" +} \ No newline at end of file diff --git a/pub/models/videocollectionmediamod/type.go b/pub/models/videocollectionmediamod/type.go new file mode 100644 index 0000000..f68661e --- /dev/null +++ b/pub/models/videocollectionmediamod/type.go @@ -0,0 +1,22 @@ +package videocollectionmediamod + +import ( + "time" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// VideoCollectionMediaModel 视频合集资源 +// @table:video_collection_media +type VideoCollectionMediaModel struct { + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` // 唯一ID + CollectionID int64 `json:"collectionId" bson:"collectionId"` // 合集ID + VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID + Status pubdef.InformationStatus `json:"status" bson:"status"` // 状态 + Rank int64 `json:"rank" bson:"rank"` // 排序 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 刷新时间 + +} +type VideoCollectionMediaModelList []*VideoCollectionMediaModel diff --git a/pub/models/videocollectionmediamod/videocollection.go b/pub/models/videocollectionmediamod/videocollection.go new file mode 100644 index 0000000..dcbfcb3 --- /dev/null +++ b/pub/models/videocollectionmediamod/videocollection.go @@ -0,0 +1,154 @@ +package videocollectionmediamod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoCollectionMediaModel) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CollectionID, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CollectionID, Value: 1}, + {Key: FN.VideoCollectionMediaModel.Rank, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CollectionID, Value: 1}, + {Key: FN.VideoCollectionMediaModel.VideoId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.VideoId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollectionMediaModel.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoCollectionMediaModelIndex) Create VideoCollectionMediaModel Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoCollectionMediaModel 插入一个渠道统计 +func InsertOneVideoCollectionMediaModel(ctx context.Context, qa *VideoCollectionMediaModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoCollectionMediaModelmod:InsertOneVideoCollectionMediaModel db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoCollectionMediaModel 删除一个渠道统计 +func DelOneVideoCollectionMediaModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoCollectionMediaModelmod:DelOneVideoCollectionMediaModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} +func DelManyVideoCollectionMediaModel(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := db.DeleteMany(ctx, filter) + if err != nil { + log.Warn("VideoCollectionMediaModelmod:DelManyVideoCollectionMediaModel db.DeleteMany failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoCollectionMediaModel 查找一个渠道统计 +func FindOneVideoCollectionMediaModel(ctx context.Context, filter bson.M) ( + *VideoCollectionMediaModel, *srvdef.SrvErr) { + var VideoCollectionMediaModel VideoCollectionMediaModel + if err := db.FindOneEx(ctx, &VideoCollectionMediaModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoCollectionMediaModelmod:FindOneVideoCollectionMediaModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoCollectionMediaModel, nil +} + +func FindManyVideoCollectionMediaModels(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCollectionMediaModelList, *srvdef.SrvErr) { + data := make(VideoCollectionMediaModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoCollectionMediaModels", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoCollectionMediaModels 统计渠道统计数量 +func CountVideoCollectionMediaModels(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoCollectionMediaModels db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoCollectionMediaModel 更新一个渠道统计 +func UpdateOneVideoCollectionMediaModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoCollectionMediaModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoCollectionMediaModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func InsertManyVideoCollectionMedia(ctx context.Context, list []*VideoCollectionMediaModel) *srvdef.SrvErr { + _, err := db.InsertMany(ctx, &list) + if err != nil { + log.Warn("post:InsertManyVideoCollectionMediaModel db.InsertMany failed", + log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} \ No newline at end of file diff --git a/pub/models/videocreatermod/creater.go b/pub/models/videocreatermod/creater.go new file mode 100644 index 0000000..957b472 --- /dev/null +++ b/pub/models/videocreatermod/creater.go @@ -0,0 +1,126 @@ +package videocreatermod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoCreaterInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCreaterInfo.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCreaterInfo.CreatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoCreaterInfoIndex) Create VideoCreaterInfo Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoCreaterInfo 插入一个渠道统计 +func InsertOneVideoCreaterInfo(ctx context.Context, qa *VideoCreaterInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + log.Warn("VideoCreaterInfomod:InsertOneVideoCreaterInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoCreaterInfo 删除一个渠道统计 +func DelOneVideoCreaterInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoCreaterInfomod:DelOneVideoCreaterInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoCreaterInfo 查找一个渠道统计 +func FindOneVideoCreaterInfo(ctx context.Context, filter bson.M) ( + *VideoCreaterInfo, *srvdef.SrvErr) { + var VideoCreaterInfo VideoCreaterInfo + if err := db.FindOneEx(ctx, &VideoCreaterInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoCreaterInfomod:FindOneVideoCreaterInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoCreaterInfo, nil +} + +func FindManyVideoCreaterInfos(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCreaterInfoList, *srvdef.SrvErr) { + data := make(VideoCreaterInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoCreaterInfos", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoCreaterInfos 统计渠道统计数量 +func CountVideoCreaterInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoCreaterInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoCreaterInfo 更新一个渠道统计 +func UpdateOneVideoCreaterInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoCreaterInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoCreaterInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} +func UpdateManyVideoCreaterInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoCreaterInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/videocreatermod/tag.go b/pub/models/videocreatermod/tag.go new file mode 100755 index 0000000..6b59b81 --- /dev/null +++ b/pub/models/videocreatermod/tag.go @@ -0,0 +1,52 @@ +package videocreatermod + +type TagVideoCreaterInfo struct { + CreatedAt string + Id string + IndexNow string + Name string + Slug string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Sort string + Status string + UpdatedAt string + Works string + Image string + CreateType string + Desc string + Background string + MediaTypes string +} + +var FN struct { + VideoCreaterInfo TagVideoCreaterInfo +} + +var TN struct { + VideoCreaterInfo string +} + +func init() { + FN.VideoCreaterInfo.CreatedAt = "createdAt" + FN.VideoCreaterInfo.Id = "_id" + FN.VideoCreaterInfo.IndexNow = "indexNow" + FN.VideoCreaterInfo.Name = "name" + FN.VideoCreaterInfo.Slug = "slug" + FN.VideoCreaterInfo.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoCreaterInfo.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoCreaterInfo.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoCreaterInfo.Sort = "sort" + FN.VideoCreaterInfo.Status = "status" + FN.VideoCreaterInfo.UpdatedAt = "updatedAt" + FN.VideoCreaterInfo.Works = "works" + FN.VideoCreaterInfo.Image = "image" + FN.VideoCreaterInfo.Background = "background" + FN.VideoCreaterInfo.CreateType = "createType" + FN.VideoCreaterInfo.Desc = "desc" + FN.VideoCreaterInfo.MediaTypes = "mediaTypes" + TN.VideoCreaterInfo = "video_creater" +} diff --git a/pub/models/videocreatermod/type.go b/pub/models/videocreatermod/type.go new file mode 100644 index 0000000..d588bec --- /dev/null +++ b/pub/models/videocreatermod/type.go @@ -0,0 +1,26 @@ +package videocreatermod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @table:video_creater +type VideoCreaterInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Name string `json:"name" bson:"name"` // 帖子标题 + Slug string `json:"slug" bson:"slug"` // slug + Status bool `json:"status" bson:"status"` // 上架状态 + Sort int64 `json:"sort" bson:"sort"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Works int64 `json:"works" bson:"works"` // 作品数量 + Image string `json:"image" bson:"image"` // 头像 + CreateType int64 `json:"createType" bson:"createType"` // 创作者类型 0=普通 1=官方 + MediaTypes []int64 `json:"mediaTypes" bson:"mediaTypes"` // 作者关联的资源类型 1视频 + Background string `json:"background" bson:"background"` // 背景 + Desc string `json:"desc" bson:"desc"` // 简介 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoCreaterInfoList []*VideoCreaterInfo diff --git a/pub/models/videofavoritemod/favorite.go b/pub/models/videofavoritemod/favorite.go new file mode 100644 index 0000000..d1ab1c1 --- /dev/null +++ b/pub/models/videofavoritemod/favorite.go @@ -0,0 +1,92 @@ +package videofavoritemod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 video_favorite 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.VideoFavorite) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: FN.VideoFavorite.UserID, Value: 1}, {Key: FN.VideoFavorite.VideoID, Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: FN.VideoFavorite.UserID, Value: 1}, {Key: FN.VideoFavorite.CreatedAt, Value: -1}, {Key: FN.VideoFavorite.Id, Value: -1}}}, + {Keys: bson.D{{Key: FN.VideoFavorite.VideoID, Value: 1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(videofavoritemod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// UpsertOneVideoFavorite 按用户ID和视频ID写入收藏关系,返回是否新增收藏 +func UpsertOneVideoFavorite(ctx context.Context, userId, videoId int64, now time.Time) (bool, *srvdef.SrvErr) { + filter := bson.M{FN.VideoFavorite.UserID: userId, FN.VideoFavorite.VideoID: videoId} + update := bson.M{ + "$set": bson.M{FN.VideoFavorite.UpdatedAt: now}, + "$setOnInsert": bson.M{ + FN.VideoFavorite.Id: mongos.Lid.VideoFavorite.Get(), + FN.VideoFavorite.UserID: userId, + FN.VideoFavorite.VideoID: videoId, + FN.VideoFavorite.CreatedAt: now, + }, + } + result, err := coll.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) + if err != nil { + log.Warn("videofavoritemod:UpsertOneVideoFavorite db.UpdateOne failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.UpsertedCount > 0, nil +} + +// DeleteOneVideoFavorite 删除一条视频收藏关系,返回是否实际删除 +func DeleteOneVideoFavorite(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + result, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("videofavoritemod:DeleteOneVideoFavorite db.DeleteOne failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return result > 0, nil +} + +// FindManyVideoFavorite 分页查询视频收藏关系 +func FindManyVideoFavorite(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoFavoriteList, *srvdef.SrvErr) { + data := make(VideoFavoriteList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("videofavoritemod:FindManyVideoFavorite db.Find failed", log.Any("filter", filter), log.E(err)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountVideoFavorite 统计视频收藏关系数量 +func CountVideoFavorite(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("videofavoritemod:CountVideoFavorite db.Count failed", log.Any("filter", filter), log.E(err)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// AggregateVideoFavorite 聚合视频收藏数据 +func AggregateVideoFavorite(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("videofavoritemod:AggregateVideoFavorite db.Aggregate failed", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/videofavoritemod/tag.go b/pub/models/videofavoritemod/tag.go new file mode 100644 index 0000000..8a5f7b6 --- /dev/null +++ b/pub/models/videofavoritemod/tag.go @@ -0,0 +1,28 @@ +package videofavoritemod + +// TagVideoFavorite 保存 video_favorite 集合字段名 +type TagVideoFavorite struct { + Id string + UserID string + VideoID string + CreatedAt string + UpdatedAt string +} + +var FN struct { + VideoFavorite TagVideoFavorite +} + +var TN struct { + VideoFavorite string +} + +func init() { + FN.VideoFavorite.Id = "_id" + FN.VideoFavorite.UserID = "userId" + FN.VideoFavorite.VideoID = "videoId" + FN.VideoFavorite.CreatedAt = "createdAt" + FN.VideoFavorite.UpdatedAt = "updatedAt" + + TN.VideoFavorite = "video_favorite" +} diff --git a/pub/models/videofavoritemod/type.go b/pub/models/videofavoritemod/type.go new file mode 100644 index 0000000..91ae91d --- /dev/null +++ b/pub/models/videofavoritemod/type.go @@ -0,0 +1,15 @@ +package videofavoritemod + +import "time" + +// VideoFavorite 视频收藏关系 +type VideoFavorite struct { + Id int64 `json:"id" bson:"_id,omitempty"` + UserID int64 `json:"userId" bson:"userId"` + VideoID int64 `json:"videoId" bson:"videoId"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` +} + +// VideoFavoriteList 视频收藏列表 +type VideoFavoriteList []*VideoFavorite diff --git a/pub/models/videoinformationmod/category_rank.go b/pub/models/videoinformationmod/category_rank.go new file mode 100644 index 0000000..88aa07c --- /dev/null +++ b/pub/models/videoinformationmod/category_rank.go @@ -0,0 +1,53 @@ +package videoinformationmod + +import ( + "context" + "videoweb/pub/common/srvdef" + + "go.mongodb.org/mongo-driver/bson" +) + +const CategoryRankField = "_categoryRank" + +// VideoCategoryRank 视频在指定分类下的排序 +type VideoCategoryRank struct { + Id int64 `json:"id" bson:"id"` // 分类ID + Rank int64 `json:"rank" bson:"rank"` // 排序值,越大越靠前 +} + +// CategoryRankAddFields 提取当前分类的排序值,未配置时为0 +func CategoryRankAddFields(categoryId int64) bson.M { + return bson.M{CategoryRankField: bson.M{"$let": bson.M{ + "vars": bson.M{"hit": bson.M{"$first": bson.M{"$filter": bson.M{ + "input": bson.M{"$ifNull": bson.A{"$categoryRanks", bson.A{}}}, + "as": "b", + "cond": bson.M{"$eq": bson.A{"$$b.id", categoryId}}, + }}}}, + "in": bson.M{"$ifNull": bson.A{"$$hit.rank", 0}}, + }}} +} + +// CategoryRankSort 按当前分类排序后使用上架时间和ID稳定分页 +func CategoryRankSort() bson.D { + return bson.D{{Key: CategoryRankField, Value: -1}, {Key: FN.VideoInformation.AddedTime, Value: -1}, {Key: FN.VideoInformation.Id, Value: -1}} +} + +// FindManyVideoByCategoryRank 按指定分类排序分页查询视频 +func FindManyVideoByCategoryRank(ctx context.Context, filter bson.M, categoryId, skip, limit int64, projection bson.M) (VideoInformationList, *srvdef.SrvErr) { + pipeline := []bson.M{ + {"$match": filter}, + {"$addFields": CategoryRankAddFields(categoryId)}, + {"$sort": CategoryRankSort()}, + {"$skip": skip}, + {"$limit": limit}, + {"$unset": CategoryRankField}, + } + if len(projection) > 0 { + pipeline = append(pipeline, bson.M{"$project": projection}) + } + list := make(VideoInformationList, 0) + if err := AggregateVideoInformations(ctx, &list, pipeline); err != nil { + return nil, err + } + return list, nil +} diff --git a/pub/models/videoinformationmod/information.go b/pub/models/videoinformationmod/information.go new file mode 100644 index 0000000..24bfb45 --- /dev/null +++ b/pub/models/videoinformationmod/information.go @@ -0,0 +1,448 @@ +package videoinformationmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoInformation) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Tags, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.IndexNow, Value: 1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.UpdatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Video.FileId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "nodes.nodeId", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Id, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.HeatValue, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.IsRecommend, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.Real.WeekWatchs, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CategoryId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.IsRecommend, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.Real.WeekWatchs, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CategoryId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CategoryId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: FN.VideoInformation.Fake.WatchTimes, Value: -1}, + {Key: FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.MediaType, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.MediaType, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + + { + Keys: bson.D{ + {Key: FN.VideoInformation.Tags, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.IsRecommend, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.Real.WeekWatchs, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Tags, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Tags, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.AddedTime, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.MonthWatchs, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.IsTop, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.VideoCount, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.Likes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.ActressIds, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.Likes, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CategoryId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.Likes, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: FN.VideoInformation.Fake.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.Likes, Value: -1}, + {Key: FN.VideoInformation.Fake.Likes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.Real.WeekWatchs, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: 1}, + {Key: FN.VideoInformation.Fake.WatchTimes, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.Likes, Value: 1}, + {Key: FN.VideoInformation.Fake.Likes, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.HevcStatus, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: FN.VideoInformation.Fake.WatchTimes, Value: -1}, + {Key: FN.VideoInformation.UpdatedAt, Value: -1}, + {Key: FN.VideoInformation.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.HevcStatus, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.UpdatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoInformationIndex) Create VideoInformation Index failed! err:%+v:", err)) + } +} + +// InsertOneVideoInformation 插入一个渠道统计 +func InsertOneVideoInformation(ctx context.Context, qa *VideoInformation) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoInformationmod:InsertOneVideoInformation db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} +func InsertManyVideoInformation(ctx context.Context, qa []*VideoInformation) *srvdef.SrvErr { + if _, err := db.InsertMany(ctx, &qa); err != nil { + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoInformation 删除一个渠道统计 +func DelOneVideoInformation(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoInformationmod:DelOneVideoInformation db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoInformation 查找一个渠道统计 +func FindOneVideoInformation(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) ( + *VideoInformation, *srvdef.SrvErr) { + var VideoInformation VideoInformation + if err := db.FindOneEx(ctx, &VideoInformation, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoInformationmod:FindOneVideoInformation db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoInformation, nil +} + +func FindManyVideoInformations(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoInformationList, *srvdef.SrvErr) { + data := make(VideoInformationList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoInformations", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoInformations 统计渠道统计数量 +func CountVideoInformations(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoInformations db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoInformation 更新一个渠道统计 +func UpdateOneVideoInformation(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoInformation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoInformation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func UpdateManyVideoInformation(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoInformation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func BulkWriteVideoInformation(ctx context.Context, docs []mongo.WriteModel, + opts ...*options.BulkWriteOptions) (*mongo.BulkWriteResult, *srvdef.SrvErr) { + result, err := db.BulkWrite(ctx, docs, opts...) + if err != nil { + log.Warn("post:BulkWriteVideoInformation db.BulkWrite failed", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result, nil +} + +// AggregateVideoInformations 聚合查询视频信息 +func AggregateVideoInformations(ctx context.Context, + bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("VideoInformation:AggregateVideoInformations db.Aggregate failed", + log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/videoinformationmod/tag.go b/pub/models/videoinformationmod/tag.go new file mode 100755 index 0000000..a00562a --- /dev/null +++ b/pub/models/videoinformationmod/tag.go @@ -0,0 +1,156 @@ +package videoinformationmod + +type TagVideoInformation struct { + ActressIds string + AddedTime string + CategoryId string + CategoryRanks string + CoverImg string + CreatedAt string + CreatorId string + CreatorName string + Desc string + FirstTag string + Id string + IndexNow string + IsHot string + IsTop string + IssuedAt string + Keyworld string + MediaType string + Nodes string + Fake struct { + Likes string + WatchTimes string + } + Real struct { + Comments string + Likes string + MonthWatchs string + ShowTimes string + TodayWatchs string + WatchTimes string + WeekWatchs string + YearWatchs string + WeekLikes string + MonthLikes string + YearLikes string + } + Remark string + SecondTags string + Sort string + HeatValue string + Status string + Tags string + Title string + Slug string + IsRecommend string + UpdatedAt string + PageUpdatedAt string + Video struct { + Cover string + FileId string + Height string + H265Url string + PreMp4FileNameFs string + TaskId string + Url string + VideoTime string + Width string + UnencryptedPreMp4 string + } + VideoCount string + IssuedBrand string + Bango string + RealUpdateAt string + UploadUserName string + OptionAccount string + UnencryptedImage string + SecondUpdatedAt string + SyncId string + HevcStatus string + HevcFailCount string + PreviewVtt string + AudioFile string + SmartCover string + HevcMetaInfo string +} + +var FN struct { + VideoInformation TagVideoInformation +} + +var TN struct { + VideoInformation string +} + +func init() { + FN.VideoInformation.ActressIds = "actressIds" + FN.VideoInformation.AddedTime = "addedTime" + FN.VideoInformation.CategoryId = "categoryId" + FN.VideoInformation.CategoryRanks = "categoryRanks" + FN.VideoInformation.CoverImg = "coverImg" + FN.VideoInformation.CreatedAt = "createdAt" + FN.VideoInformation.CreatorId = "creatorId" + FN.VideoInformation.CreatorName = "creatorName" + FN.VideoInformation.Desc = "desc" + FN.VideoInformation.FirstTag = "firstTag" + FN.VideoInformation.Id = "_id" + FN.VideoInformation.IndexNow = "indexNow" + FN.VideoInformation.IsHot = "isHot" + FN.VideoInformation.IsTop = "isTop" + FN.VideoInformation.IssuedAt = "issuedAt" + FN.VideoInformation.Keyworld = "keyworld" + FN.VideoInformation.MediaType = "mediaType" + FN.VideoInformation.Nodes = "nodes" + FN.VideoInformation.Fake.Likes = "fake.likes" + FN.VideoInformation.Fake.WatchTimes = "fake.watchTimes" + FN.VideoInformation.Real.Comments = "real.comments" + FN.VideoInformation.Real.Likes = "real.likes" + FN.VideoInformation.Real.MonthWatchs = "real.monthWatchs" + FN.VideoInformation.Real.ShowTimes = "real.showTimes" + FN.VideoInformation.Real.TodayWatchs = "real.todayWatchs" + FN.VideoInformation.Real.WatchTimes = "real.watchTimes" + FN.VideoInformation.Real.WeekWatchs = "real.weekWatchs" + FN.VideoInformation.Real.YearWatchs = "real.yearWatchs" + FN.VideoInformation.Real.WeekLikes = "real.weekLikes" + FN.VideoInformation.Real.MonthLikes = "real.monthLikes" + FN.VideoInformation.Real.YearLikes = "real.yearLikes" + FN.VideoInformation.Remark = "remark" + FN.VideoInformation.SecondTags = "secondTags" + FN.VideoInformation.Sort = "sort" + FN.VideoInformation.HeatValue = "heatValue" + FN.VideoInformation.Status = "status" + FN.VideoInformation.Tags = "tags" + FN.VideoInformation.Title = "title" + FN.VideoInformation.Slug = "slug" + FN.VideoInformation.IsRecommend = "isRecommend" + FN.VideoInformation.UpdatedAt = "updatedAt" + FN.VideoInformation.PageUpdatedAt = "pageUpdatedAt" + FN.VideoInformation.Video.Cover = "video.cover" + FN.VideoInformation.Video.FileId = "video.fileId" + FN.VideoInformation.Video.Height = "video.height" + FN.VideoInformation.Video.H265Url = "video.h265Url" + FN.VideoInformation.Video.PreMp4FileNameFs = "video.preMp4FileNameFs" + FN.VideoInformation.Video.TaskId = "video.taskId" + FN.VideoInformation.Video.Url = "video.url" + FN.VideoInformation.Video.VideoTime = "video.videoTime" + FN.VideoInformation.Video.Width = "video.width" + FN.VideoInformation.Video.UnencryptedPreMp4 = "video.unencryptedPreMp4" + FN.VideoInformation.VideoCount = "videoCount" + FN.VideoInformation.IssuedBrand = "issuedBrand" + FN.VideoInformation.RealUpdateAt = "realUpdateAt" + FN.VideoInformation.Bango = "bango" + FN.VideoInformation.UploadUserName = "uploadUserName" + FN.VideoInformation.OptionAccount = "optionAccount" + FN.VideoInformation.UnencryptedImage = "unencryptedImage" + FN.VideoInformation.SyncId = "syncId" + FN.VideoInformation.SecondUpdatedAt = "secondUpdatedAt" + FN.VideoInformation.HevcStatus = "hevcStatus" + FN.VideoInformation.HevcFailCount = "hevcFailCount" + FN.VideoInformation.PreviewVtt = "previewVtt" + FN.VideoInformation.AudioFile = "audioFile" + FN.VideoInformation.SmartCover = "smartCover" + FN.VideoInformation.HevcMetaInfo = "hevcMetaInfo" + TN.VideoInformation = "video_information" +} diff --git a/pub/models/videoinformationmod/type.go b/pub/models/videoinformationmod/type.go new file mode 100644 index 0000000..0075f53 --- /dev/null +++ b/pub/models/videoinformationmod/type.go @@ -0,0 +1,172 @@ +package videoinformationmod + +import ( + "time" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +// VideoInformation 视频信息集合 +// @table:video_information +type VideoInformation struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 标题 + Slug string `json:"slug" bson:"slug"` // slug + Bango string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 + MediaType MediaType `json:"mediaType" bson:"mediaType"` // 资源类型 + AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 上架时间 + ActressIds []int64 `json:"actressIds" bson:"actressIds"` // 女优ID + CreatorId int64 `json:"creatorId" bson:"creatorId"` // 作者Id + CreatorName string `json:"creatorName" bson:"creatorName"` // 作者名称 + Keyworld []string `json:"keyworld,omitempty" bson:"keyworld"` // 关键字 + CategoryId int64 `json:"categoryId,omitempty" bson:"categoryId"` // 分类ID + CategoryRanks []VideoCategoryRank `json:"categoryRanks" bson:"categoryRanks"` // 各分类下的排序 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Nodes []Node `json:"nodes" bson:"nodes"` // 节点 + Status pubdef.InformationStatus `json:"status" bson:"status"` // 帖子上架状态 + Video VideoInfo `json:"video" bson:"video"` // 视频 + Tags []int64 `json:"tags" bson:"tags"` // 标签 + IsHot bool `json:"isHot" bson:"isHot"` // 是否热门 + IsRecommend bool `json:"isRecommend" bson:"isRecommend"` // 是否推荐 true-推荐 false-不推荐 + Sort int64 `json:"sort" bson:"sort"` // 排序 + HeatValue int64 `json:"heatValue" bson:"heatValue"` // 后台热度值 + Real pubdef.Flow `json:"real" bson:"real"` // 真实流量 + Fake pubdef.FakeFlow `json:"fake" bson:"fake"` // 伪造流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + IssuedAt time.Time `json:"issuedAt" bson:"issuedAt"` // 发行时间 + IssuedBrand string `json:"issuedBrand" bson:"issuedBrand"` // 发行商 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + PageUpdatedAt time.Time `json:"pageUpdatedAt" bson:"pageUpdatedAt"` // 页面展示更新时间 + RealUpdateAt time.Time `json:"realUpdateAt" bson:"realUpdateAt"` // 播放相关数据更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + IsTop bool `json:"isTop" bson:"isTop"` // 是否置顶 + UploadUserName string `bson:"uploadUserName" json:"uploadUserName,omitempty"` // 上传用户名 + OptionAccount string `bson:"optionAccount,omitempty" json:"optionAccount"` // 最后操作人 + SyncId string `json:"syncId" bson:"syncId"` // 同步ID + SecondUpdatedAt time.Time `json:"secondUpdatedAt" bson:"secondUpdatedAt"` // 二次修改时间 + HevcStatus HevcStatus `json:"hevcStatus" bson:"hevcStatus"` // 主视频 H265 转码状态 + HevcFailCount int `json:"hevcFailCount" bson:"hevcFailCount"` // 主视频转码累计失败次数 + PreviewVtt string `json:"previewVtt" bson:"previewVtt,omitempty"` // 雪碧图 vtt + AudioFile string `json:"audioFile" bson:"audioFile,omitempty"` // 音频文件路径 + SmartCover string `json:"smartCover" bson:"smartCover,omitempty"` // 智能封面 + HevcMetaInfo *VideoMetaInfo `json:"hevcMetaInfo" bson:"hevcMetaInfo,omitempty"` // H265 视频元信息 +} +type VideoInformationList []*VideoInformation + +// HevcStatus H265 转码状态(主视频 video.url)。 +type HevcStatus int + +const ( + HevcStatusNone HevcStatus = 0 // 未提交转码 + HevcStatusPending HevcStatus = 1 // 转码中 + HevcStatusSuccess HevcStatus = 2 // 转码成功 + HevcStatusFailed HevcStatus = -1 // 转码失败 +) + +// VideoMetaInfo H265 转码视频元信息(对应老司机 hevc_meta_info)。 +type VideoMetaInfo struct { + M3u8URL string `json:"m3u8Url" bson:"m3u8Url,omitempty"` + Codec string `json:"codec" bson:"codec,omitempty"` + Profile string `json:"profile" bson:"profile,omitempty"` + Width int `json:"width" bson:"width,omitempty"` + Height int `json:"height" bson:"height,omitempty"` + Fps float64 `json:"fps" bson:"fps,omitempty"` + VideoBitrate int `json:"videoBitrate" bson:"videoBitrate,omitempty"` + PixFmt string `json:"pixFmt" bson:"pixFmt,omitempty"` + AudioCodec string `json:"audioCodec" bson:"audioCodec,omitempty"` + AudioBitrate int `json:"audioBitrate" bson:"audioBitrate,omitempty"` + AudioSampleRate int `json:"audioSampleRate" bson:"audioSampleRate,omitempty"` + AudioChannels int `json:"audioChannels" bson:"audioChannels,omitempty"` + Bitrate int `json:"bitrate" bson:"bitrate,omitempty"` + Size int64 `json:"size" bson:"size,omitempty"` + Duration float64 `json:"duration" bson:"duration,omitempty"` +} + +type Node struct { + NodeId string `json:"nodeId" bson:"nodeId"` // 节点ID + Title string `bson:"title" json:"title"` // 节点标题 + Videos []VideoInfo `json:"videos" bson:"videos"` // 视频信息列表 + Images []string `json:"images" bson:"images"` // 图片 + Url string `json:"url" bson:"url"` // 链接 + Text string `json:"text" bson:"text"` // 文本 +} + +type VideoInfo struct { + TaskId string `json:"taskId" bson:"taskId"` // 视频ID(视频才会存在) + FileId string `json:"fileId" bson:"fileId"` // 媒资库ID + Url string `json:"url" bson:"url"` // H.264 m3u8 播放地址(始终存在) + H265Url string `json:"h265Url" bson:"h265Url"` // H.265 m3u8 播放地址,空字符串表示暂无该编码源 + Cover string `json:"cover" bson:"cover"` // 视频封面 + Height int64 `json:"height" bson:"height"` // 分辨率高(视频才会存在) + Width int64 `json:"width" bson:"width"` // 分辨率宽(视频才会存在) + VideoTime int32 `bson:"videoTime" json:"videoTime"` // 影片长度 + PreMp4FileNameFs string `bson:"preMp4FileNameFs,omitempty" json:"preMp4FileNameFs"` // 预览MP4文件 + UnencryptedPreMp4 string `bson:"unencryptedPreMp4,omitempty" json:"unencryptedPreMp4"` // 不加密的预览MP4 +} + +type VideoSortItem struct { + Key VideoSortType `json:"key" form:"key"` // 传递值 + Value string `json:"value" form:"value"` // 显示名字 +} + +type MediaType int64 + +const ( + MediaType_Video MediaType = 0 // 视频 + MediaType_Collection MediaType = 1 // 合集 + MediaType_Advitise MediaType = 2 // 广告 + MediaType_AV MediaType = 3 // AV视频 +) + +// VideoSortType 排序条件 +type VideoSortType int64 + +const ( + VideoSortType_Recommend VideoSortType = 1 // 最近最佳 + VideoSortType_Hot VideoSortType = 2 // 最多观看 + VideoSortType_New VideoSortType = 3 // 最新更新 +) + +var VideoSortTypeName = map[VideoSortType]string{ + VideoSortType_Recommend: "最近最佳", + VideoSortType_New: "最新更新", + VideoSortType_Hot: "最多观看", +} +var VideoSortItems = []VideoSortItem{ + {Key: VideoSortType_Recommend, Value: "最近最佳"}, + {Key: VideoSortType_New, Value: "最新更新"}, + {Key: VideoSortType_Hot, Value: "最多观看"}, +} + +func (s VideoSortType) GetSortBson() bson.D { + switch s { + case VideoSortType_Recommend: + return bson.D{{FN.VideoInformation.Sort, -1}, {FN.VideoInformation.Real.WeekWatchs, -1}} + case VideoSortType_Hot: + return bson.D{{FN.VideoInformation.Real.WatchTimes, -1}} + case VideoSortType_New: + return bson.D{{FN.VideoInformation.AddedTime, -1}} + default: + return bson.D{{FN.VideoInformation.AddedTime, -1}} + } +} + +func (s VideoSortType) GetSortItem() VideoSortItem { + switch s { + case VideoSortType_Recommend: + return VideoSortItems[0] + case VideoSortType_Hot: + return VideoSortItems[2] + case VideoSortType_New: + return VideoSortItems[1] + default: + return VideoSortItems[0] + } +} diff --git a/pub/models/videolikemod/tag.go b/pub/models/videolikemod/tag.go new file mode 100644 index 0000000..232902d --- /dev/null +++ b/pub/models/videolikemod/tag.go @@ -0,0 +1,28 @@ +package videolikemod + +// TagVideoLike 保存 video_like 集合字段名 +type TagVideoLike struct { + Id string + UserID string + VideoID string + CreatedAt string + UpdatedAt string +} + +var FN struct { + VideoLike TagVideoLike +} + +var TN struct { + VideoLike string +} + +func init() { + FN.VideoLike.Id = "_id" + FN.VideoLike.UserID = "userId" + FN.VideoLike.VideoID = "videoId" + FN.VideoLike.CreatedAt = "createdAt" + FN.VideoLike.UpdatedAt = "updatedAt" + + TN.VideoLike = "video_like" +} diff --git a/pub/models/videolikemod/type.go b/pub/models/videolikemod/type.go new file mode 100644 index 0000000..bcd2b66 --- /dev/null +++ b/pub/models/videolikemod/type.go @@ -0,0 +1,15 @@ +package videolikemod + +import "time" + +// VideoLike 视频点赞关系 +type VideoLike struct { + Id int64 `json:"id" bson:"_id,omitempty"` + UserID int64 `json:"userId" bson:"userId"` // 用户ID + VideoID int64 `json:"videoId" bson:"videoId"` // 视频ID + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` +} + +// VideoLikeList 视频点赞列表 +type VideoLikeList []*VideoLike diff --git a/pub/models/videolikemod/video_like.go b/pub/models/videolikemod/video_like.go new file mode 100644 index 0000000..618b8bc --- /dev/null +++ b/pub/models/videolikemod/video_like.go @@ -0,0 +1,83 @@ +package videolikemod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 video_like 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.VideoLike) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: FN.VideoLike.UserID, Value: 1}, {Key: FN.VideoLike.VideoID, Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: FN.VideoLike.VideoID, Value: 1}}}, + {Keys: bson.D{{Key: FN.VideoLike.CreatedAt, Value: -1}, {Key: FN.VideoLike.VideoID, Value: 1}}}, + {Keys: bson.D{{Key: FN.VideoLike.UserID, Value: 1}, {Key: FN.VideoLike.CreatedAt, Value: -1}, {Key: FN.VideoLike.Id, Value: -1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(videolikemod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// UpsertOneVideoLike 按用户ID和视频ID写入点赞关系,返回是否新增点赞 +func UpsertOneVideoLike(ctx context.Context, userId, videoId int64, now time.Time) (bool, *srvdef.SrvErr) { + filter := bson.M{FN.VideoLike.UserID: userId, FN.VideoLike.VideoID: videoId} + update := bson.M{ + "$set": bson.M{FN.VideoLike.UpdatedAt: now}, + "$setOnInsert": bson.M{ + FN.VideoLike.Id: mongos.Lid.VideoLike.Get(), + FN.VideoLike.UserID: userId, + FN.VideoLike.VideoID: videoId, + FN.VideoLike.CreatedAt: now, + }, + } + result, err := coll.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) + if err != nil { + log.Warn("videolikemod:UpsertOneVideoLike db.UpdateOne failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.UpsertedCount > 0, nil +} + +// DeleteOneVideoLike 删除一条视频点赞关系,返回是否实际删除 +func DeleteOneVideoLike(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + result, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("videolikemod:DeleteOneVideoLike db.DeleteOne failed", log.Any("filter", filter), log.E(err)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return result > 0, nil +} + +// CountVideoLike 统计视频点赞关系数量 +func CountVideoLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("videolikemod:CountVideoLike db.Count failed", log.Any("filter", filter), log.E(err)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// AggregateVideoLike 聚合查询视频点赞关系 +func AggregateVideoLike(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("videolikemod:AggregateVideoLike db.Aggregate failed", log.Any("pipeline", pipeline), log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/videotagmod/tag.go b/pub/models/videotagmod/tag.go new file mode 100755 index 0000000..d3a781e --- /dev/null +++ b/pub/models/videotagmod/tag.go @@ -0,0 +1,48 @@ +package videotagmod + +type TagVideoTagInfo struct { + CreatedAt string + Id string + IndexNow string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Sort string + Status string + Title string + UpdatedAt string + WatchTimes string + Works string + Images string + MediaTypes string + IsShowHome string +} + +var FN struct { + VideoTagInfo TagVideoTagInfo +} + +var TN struct { + VideoTagInfo string +} + +func init() { + FN.VideoTagInfo.CreatedAt = "createdAt" + FN.VideoTagInfo.Id = "_id" + FN.VideoTagInfo.IndexNow = "indexNow" + FN.VideoTagInfo.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoTagInfo.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoTagInfo.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoTagInfo.Sort = "sort" + FN.VideoTagInfo.Status = "status" + FN.VideoTagInfo.Title = "title" + FN.VideoTagInfo.UpdatedAt = "updatedAt" + FN.VideoTagInfo.WatchTimes = "watchTimes" + FN.VideoTagInfo.Works = "works" + FN.VideoTagInfo.Images = "images" + FN.VideoTagInfo.MediaTypes = "mediaTypes" + FN.VideoTagInfo.IsShowHome = "isShowHome" + TN.VideoTagInfo = "video_taginfo" +} diff --git a/pub/models/videotagmod/taginfo.go b/pub/models/videotagmod/taginfo.go new file mode 100644 index 0000000..102c0ac --- /dev/null +++ b/pub/models/videotagmod/taginfo.go @@ -0,0 +1,155 @@ +package videotagmod + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + + "fmt" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// 表名 +var db *mongos.Collection + +func Init(ctx context.Context) { + // 获取数据库表 + db = mongos.Shop.Coll(TN.VideoTagInfo) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Title, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Status, Value: 1}, + {Key: FN.VideoTagInfo.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Status, Value: 1}, + {Key: FN.VideoTagInfo.WatchTimes, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoTagInfoIndex) Create VideoTagInfo Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoTagInfo 插入一个渠道统计 +func InsertOneVideoTagInfo(ctx context.Context, qa *VideoTagInfo) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoTagInfomod:InsertOneVideoTagInfo db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoTagInfo 删除一个渠道统计 +func DelOneVideoTagInfo(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoTagInfomod:DelOneVideoTagInfo db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoTagInfo 查找一个渠道统计 +func FindOneVideoTagInfo(ctx context.Context, filter bson.M) ( + *VideoTagInfo, *srvdef.SrvErr) { + var VideoTagInfo VideoTagInfo + if err := db.FindOneEx(ctx, &VideoTagInfo, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoTagInfomod:FindOneVideoTagInfo db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoTagInfo, nil +} + +func FindManyVideoTagInfos(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoTagInfoList, *srvdef.SrvErr) { + data := make(VideoTagInfoList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoTagInfos", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoTagInfos 统计渠道统计数量 +func CountVideoTagInfos(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoTagInfos db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoTagInfo 更新一个渠道统计 +func UpdateOneVideoTagInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoTagInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoTagInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// BulkWriteVideoTagInfo 批量写入/更新视频标签 +func BulkWriteVideoTagInfo(ctx context.Context, docs []mongo.WriteModel, + opts ...*options.BulkWriteOptions) (*mongo.BulkWriteResult, *srvdef.SrvErr) { + result, err := db.BulkWrite(ctx, docs, opts...) + if err != nil { + log.Warn("VideoTagInfo:BulkWriteVideoTagInfo db.BulkWrite failed", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result, nil +} + +func UpdateManyVideoTagInfo(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoTagInfo db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount < 1 { + log.Warn("post:UpdateManyVideoInfomation db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/videotagmod/type.go b/pub/models/videotagmod/type.go new file mode 100644 index 0000000..387b133 --- /dev/null +++ b/pub/models/videotagmod/type.go @@ -0,0 +1,24 @@ +package videotagmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @table:video_taginfo +type VideoTagInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + Status bool `json:"status" bson:"status"` // 上架状态 + Sort int64 `json:"sort" bson:"sort"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Images string `json:"images" bson:"images"` // 背景 + Works int64 `json:"works" bson:"works"` // 作品数量 + MediaTypes []int64 `json:"mediaTypes" bson:"mediaTypes"` // 作者关联的资源类型 1视频 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 预计算:该标签下所有视频的累计播放量 + IsShowHome bool `json:"isShowHome" bson:"isShowHome"` // 是否显示在首页 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoTagInfoList []*VideoTagInfo diff --git a/pub/mongos/QueryBuilder.go b/pub/mongos/QueryBuilder.go new file mode 100644 index 0000000..9cdb5df --- /dev/null +++ b/pub/mongos/QueryBuilder.go @@ -0,0 +1,150 @@ +package mongos + +import ( + "go.mongodb.org/mongo-driver/bson" +) + +type QueryBuilder struct { + query bson.M +} + +func NewQueryBuilder() *QueryBuilder { + return &QueryBuilder{query: bson.M{}} +} + +func (q *QueryBuilder) Build() bson.M { + return q.query +} +func (q *QueryBuilder) addCondition(condition bool, field string, value interface{}) { + if condition { + q.query[field] = value + } +} + +func (q *QueryBuilder) Eq(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = value + } + return q +} + +func (q *QueryBuilder) Ne(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$ne": value} + } + return q +} + +func (q *QueryBuilder) Or(conditions ...bson.M) *QueryBuilder { + if len(conditions) > 0 { + q.query["$or"] = conditions + } + return q +} + +func (q *QueryBuilder) Set(value bson.M) *QueryBuilder { + q.query["$set"] = value + return q +} + +func (q *QueryBuilder) Gt(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$gt": value} + } + return q +} + +func (q *QueryBuilder) Gte(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$gte": value} + } + return q +} + +func (q *QueryBuilder) Lt(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$lt": value} + } + return q +} + +func (q *QueryBuilder) Lte(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$lte": value} + } + return q +} + +func (q *QueryBuilder) In(field string, values *[]int64, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$in": *values} + } + return q +} + +func (q *QueryBuilder) Nin(field string, values []interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$nin": values} + } + return q +} + +func (q *QueryBuilder) Regex(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"regex": value.(string)} + } + return q +} + +func (q *QueryBuilder) All(field string, values []interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$all": values} + } + return q +} + +func (q *QueryBuilder) ElemMatch(field string, value interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$elemMatch": value} + } + return q +} + +func (q *QueryBuilder) Size(field string, value int, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$size": value} + } + return q +} + +func (q *QueryBuilder) Exists(field string, value bool, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$exists": value} + } + return q +} + +func (q *QueryBuilder) Mod(field string, divisor, remainder int, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$mod": []int{divisor, remainder}} + } + return q +} + +func (q *QueryBuilder) GeoWithin(field string, geometry interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$geoWithin": bson.M{ + "$geometry": geometry, + }} + } + return q +} + +func (q *QueryBuilder) GeoIntersects(field string, geometry interface{}, condition ...bool) *QueryBuilder { + if len(condition) == 0 || condition[0] { + q.query[field] = bson.M{"$geoIntersects": bson.M{ + "$geometry": geometry, + }} + } + return q +} diff --git a/pub/mongos/base_mod.go b/pub/mongos/base_mod.go new file mode 100644 index 0000000..e4efa22 --- /dev/null +++ b/pub/mongos/base_mod.go @@ -0,0 +1,582 @@ +package mongos + +import ( + "context" + "errors" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + + "reflect" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type Collection struct { + name string + coll *mongo.Collection +} + +func (b *Collection) FindOne(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOneOptions) error { + err := b.coll.FindOne(ctx, query, opts...).Decode(bind) + if err != nil && err == mongo.ErrNoDocuments { + return nil + } + return err +} + +func (b *Collection) FindOneEx(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOneOptions) error { + err := b.coll.FindOne(ctx, query, opts...).Decode(bind) + return err +} + +func (b *Collection) FindMOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update interface{}, opts ...*options.FindOneAndUpdateOptions) error { + setReturn := false + for _, opt := range opts { + if opt != nil && opt.ReturnDocument != nil { + setReturn = true + break + } + } + if !setReturn { + after := options.After + opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) + } + um := utils.Struct2Map(update) + // um["update_at"] = time.Now() + // set := bson.M{ + // "$set": um, + // } + result := b.coll.FindOneAndUpdate(ctx, query, um, opts...) + err := result.Decode(bind) + return err +} + +func (b *Collection) FindMOneAndUpdateInc(ctx context.Context, bind interface{}, query bson.M, update interface{}, inc interface{}, opts ...*options.FindOneAndUpdateOptions) error { + um := utils.Struct2Map(update) + um["update_at"] = time.Now() + im := utils.Struct2Map(inc) + set := bson.M{ + "$set": um, + } + if im != nil { + set["$inc"] = inc + } + + setReturn := false + for _, opt := range opts { + if opt != nil && opt.ReturnDocument != nil { + setReturn = true + break + } + } + if !setReturn { + after := options.After + opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) + } + + result := b.coll.FindOneAndUpdate(ctx, query, set, opts...) + err := result.Decode(bind) + return err +} + +// func (b *Collection) FindOneAndUpdateTiny( +// ctx context.Context, +// query interface{}, +// update bson.M, +// opts ...*options.FindOneAndUpdateOptions) error { +// result := b.collection().FindOneAndUpdate(ctx, query, update, opts...) +// return result.Err() +// } + +// func (b *MongoTool) FindOneAndUpdateReturnTiny(ctx context.Context, +// bind interface{}, +// query bson.M, +// update bson.M, +// afterDoc bool, +// opts ...*options.FindOneAndUpdateOptions) error { + +// if afterDoc { +// opts = append(opts, options.FindOneAndUpdate().SetReturnDocument(options.After)) +// } else { +// opts = append(opts, options.FindOneAndUpdate().SetReturnDocument(options.Before)) +// } + +// result := b.collection().FindOneAndUpdate(ctx, query, update, opts...).Decode(bind) +// return result +// } + +// func (b *Collection) FindOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) error { +// setReturn := false +// for _, opt := range opts { +// if opt != nil && opt.ReturnDocument != nil { +// setReturn = true +// break +// } +// } +// if !setReturn { +// after := options.After +// opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) +// } +// b.setUpdatedAt(update, time.Now()) +// result := b.collection().FindOneAndUpdate(ctx, query, update, opts...) +// err := result.Decode(bind) +// return err +// } + +func (b *Collection) Find(ctx context.Context, bind interface{}, query bson.M, opt ...*options.FindOptions) error { + err := b.validSlice(bind) + cur, err := b.coll.Find(ctx, query, opt...) + if err != nil && err == mongo.ErrNoDocuments { + return nil + } else if err != nil { + return err + } + err = cur.All(ctx, bind) + return err +} + +// func (b *Collection) FindByPage(ctx context.Context, bind interface{}, query bson.M, pageAndSort *PageAndSort, opts ...*options.FindOptions) error { +// err := b.validSlice(bind) + +// if err != nil { +// return err +// } + +// opt := &options.FindOptions{} +// if pageAndSort == nil { +// return errors.New("page must not nil") +// } + +// if pageAndSort.Page < 0 || pageAndSort.PageSize == 0 { +// return errors.New("page and PageSize must not nil") +// } + +// skip := pageAndSort.Page * pageAndSort.PageSize +// opt.Skip = &skip +// opt.Limit = &pageAndSort.PageSize +// if pageAndSort.Sort != nil { +// opt.Sort = pageAndSort.Sort +// } +// opts = append(opts, opt) +// cur, err := b.collection().Find(ctx, query, opts...) +// if err != nil && err == mongo.ErrNoDocuments { +// return nil +// } else if err != nil { +// return err +// } +// err = cur.All(ctx, bind) +// return err + +// } + +func (b *Collection) FindOneAndDelete(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOneAndDeleteOptions) error { + result := b.coll.FindOneAndDelete(ctx, query, opts...) + if result.Err() != nil { + return result.Err() + } + return result.Decode(bind) +} + +func (b *Collection) InsertOneTiny(ctx context.Context, doc interface{}) (err error) { + _, err = b.coll.InsertOne(ctx, doc) + return +} + +func (b *Collection) InsertOne(ctx context.Context, doc interface{}) (int, error) { + result, err := b.coll.InsertOne(ctx, doc) + if err != nil { + // log.Info("Collection:InsertOne failed", log.Any("colName", b.name), log.E(err)) + return 0, err + } + if _, ok := result.InsertedID.(primitive.ObjectID); ok { + return 1, err + } + return 0, err +} +func (b *Collection) IDInsertOne(ctx context.Context, doc interface{}) (string, error) { + result, err := b.coll.InsertOne(ctx, doc) + if err != nil { + log.Info("Collection:InsertOne failed", log.Any("colName", b.name), log.E(err)) + return "", err + } + if id, ok := result.InsertedID.(primitive.ObjectID); ok { + return id.Hex(), err + } + return "", err +} + +func (b *Collection) validSlice(bind interface{}) error { + if reflect.TypeOf(bind).Kind() != reflect.Ptr { + return errors.New("interface must be a Ptr") + } + if reflect.TypeOf(bind).Elem().Kind() != reflect.Slice { + return errors.New("interface must be a slice") + } + return nil +} + +func (b *Collection) InsertMany(ctx context.Context, docs interface{}) ([]interface{}, error) { + err := b.validSlice(docs) + if err != nil { + return nil, err + } + res := b.setTimeForSlice(docs) + result, err := b.coll.InsertMany(ctx, res) + if err != nil { + return nil, err + } + return result.InsertedIDs, err +} + +// struct方式 +func (b *Collection) UpdateMOne(ctx context.Context, query bson.M, update interface{}) (*mongo.UpdateResult, error) { + um := utils.Struct2Map(update) + um["update_at"] = time.Now() + set := bson.M{ + "$set": um, + } + return b.coll.UpdateOne(ctx, query, set) +} + +func (b *Collection) UpdateMOneInc(ctx context.Context, query bson.M, update interface{}, inc interface{}) (*mongo.UpdateResult, error) { + um := utils.Struct2Map(update) + um["update_at"] = time.Now() + set := bson.M{ + "$set": um, + } + im := utils.Struct2Map(inc) + if im != nil { + set["$inc"] = im + } + return b.coll.UpdateOne(ctx, query, set) +} + +// 原生bson.M 方式 +func (b *Collection) UpdateOne(ctx context.Context, query bson.M, update bson.M, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) { + //b.setUpdatedAt(update, time.Now()) + return b.coll.UpdateOne(ctx, query, update, opts...) +} + +func (b *Collection) UpsertOne(ctx context.Context, query bson.M, update bson.M) (*mongo.UpdateResult, error) { + opt := &options.UpdateOptions{} + opt.SetUpsert(true) + //b.setDeaultTime(update) + result, err := b.coll.UpdateOne(ctx, query, update, opt) + return result, err + +} + +func (b *Collection) UpdateMMany(ctx context.Context, query bson.M, update interface{}) (*mongo.UpdateResult, error) { + // b.setUpdatedAt(update, time.Now()) + return b.coll.UpdateMany(ctx, query, update) +} + +func (b *Collection) UpdateMany(ctx context.Context, query bson.M, update bson.M) (*mongo.UpdateResult, error) { + //b.setUpdatedAt(update, time.Now()) + return b.coll.UpdateMany(ctx, query, update) +} + +// func (b *Collection) DeleteById(ctx context.Context, id interface{}) (int64, error) { + +// param := bson.M{"_id": id} +// result, err := b.collection().DeleteOne(ctx, param) +// if err != nil { +// return 0, err +// } +// return result.DeletedCount, nil + +// } + +func (b *Collection) DeleteOne(ctx context.Context, query bson.M, opts ...*options.DeleteOptions) (int64, error) { + result, err := b.coll.DeleteOne(ctx, query) + if err != nil { + return 0, err + } + return result.DeletedCount, nil +} + +func (b *Collection) DeleteMany(ctx context.Context, query bson.M, opts ...*options.DeleteOptions) (int64, error) { + result, err := b.coll.DeleteMany(ctx, query, opts...) + if err != nil { + return 0, err + } + deletedCount := result.DeletedCount + return deletedCount, nil + +} + +func (b *Collection) Distince(ctx context.Context, filter bson.M, fieldName string) ([]interface{}, error) { + return b.coll.Distinct(ctx, fieldName, filter) +} + +func (b *Collection) Aggregate(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) error { + + err := b.validSlice(bind) + if err != nil { + return err + } + //bs, _ := json.Marshal(pipeline) + //log.Debug(string(bs)) + cur, err := b.coll.Aggregate(ctx, pipeline, opts...) + if err != nil { + return err + } + return cur.All(ctx, bind) +} + +func (b *Collection) ObjectAggregate(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) error { + cur, err := b.coll.Aggregate(ctx, pipeline, opts...) + if err != nil { + return err + } + if cur.Next(ctx) { + return cur.Decode(bind) + } + return nil +} + +func (b *Collection) QueryOne(ctx context.Context, bind interface{}, query interface{}, opts ...*options.FindOneOptions) error { + err := b.coll.FindOne(ctx, query, opts...).Decode(bind) + if err != nil && err == mongo.ErrNoDocuments { + return nil + } + return err +} + +func (b *Collection) Count(ctx context.Context, query interface{}, opts ...*options.CountOptions) (int64, error) { + return b.coll.CountDocuments(ctx, query, opts...) +} + +func (b *Collection) EstimatedCount(ctx context.Context, opts ...*options.EstimatedDocumentCountOptions) (int64, error) { + return b.coll.EstimatedDocumentCount(ctx, opts...) +} + +func (b *Collection) BulkWrite(ctx context.Context, models []mongo.WriteModel, opts ...*options.BulkWriteOptions) (*mongo.BulkWriteResult, error) { + return b.coll.BulkWrite(ctx, models, opts...) +} + +// CreateIndex 创建数据索引.options 在index里面创建 +func (b *Collection) CreateIndex(ctx context.Context, models []mongo.IndexModel) ([]string, error) { + data, err := b.coll.Indexes().CreateMany(ctx, models) + if err != nil { + if strings.Contains(err.Error(), "IndexKeySpecsConflict") { + return data, nil + } + if strings.Contains(err.Error(), "IndexOptionsConflict") { + return data, nil + } + return data, err + } + return data, nil +} + +// DropIndex 删除数据索引.options 在index里面创建 +// func (b *Collection) DropIndex(ctx context.Context, indexName string) error { +// _, err := b.coll.Indexes().DropOne(ctx, indexName) +// if err != nil { +// return err +// } +// return nil +// } + +// func (b *MongoTool) addCreatedAt(val interface{}) { +// if _, ok := val.(bson.M); ok { +// return +// } +// if _, ok := val.(map[string]interface{}); ok { +// return +// } +// v := reflect.ValueOf(val).Elem().FieldByName("CreatedAt") +// if v.IsValid() { +// v.Set(reflect.ValueOf(time.Now())) +// } + +// } +// func (b *MongoTool) addUpdatedAt(val interface{}) { +// if _, ok := val.(bson.M); ok { +// return +// } +// if _, ok := val.(map[string]interface{}); ok { +// return +// } +// v := reflect.ValueOf(val).Elem().FieldByName("UpdatedAt") +// if v.IsValid() { +// v.Set(reflect.ValueOf(time.Now())) +// } +// } + +func (b *Collection) setTimeForSlice(docs interface{}) []interface{} { + arr := reflect.ValueOf(docs).Elem() + result := make([]interface{}, 0) + for i := 0; i < arr.Len(); i++ { + ele := arr.Index(i).Elem() + now := time.Now() + if ma := ele.FieldByName("update_at"); ma.IsValid() { + ma.Set(reflect.ValueOf(now)) + } + if ca := ele.FieldByName("create_at"); ca.IsValid() { + ca.Set(reflect.ValueOf(now)) + } + result = append(result, ele.Interface()) + } + return result +} + +// func (b *Collection) validSlice(bind interface{}) error { + +// if reflect.TypeOf(bind).Kind() != reflect.Ptr { +// return errors.New("interface must be a Ptr") +// } +// if reflect.TypeOf(bind).Elem().Kind() != reflect.Slice { +// return errors.New("interface must be a slice") +// } +// return nil +// } + +// func (b *Collection) setUpdatedAt(doc bson.M, now time.Time) { +// set, exists := doc["$set"] +// if !exists { +// doc["$set"] = bson.M{"updatedAt": now} +// return +// } + +// if res, ok := set.(bson.M); ok { +// res["updatedAt"] = now +// } +// } + +// func (b *Collection) setCreatedAt(doc bson.M, now time.Time) { +// set, exists := doc["$set"] +// if !exists { +// doc["$set"] = bson.M{"createdAt": now} +// return +// } +// if res, ok := set.(bson.M); ok { +// res["createdAt"] = now +// } +// } + +// func (b *Collection) setDeaultTime(doc bson.M) { +// now := time.Now() +// set, exists := doc["$set"] +// if exists { +// if res, ok := set.(bson.M); ok { +// delete(res, "createdAt") +// res["updatedAt"] = now +// if d, ok := doc["$setOnInsert"]; ok { +// if ss, ok := d.(bson.M); ok { +// if _, ok := ss["createdAt"]; !ok { +// ss["createdAt"] = now +// } +// } +// } else { +// doc["$setOnInsert"] = bson.M{"createdAt": now} +// } +// return +// } +// if res, ok := set.(interface{}); ok { +// struct2Map := utils.Struct2Map(res) +// delete(struct2Map, "createdAt") +// struct2Map["updatedAt"] = now +// doc["$set"] = struct2Map +// doc["$setOnInsert"] = bson.M{"createdAt": now} +// return +// } +// } +// } + +// //////////////////////////////////////////// +func (b *Collection) GetStringIDInsertOne(ctx context.Context, doc interface{}) (string, error) { + result, err := b.coll.InsertOne(ctx, doc) + if err != nil { + return "", err + } + if id, ok := result.InsertedID.(primitive.ObjectID); ok { + return id.Hex(), nil + } + return "", err +} + +// InsertOne 插入单条信息 +func (b *Collection) GetObjectIDInsertOne(ctx context.Context, document interface{}, opts ...*options.InsertOneOptions) (*mongo.InsertOneResult, error) { + return b.coll.InsertOne(ctx, document, opts...) +} + +// UpdateOneNew 或者修改或者插入一条数据 +func (b *Collection) UpdateOneNew(ctx context.Context, query bson.M, update bson.M) (*mongo.UpdateResult, error) { + return b.coll.UpdateOne(ctx, query, update, options.Update().SetUpsert(true)) +} + +func (b *Collection) Max(ctx context.Context, fieldName string, filter bson.M) (int64, error) { + //b.coll.Aggregate(ctx, ) + type Temp struct { + Max int64 `bson:"max"` + } + var temp []Temp + query := []bson.M{ + {"$group": bson.M{ + "_id": nil, + "max": bson.M{"$max": "$" + fieldName}, + }, + }, + } + if err := b.Aggregate(ctx, &temp, query); err != nil || len(temp) <= 0 { + return 0, err + } + + return temp[0].Max, nil +} + +// func (b *Collection) setUpdatedAt(doc bson.M, now time.Time) { +// set, exists := doc["$set"] +// if !exists { +// doc["$set"] = bson.M{"updatedAt": now} +// return +// } + +// if res, ok := set.(bson.M); ok { +// res["updatedAt"] = now +// } +// } + +func (b *Collection) FindOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) error { + setReturn := false + for _, opt := range opts { + if opt != nil && opt.ReturnDocument != nil { + setReturn = true + break + } + } + if !setReturn { + after := options.After + opts = append(opts, &options.FindOneAndUpdateOptions{ReturnDocument: &after}) + } + // b.coll.setUpdatedAt(update, time.Now()) + result := b.coll.FindOneAndUpdate(ctx, query, update, opts...) + err := result.Decode(bind) + return err +} + +// // +// func (b *Collection) IfNotExistThenInsertAndUpdate(ctx context.Context, query bson.M, insert bson.M, update bson.M) error { +// // func (b *MongoTool) FindOneAndUpdate(ctx context.Context, bind interface{}, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) error { +// err := b.FindOneAndUpdate(ctx, nil, query, update) +// if err != nil { +// _, err = b.InsertOne(ctx, insert) +// } +// return err +// } + +func (b *Collection) WatchOpLog(ctx context.Context, pipeline []bson.M, + opt ...*options.ChangeStreamOptions) (*mongo.ChangeStream, error) { + changeStream, err := b.coll.Watch(ctx, pipeline, opt...) + if err != nil { + return nil, err + } + return changeStream, nil +} diff --git a/pub/mongos/codec.go b/pub/mongos/codec.go new file mode 100644 index 0000000..9d9fb30 --- /dev/null +++ b/pub/mongos/codec.go @@ -0,0 +1,119 @@ +package mongos + +import ( + "fmt" + "github.com/shopspring/decimal" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/bsoncodec" + "go.mongodb.org/mongo-driver/bson/bsonrw" + "go.mongodb.org/mongo-driver/bson/bsontype" + "go.mongodb.org/mongo-driver/bson/primitive" + "reflect" + "time" +) + +var Registry = func() *bsoncodec.Registry { + builder := bson.NewRegistryBuilder() + builder.RegisterDecoder(reflect.TypeOf(time.Time{}), &localTimeDecoder{}) + builder.RegisterDecoder(reflect.TypeOf(decimal.Decimal{}), &decimalDecoder{}) + builder.RegisterEncoder(reflect.TypeOf(decimal.Decimal{}), &decimalEncoder{}) + builder.RegisterDefaultDecoder(reflect.Float32, &floatDecoder{}) + builder.RegisterDefaultDecoder(reflect.Float64, &floatDecoder{}) + return builder.Build() +}() + +type floatDecoder struct { +} + +func (dvd *floatDecoder) DecodeValue(dc bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + var f float64 + var err error + switch vr.Type() { + case bsontype.Int32: + i32, err := vr.ReadInt32() + if err != nil { + return err + } + f = float64(i32) + case bsontype.Int64: + i64, err := vr.ReadInt64() + if err != nil { + return err + } + f = float64(i64) + case bsontype.Double: + f, err = vr.ReadDouble() + if err != nil { + return err + } + default: + return fmt.Errorf("cannot decode %v into a float32 or float64 type", vr.Type()) + } + val.SetFloat(f) + return nil +} + +type localTimeDecoder struct{} + +func (*localTimeDecoder) DecodeValue(dc bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + dvd := bsoncodec.DefaultValueDecoders{} + err := dvd.TimeDecodeValue(dc, vr, val) + if err != nil { + return err + } + t := val.Interface().(time.Time) + val.Set(reflect.ValueOf(t.Local())) + return nil +} + +type decimalDecoder struct{} + +func (*decimalDecoder) DecodeValue(dc bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + if !val.IsValid() || val.Type() != reflect.TypeOf(decimal.Decimal{}) { + return bsoncodec.ValueDecoderError{Name: "DecimalDecodeValue", Types: []reflect.Type{reflect.TypeOf(decimal.Decimal{})}, Received: val} + } + var ( + num bool + ) + if vr.Type() == bson.TypeInt32 || vr.Type() == bson.TypeInt64 { + vr.ReadInt32() + vr.ReadInt64() + num = true + } + if num { + d := decimal.NewFromFloat(0.0) + val.Set(reflect.ValueOf(d)) + return nil + } + + mongodecimal, err := vr.ReadDecimal128() + if err != nil { + return err + } + d, err := decimal.NewFromString(mongodecimal.String()) + if err != nil { + return err + } + val.Set(reflect.ValueOf(d)) + return nil +} + +// + +type decimalEncoder struct{} + +func (*decimalEncoder) EncodeValue(ctx bsoncodec.EncodeContext, vw bsonrw.ValueWriter, val reflect.Value) error { + if !val.IsValid() || val.Type() != reflect.TypeOf(decimal.Decimal{}) { + return bsoncodec.ValueDecoderError{Name: "DecimalEncodeValue", Types: []reflect.Type{reflect.TypeOf(decimal.Decimal{})}, Received: val} + } + + if d, ok := val.Interface().(decimal.Decimal); ok { + mongodecimal, err := primitive.ParseDecimal128(d.StringFixed(2)) + if err != nil { + return err + } + val = reflect.ValueOf(mongodecimal) + } + dve := bsoncodec.DefaultValueEncoders{} + return dve.Decimal128EncodeValue(ctx, vw, val) +} diff --git a/pub/mongos/generate_id.go b/pub/mongos/generate_id.go new file mode 100644 index 0000000..f0f9487 --- /dev/null +++ b/pub/mongos/generate_id.go @@ -0,0 +1,110 @@ +package mongos + +import ( + "context" + "fmt" + "videoweb/pub/models/utils" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var Lid struct { + User *lid + MwAcc *lid + MwRole *lid + MwOpLog *lid + MwAccToken *lid + SystemMsg *lid + AdvitiseInfo *lid + AdvitiseBind *lid + AdvitiseLocation *lid + App *lid + VideoTag *lid + VideoCategory *lid + VideoInfo *lid + VideoFavorite *lid + VideoLike *lid + TransCategory *lid + Translation *lid + VideoCreator *lid + Comment *lid + HtmlText *lid + ContentTag *lid + Actress *lid + HotNav *lid + HomeBanner *lid + Announcement *lid + Playlet *lid + PlayletChapter *lid + PlayletCategory *lid + PlayletHomeConfig *lid + PlayletLike *lid + PlayletFavorite *lid + PlayletTheme *lid + PlayletTag *lid +} + +func StartLid() { + + coll := Shop.Coll(utils.TN.GenerateId) + initIndex(coll) + + Lid.User = createLid(coll, "user", 10) + Lid.MwAcc = createLid(coll, "MwAcc", 1) + Lid.MwRole = createLid(coll, "MwRole", 1) + Lid.MwOpLog = createLid(coll, "MwOpLog", 10) + Lid.MwAccToken = createLid(coll, "MwAccToken", 10) + Lid.SystemMsg = createLid(coll, "SystemMsg", 10) + Lid.AdvitiseInfo = createLid(coll, "AdvitiseInfo", 10) + Lid.AdvitiseBind = createLid(coll, "AdvitiseBind", 10) + Lid.AdvitiseLocation = createLid(coll, "AdvitiseLocation", 10) + Lid.App = createLid(coll, "App", 10) + Lid.VideoTag = createLid(coll, "VideoTag", 10) + Lid.VideoCategory = createLid(coll, "VideoCategory", 10) + Lid.VideoInfo = createLid(coll, "VideoInfo", 10) + Lid.VideoFavorite = createLid(coll, "VideoFavorite", 10) + Lid.VideoLike = createLid(coll, "VideoLike", 10) + Lid.TransCategory = createLid(coll, "TransCategory", 10) + Lid.Translation = createLid(coll, "TranslationData", 10) + Lid.VideoCreator = createLid(coll, "VideoCreator", 10) + Lid.Comment = createLid(coll, "Comment", 10) + Lid.HtmlText = createLid(coll, "HtmlText", 10) + Lid.ContentTag = createLid(coll, "contentTag", 10) + Lid.Actress = createLid(coll, "Actress", 10) + Lid.HotNav = createLid(coll, "HotNav", 10) + Lid.HomeBanner = createLid(coll, "homeBanner", 10) + Lid.Announcement = createLid(coll, "announcement", 10) + Lid.Playlet = createLid(coll, "playlet", 10) + Lid.PlayletChapter = createLid(coll, "playletChapter", 10) + Lid.PlayletCategory = createLid(coll, "playletCategory", 10) + Lid.PlayletHomeConfig = createLid(coll, "playletHomeConfig", 10) + Lid.PlayletLike = createLid(coll, "playletLike", 10) + Lid.PlayletFavorite = createLid(coll, "playletFavorite", 10) + Lid.PlayletTheme = createLid(coll, "playletTheme", 10) + Lid.PlayletTag = createLid(coll, "playletTag", 10) +} + +// initIndex 初始化索引 +func initIndex(coll *Collection) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: utils.FN.GenerateId.BusinessId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + } + if _, err := coll.CreateIndex(context.Background(), indexes); err != nil { + panic(fmt.Sprintf("(user:initIndex) Create Index user failed! err:%+v:", err)) + } +} + +func createLid(db *Collection, businessId string, len int) *lid { + lid, err := newLid(db, businessId, len) + if err != nil { + panic(err) + } + return lid +} diff --git a/pub/mongos/lid.go b/pub/mongos/lid.go new file mode 100644 index 0000000..f2dd3da --- /dev/null +++ b/pub/mongos/lid.go @@ -0,0 +1,102 @@ +package mongos + +import ( + "context" + "time" + "videoweb/pub/models/utils" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +const notFindMsg = "mongo: no documents in result" + +type lid struct { + db *Collection // 数据库连接 + businessId string // 业务id + ch chan int64 // id缓冲池 + min, max int64 // id段最小值,最大值 +} + +// newLid 创建一个lid,db:数据库连接;businessId:业务id;len:缓冲池大小 +func newLid(db *Collection, businessId string, len int) (*lid, error) { + lid := lid{ + db: db, + businessId: businessId, + ch: make(chan int64, len), + } + go lid.productId() + return &lid, nil +} + +// Get 获取自增id +func (l *lid) Get() int64 { + return <-l.ch +} + +// productId 生产id +func (l *lid) productId() { + l.reset() + for { + if l.min >= l.max { + l.reset() + + } + + l.min++ + l.ch <- l.min + } +} + +// reset 不断尝试从数据库获取,直到成功 +func (l *lid) reset() { + for { + err := l.getFromDB() + if err == nil { + return + } + time.Sleep(time.Second) + continue + } +} + +func (l *lid) getFromDB() error { + var ( + max_id int64 + step int64 + //idModel IdModel + idModel utils.GenerateId + ) + businessIdfilter := bson.M{utils.FN.GenerateId.BusinessId: l.businessId} + txErr := Shop.Transaction(context.Background(), func(sctx mongo.SessionContext) (err error) { + err = l.db.FindOneEx(sctx, &idModel, businessIdfilter) + if err != nil { + if err == mongo.ErrNoDocuments { + idModel.MaxId = 10000 + _, err = l.db.InsertOne(sctx, + bson.M{ + utils.FN.GenerateId.BusinessId: l.businessId, + utils.FN.GenerateId.MaxId: 0, + utils.FN.GenerateId.Step: 10, + utils.FN.GenerateId.Desc: "id自增器", + utils.FN.GenerateId.CreateAt: time.Now(), + utils.FN.GenerateId.UpdateAt: time.Now()}) + if err != nil { + return + } + } else { + return + } + } + max_id = idModel.MaxId + step = idModel.Step + _, err = l.db.UpdateOne(sctx, businessIdfilter, bson.M{"$set": bson.M{utils.FN.GenerateId.MaxId: max_id + step}}) + if err != nil { + return + } + l.min = max_id + l.max = max_id + step + return + }) + return txErr +} diff --git a/pub/mongos/manager.go b/pub/mongos/manager.go new file mode 100644 index 0000000..ddec16e --- /dev/null +++ b/pub/mongos/manager.go @@ -0,0 +1,168 @@ +package mongos + +import ( + "context" + "errors" + "time" + "videoweb/pub/common/log" + + "go.mongodb.org/mongo-driver/mongo/readconcern" + "go.mongodb.org/mongo-driver/mongo/readpref" + "go.mongodb.org/mongo-driver/mongo/writeconcern" + "go.mongodb.org/mongo-driver/x/mongo/driver/connstring" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// var ( +// connPools *mongo.Client +// dataBase *mongo.Database +// ) + +var ( + //业务库 + Shop *Manager + //日志库 + ShopLog *Manager +) + +type Manager struct { + client *mongo.Client + //dataBase *mongo.Database + url string + db string +} + +func NewManager(url string) *Manager { + return &Manager{url: url} +} + +func (m *Manager) Database() *mongo.Database { + return m.client.Database(m.db) +} + +func (m *Manager) Coll(name string) *Collection { + coll := m.Database().Collection(name) + return &Collection{name: name, coll: coll} +} + +func (m *Manager) Init() error { + cs, err := connstring.Parse(m.url) + if err != nil { + log.Error("db connect parse err", log.E(err)) + return err + } + if cs.Database == "" { + log.Error("db connect database MUST contains") + return errors.New("database MUST contains") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(m.url)) + if err != nil { + log.Error("[warn] connect mongo db err", log.E(err)) + return err + } + if err = client.Ping(ctx, nil); err != nil { + log.Error("[warn] ping mongo db err", log.E(err)) + return err + } + + //connPools = client + m.client = client + m.db = cs.Database + + log.Info("mongos:Init ok!", log.Any("dbName", m.db)) + return nil +} + +func (m *Manager) DisConnect() { + if m.client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := m.client.Disconnect(ctx); err != nil { + log.Error("db disconnect err", log.E(err)) + } + + m.client = nil + } +} + +// Transaction 开启事务处理包裹处理,里面处理的全是利用的事务的ctx 切记里面的context必须要使用同一个,因为里面有重试机制,循环调用该函数 +func (m *Manager) Transaction(ctx context.Context, fn func(sctx mongo.SessionContext) error) error { + opts := options.Session().SetDefaultReadPreference(readpref.Primary()) + handler := func(sctx mongo.SessionContext) error { + lHandler := func(ctx mongo.SessionContext) (err error) { + tOpts := options.Transaction() + tOpts.SetReadConcern(readconcern.Snapshot()) + tOpts.SetWriteConcern(writeconcern.New(writeconcern.WMajority())) + err = sctx.StartTransaction(tOpts) + if err != nil { + return + } + defer func() { + if rErr := recover(); rErr != nil { + log.Info("caught panic during transaction, aborting...", log.Any("", rErr)) + aErr := sctx.AbortTransaction(sctx) + if aErr != nil { + log.Info("中止事务错误", log.E(aErr)) + } + } + }() + err = fn(sctx) + if err != nil { + aErr := sctx.AbortTransaction(sctx) + if aErr != nil { + log.Info("中止事务错误", log.E(aErr)) + } + log.Info("事务内错误", log.E(err)) + return + } + return commitWithRetry(sctx) + } + return runTransactionWithRetry(sctx, lHandler) + } + return m.client.UseSessionWithOptions(ctx, opts, handler) +} + +func runTransactionWithRetry(sctx mongo.SessionContext, txnFn func(mongo.SessionContext) error) error { + var err error + for i := int64(0); i < 100; i++ { + err = txnFn(sctx) // Performs transaction. + if err == nil { + return nil + } + // If transient error, retry the whole transaction + if cmdErr, ok := err.(mongo.CommandError); ok && cmdErr.HasErrorLabel("TransientTransactionError") { + continue + } + return err + } + return err +} + +// commitWithRetry is an example function demonstrating transaction retry logic. +func commitWithRetry(sctx mongo.SessionContext) error { + for { + err := sctx.CommitTransaction(sctx) + switch e := err.(type) { + case nil: + return nil + case mongo.CommandError: + // Can retry commit + if e.HasErrorLabel("UnknownTransactionCommitResult") { + continue + } + return e + default: + return e + } + } +} + +// func Transaction(ctx context.Context, fn func(sctx mongo.SessionContext) error) error { +// return connMgr.Transaction(ctx, fn) +// } diff --git a/pub/pubdef/comment.go b/pub/pubdef/comment.go new file mode 100644 index 0000000..a33d935 --- /dev/null +++ b/pub/pubdef/comment.go @@ -0,0 +1,27 @@ +package pubdef + +// CommentObjectType 评论对象类型 +type CommentObjectType int64 + +const ( + CommentObjectTypeVideo CommentObjectType = 0 // 视频 + CommentObjectTypePlaylet CommentObjectType = 4 // 短剧 + CommentObjectTypePlayletChapter CommentObjectType = 11 // 短剧剧集 +) + +var commentObjectTypeNameMap = map[CommentObjectType]string{ + CommentObjectTypeVideo: "视频", + CommentObjectTypePlaylet: "短剧", + CommentObjectTypePlayletChapter: "短剧剧集", +} + +// CommentObjectTypeName 返回对象类型对应的展示名称 +func CommentObjectTypeName(objectType CommentObjectType) string { + return commentObjectTypeNameMap[objectType] +} + +// ValidCommentObjectType 判断评论对象类型是否合法 +func ValidCommentObjectType(objectType CommentObjectType) bool { + _, ok := commentObjectTypeNameMap[objectType] + return ok +} diff --git a/pub/pubdef/flow.go b/pub/pubdef/flow.go new file mode 100644 index 0000000..642293d --- /dev/null +++ b/pub/pubdef/flow.go @@ -0,0 +1,36 @@ +package pubdef + +type Flow struct { + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 真实观看次数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + Comments int64 `json:"comments" bson:"comments"` // 评论条数 + Likes int64 `json:"likes" bson:"likes"` // 真实点赞数(收藏总量) + WeekWatchs int64 `json:"weekWatchs" bson:"weekWatchs"` // 本周观看次数 + MonthWatchs int64 `json:"monthWatchs" bson:"monthWatchs"` // 本月观看次数 + YearWatchs int64 `json:"yearWatchs" bson:"yearWatchs"` // 本年观看次数 + TodayWatchs int64 `json:"todayWatchs" bson:"todayWatchs"` // 当日观看次数 + WeekLikes int64 `json:"weekLikes" bson:"weekLikes"` // 近7日收藏量 + MonthLikes int64 `json:"monthLikes" bson:"monthLikes"` // 近30日收藏量 + YearLikes int64 `json:"yearLikes" bson:"yearLikes"` // 近365日收藏量 +} + +type FakeFlow struct { + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 假观看次数 + Likes int64 `json:"likes" bson:"likes"` // 假点赞数 +} + +type InformationStatus int32 + +const ( + InformationStatusWaiting InformationStatus = 0 // 初始化 + InformationStatusVerifyOK InformationStatus = 1 // 上架 + InformationStatusVerifyCancel InformationStatus = 2 // 下架 +) + +type InformationUpdateStatus int32 + +const ( + InformationUpdateStatusWaiting InformationUpdateStatus = 0 // 待发布 + InformationUpdateStatusLoading InformationUpdateStatus = 1 // 连载中 + InformationUpdateStatusEnd InformationUpdateStatus = 2 // 已完结 +) diff --git a/pub/pubdef/media.go b/pub/pubdef/media.go new file mode 100644 index 0000000..9de5a91 --- /dev/null +++ b/pub/pubdef/media.go @@ -0,0 +1,8 @@ +package pubdef + +type ContentType int64 + +const ( + ContentType_Video ContentType = 1 // 视频 + ContentType_Collection ContentType = 2 // 合集 +) diff --git a/pub/pubdef/pubdef.go b/pub/pubdef/pubdef.go new file mode 100644 index 0000000..7b87cd7 --- /dev/null +++ b/pub/pubdef/pubdef.go @@ -0,0 +1,83 @@ +package pubdef + +import "fmt" + +type Page struct { + PageNum int64 `form:"pageNum" json:"pageNum,omitempty"` + PageSize int64 `form:"pageSize" json:"pageSize,omitempty"` +} + +const ( + DateISO = "2006-01-02" + TimeISO = "2006-01-02 15:04:05" + AppId = 1046 // 91pron + AppName = "海角社区" + DefaultChannelCode = "hjsq" + + // CompanyPlatform 是内容中台授权的产品名,对应 company_platform 字段 + CompanyPlatform = "海角社区" + // seoPushEnabled 对外推送总开关。需要推送时改为 true 后重新发版。 + // false 时不登记任务,开启后再按当前页面地址生成并推送。 + // 真正提交还要求 runMod=prod,测试环境即使打开开关也只 dry-run。 + seoPushEnabled = false +) + +func ReportAppId() string { + return fmt.Sprintf("JHA-%d", AppId) +} + +// Enabled 业务开关是否打开(不含环境判断)。 +func Enabled() bool { + return seoPushEnabled +} + +const PageSize int64 = 40 + +func GetTotalPage(total int64, page int64) int64 { + totalPage := total / PageSize + if total%PageSize > 0 { + totalPage++ + } + return totalPage +} + +type SeoValue struct { + Title string `json:"title" bson:"webTitle"` // 网站SEO标题 + Keyworld []string `json:"keyworld" bson:"webKeyworld"` // 网站SEO关键字 + Description string `json:"description" bson:"webDescription"` // 网站SEO描述 +} + +// DisplayMode 分类展示方式 +type DisplayMode int64 + +const ( + DisplayModeDefault DisplayMode = 0 // 默认(上图下标题多列) + DisplayModeCardCarousel DisplayMode = 1 // 卡片层叠轮播 + DisplayModeAvatarList DisplayMode = 2 // 头像列表 + DisplayModeImageText DisplayMode = 3 // 图文列表(左图右标题多列) + DisplayModeBigCover DisplayMode = 4 // 大封面列表(一大图两列) +) + +// CategoryDisplayPosition 分类显示位置 +type CategoryDisplayPosition int64 + +const ( + CategoryDisplayPositionTabOnly CategoryDisplayPosition = 0 // 仅TAB + CategoryDisplayPositionHomeOnly CategoryDisplayPosition = 1 // 仅首页 + CategoryDisplayPositionBoth CategoryDisplayPosition = 2 // TAB和首页都显示 +) + +// CategorySortType 首页分类内容排序方式。 +type CategorySortType int64 + +const ( + CategorySortTypeAddedTimeDesc CategorySortType = 0 // 上架时间降序 + CategorySortTypeWatchDesc CategorySortType = 1 // 观看次数降序 + CategorySortTypeLikeDesc CategorySortType = 2 // 点赞(收藏)次数降序 + CategorySortTypeAddedTimeAsc CategorySortType = 3 // 上架时间升序 + CategorySortTypeWatchAsc CategorySortType = 4 // 观看次数升序 + CategorySortTypeLikeAsc CategorySortType = 5 // 点赞(收藏)次数升序 + CategorySortTypeUpdateTimeDesc CategorySortType = 6 // 更新时间降序 + CategorySortTypeUpdateTimeAsc CategorySortType = 7 // 更新时间升序 + CategorySortTypeSortType CategorySortType = 8 // 自定义排序 +) diff --git a/pub/pubdef/translation.go b/pub/pubdef/translation.go new file mode 100644 index 0000000..2cbc905 --- /dev/null +++ b/pub/pubdef/translation.go @@ -0,0 +1,26 @@ +package pubdef + +// TranslationType 导航类型 +type TranslationType int64 + +const ( + TranslationTypeUrl TranslationType = 1 // URL模式 + TranslationTypeSub TranslationType = 2 // 二级导航 + TranslationTypeBottom TranslationType = 3 // 底部导航 +) + +const ( + TranslationPath_Video = "/video" // 视频首页 + TranslationPath_VideoAll = "/video/all" // 全部视频 + TranslationPath_VideoRank = "/video/rank" // 视频排行榜 + TranslationPath_ActressRank = "/actress/rank" // 女优排行榜 + TranslationPath_VideoMore = "/video/more/%d" // 视频分类 +) + +// FixedNavItem 固定导航路径对应的名称(与路径常量一一对应,供各端统一使用) +const ( + TranslationName_Video = "视频首页" + TranslationName_VideoAll = "全部视频" + TranslationName_VideoRank = "视频排行榜" + TranslationName_ActressRank = "女优排行榜" +) diff --git a/pub/pubdef/user.go b/pub/pubdef/user.go new file mode 100644 index 0000000..f78382c --- /dev/null +++ b/pub/pubdef/user.go @@ -0,0 +1,70 @@ +package pubdef + +type UserStatus int32 + +const ( + UserStatusNormal UserStatus = 0 // 正常 + UserStatusDisable UserStatus = 1 // 封禁 + UserStatusSlient UserStatus = 2 // 禁言 +) + +var UserStatusMap = map[UserStatus]string{ + UserStatusNormal: "正常", + UserStatusDisable: "封禁", + UserStatusSlient: "禁止登陆", +} + +type LoginType int32 + +const ( + LoginTypeDevice LoginType = 0 + iota // 设备登陆 + LoginTypeMobile // 手机登陆 + LoginTypeQrCode // 二维码登陆 + LoginTypeAccount // 账号登陆 +) + +// SysType 客户端系统/平台类型 +type SysType string + +const ( + ST_None SysType = "" + ST_IOS SysType = "ios" + ST_Android SysType = "android" + ST_H5 SysType = "h5" + ST_PC SysType = "pc" + ST_H5IOS SysType = "h5_ios" + ST_H5Android SysType = "h5_android" + ST_H5PC SysType = "h5_pc" + ST_Light SysType = "light" + ST_TG SysType = "tg" + ST_Web SysType = "web" +) + +func (s SysType) String() string { return string(s) } + +// IsH5 判断是否为 H5/Web 端 +func (s SysType) IsH5() bool { + return s == ST_H5IOS || s == ST_H5Android || s == ST_H5PC || + s == ST_H5 || s == ST_Light || s == ST_TG || s == ST_Web +} + +// IsIOS 判断是否为 iOS 原生 +func (s SysType) IsIOS() bool { return s == ST_IOS } + +// IsAndroid 判断是否为 Android 原生 +func (s SysType) IsAndroid() bool { return s == ST_Android } + +// IsWeb 判断是否为 Web 端 +func (s SysType) IsWeb() bool { return s == ST_Web } + +// SysTypeFromString 将字符串转换为 SysType +func SysTypeFromString(s string) SysType { + switch SysType(s) { + case ST_IOS, ST_Android, ST_H5, ST_PC, + ST_H5IOS, ST_H5Android, ST_H5PC, + ST_Light, ST_TG, ST_Web: + return SysType(s) + default: + return ST_None + } +} diff --git a/pub/redis/List.go b/pub/redis/List.go new file mode 100644 index 0000000..db85e71 --- /dev/null +++ b/pub/redis/List.go @@ -0,0 +1,95 @@ +package redis + +import ( + "github.com/go-redis/redis" + "time" +) + +// List represents a redis list. +type List struct { + key string + client *redis.Client +} + +// NewList creates a new redis list instance. +func NewList(client *redis.Client, key string) *List { + return &List{ + key: key, + client: client, + } +} + +// Key returns the key of the list. +func (l *List) Key() string { + return l.key +} + +// Expire sets the expiration time of the list. +func (l *List) Expire(expiration time.Duration) (bool, error) { + return l.client.Expire(l.key, expiration).Result() +} + +// Delete deletes the list. +func (l *List) Delete() (int64, error) { + return l.client.Del(l.key).Result() +} + +// Len returns the length of the list. +func (l *List) Len() (int64, error) { + return l.client.LLen(l.key).Result() +} + +// Push adds a new element to the tail of the list. +func (l *List) Push(value interface{}) error { + return l.client.RPush(l.key, value).Err() +} + +// Pop removes and returns the first element of the list. +func (l *List) Pop() (string, error) { + return l.client.LPop(l.key).Result() +} + +// PushFront adds a new element to the head of the list. +func (l *List) PushFront(value interface{}) error { + return l.client.LPush(l.key, value).Err() +} + +// PopBack removes and returns the last element of the list. +func (l *List) PopBack() (string, error) { + return l.client.RPop(l.key).Result() +} + +// Index returns the element at the specified index. +func (l *List) Index(index int64) (string, error) { + return l.client.LIndex(l.key, index).Result() +} + +// Range returns a range of elements from the list. +func (l *List) Range(start, stop int64) ([]string, error) { + return l.client.LRange(l.key, start, stop).Result() +} + +// Trim removes elements from the list. +func (l *List) Trim(start, stop int64) error { + return l.client.LTrim(l.key, start, stop).Err() +} + +// Set sets the element at the specified index. +func (l *List) Set(index int64, value interface{}) error { + return l.client.LSet(l.key, index, value).Err() +} + +// InsertBefore inserts a new element before the specified element. +func (l *List) InsertBefore(pivot, value interface{}) error { + return l.client.LInsertBefore(l.key, pivot, value).Err() +} + +// InsertAfter inserts a new element after the specified element. +func (l *List) InsertAfter(pivot, value interface{}) error { + return l.client.LInsertAfter(l.key, pivot, value).Err() +} + +// Remove removes all occurrences of the specified element. +func (l *List) Remove(count int64, value interface{}) (int64, error) { + return l.client.LRem(l.key, count, value).Result() +} diff --git a/pub/redis/channeldef.go b/pub/redis/channeldef.go new file mode 100644 index 0000000..f76ed5d --- /dev/null +++ b/pub/redis/channeldef.go @@ -0,0 +1,75 @@ +package redis + +import "time" + +// RCType redis channel type +type RCType string + +const ( + // config + RCT_cfg_all RCType = "config.*" + // tabkind 配置改变 + RCT_cfg_tabkind_change RCType = "config.tabkindchange" + // mediatag 配置改变 + RCT_cfg_mediatag_change RCType = "config.mediatagchange" + // mediacat 配置改变 + RCT_cfg_mediacat_change RCType = "config.mediacatchange" + // mediatopic 配置改变 + RCT_cfg_mediatopic_change RCType = "config.mediatopicchange" + // mediacolumn 配置改变 + RCT_cfg_mediacolumn_change RCType = "config.mediacolumnchange" + // syscfg 系统配置改变 + RCT_cfg_syscfg_change RCType = "config.syscfgchange" + + // 广告 + RCT_ad_all RCType = "ad.*" + // 广告配置改变 + RCT_ad_cfgchange RCType = "ad.cfgchange" + + // 系统通知 + RCT_blt_all RCType = "blt.*" + // 系统通知 + RCT_blt_cfgchange RCType = "blt.cfgchange" + + //media + RCT_media_all RCType = "media.*" + //media 信息改变 + RCT_media_change RCType = "media.change" + //media 信息添加 + RCT_media_add RCType = "media.add" + //media 信息删除 + RCT_media_del RCType = "media.del" + //media 相关排行榜改变 + RCT_media_topchange RCType = "media.topchange" + + //user + RCT_user_all RCType = "user.*" + //user 信息改变 + RCT_user_change RCType = "user.change" + //user 信息添加 + RCT_user_add RCType = "user.add" + //user 信息删除 + RCT_user_del RCType = "user.del" + //user 相关排行榜改变 + RCT_user_topchange RCType = "user.topchange" + + //视频推荐 + RCT_recommend_all RCType = "recommend.*" + //博主推荐 + RCT_recommend_blogger_all RCType = "recommend.bloggerALL" + // 视频每日推荐 + RCT_recommend_media_all RCType = "recommend.mediaAll" + + //tg消息 + RCT_tgmsg_all RCType = "tgmsg.*" + RCT_tgmsg_send_msg RCType = "tgmsg.sendmsg" + + //vipcard + RCT_vipcard_all RCType = "vipcard.*" + RCT_vipcard_change RCType = "vipcard.change" + + NameSource = "name:source" //姓名池 + NameSourceExpire = 2 * 24 * time.Hour + CommunicateServerChannel = "comm:lld#server" + CommunicateClientChannel = "comm:lld#client" +) diff --git a/pub/redis/hash.go b/pub/redis/hash.go new file mode 100644 index 0000000..c0f5d0c --- /dev/null +++ b/pub/redis/hash.go @@ -0,0 +1,112 @@ +package redis + +import ( + "time" + + "github.com/go-redis/redis" +) + +//key: {name:value, name:value, name:value} + +type Hash struct { + key string + client *redis.Client +} + +func NewHash(client *redis.Client, key string) *Hash { + return &Hash{ + key: key, + client: client, + } +} + +func (s *Hash) Key() string { + return s.key +} + +// Expire 设置集合过期时间 +func (s *Hash) Expire(expiration time.Duration) (bool, error) { + return s.client.Expire(s.key, expiration).Result() +} + +// Delete 删除一个或多个 Name +func (s *Hash) Delete(names ...string) (int64, error) { + return s.client.HDel(s.key, names...).Result() +} + +// Exists 检测Name是否在哈希表中 +func (s *Hash) Exists(name string) (bool, error) { + return s.client.HExists(s.key, name).Result() +} + +// NameValues 获取所有哈希表数据 +func (s *Hash) NameValues() (map[string]string, error) { + return s.client.HGetAll(s.key).Result() +} + +// Names 获取所有哈希表的 Name +func (s *Hash) Names() ([]string, error) { + return s.client.HKeys(s.key).Result() +} + +// Values 获取所有哈希表的 Total +func (s *Hash) Values() ([]string, error) { + return s.client.HVals(s.key).Result() +} + +// SetNX 当name不存在时才向哈希表中设置一对Name:Total +func (s *Hash) SetNX(name string, value interface{}) (bool, error) { + return s.client.HSetNX(s.key, name, value).Result() +} + +// Set 向哈希表中设置一对Name:Total +func (s *Hash) Set(name string, value interface{}) (bool, error) { + return s.client.HSet(s.key, name, value).Result() +} + +// Get 获取哈希表中Name对应的Value +func (s *Hash) Get(name string) (string, error) { + return s.client.HGet(s.key, name).Result() +} + +// GetResult 获取哈希表中Name对应的Value +func (s *Hash) GetResult(name string) *redis.StringCmd { + return s.client.HGet(s.key, name) +} + +// IncInt 返回累加之后的新值 +func (s *Hash) IncInt(name string, value int64) (int64, error) { + return s.client.HIncrBy(s.key, name, value).Result() +} + +// IncFloat 返回累加之后的新值 +func (s *Hash) IncFloat(name string, value float64) (float64, error) { + return s.client.HIncrByFloat(s.key, name, value).Result() +} + +// Len 返回哈希表的大小 +func (s *Hash) Len() (int64, error) { + return s.client.HLen(s.key).Result() +} + +// Scan 扫描哈希表数据 +func (s *Hash) Scan(match string, piece int64, callback func(name, value string)) error { + proc := func(l []string) { + for i := 0; i+1 < len(l); i += 2 { + callback(l[i], l[i+1]) + } + } + lst, cur, err := s.client.HScan(s.key, 0, match, piece).Result() + if err != nil { + return err + } + proc(lst) + for cur > 0 { + lst, cur, err = s.client.HScan(s.key, cur, match, piece).Result() + if err != nil { + return err + } + proc(lst) + } + return nil +} diff --git a/pub/redis/manager.go b/pub/redis/manager.go new file mode 100644 index 0000000..79b1d3e --- /dev/null +++ b/pub/redis/manager.go @@ -0,0 +1,177 @@ +package redis + +import ( + "fmt" + "regexp" + "sync" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + + "github.com/go-redis/redis" +) + +type SubscribeCallback func(channel RCType, message string) + +var ( + redisCli *redis.Client + opts Options + + //订阅参数 + pubsub *redis.PubSub + subCBMap map[RCType]SubscribeCallback + cbMapLock sync.RWMutex +) + +func Client() *redis.Client { + return redisCli +} + +func Disconnect() { + if redisCli != nil { + if pubsub != nil { + pubsub.Close() + } + redisCli.Close() + redisCli = nil + } +} + +func Init(opts Options) error { + c := redis.NewClient( + &redis.Options{ + Addr: opts.Addr, + Password: opts.Psw, + DB: opts.Db, + }, + ) + + if err := c.Ping().Err(); err != nil { + log.Error(fmt.Sprintf("[error] ping redis err %+v\n", err)) + return err + } + redisCli = c + + pubsub = nil + if opts.Subscribe { + subCBMap = make(map[RCType]SubscribeCallback) + pubsub = redisCli.Subscribe() + //接受发布消息 + go ReceiveMessage() + } + + log.Info("redis:Init Ok!") + return nil +} + +// OnMessage 订阅消息回调 +func OnMessage(channel RCType, message string) { + cbMapLock.RLock() + defer cbMapLock.RUnlock() + flag := false + for k, v := range subCBMap { + reg := regexp.MustCompile(string(k)) + //log.Info("test", log.Any("k", k), log.Any("channelchannel", channel)) + if b := reg.MatchString(string(channel)); b { + v(channel, message) + flag = true + } + } + if !flag { + log.Warn("(redis:OnMessage) callback doesn't exist!", log.Any("channel", channel)) + } +} + +// ReceiveMessage 接受发布消息 +func ReceiveMessage() { + for { + msg, err := pubsub.Receive() + if err != nil { + fmt.Println("test1", err) + return + } + + switch msg := msg.(type) { + case *redis.Subscription: + // Ignore. + log.Info("(redis:ReceiveMessage) subscribe success!", log.Any("channel", msg.Channel)) + case *redis.Pong: + // Ignore. + fmt.Println("Pong") + case *redis.Message: + channel := msg.Channel + message := msg.Payload + OnMessage(RCType(channel), message) + default: + log.Error("(redis:ReceiveMessage) msg!", log.Any("msg", msg)) + return + } + } +} + +// Subscribe 订阅一个消息 +func Subscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:Subscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.Subscribe(string(channel)) + if err != nil { + log.Warn("(redis:Subscribe) Subscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + subCBMap[channel] = cb + return nil +} + +// UnSubscribe 取消订阅一个消息 +func UnSubscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:UnSubscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.Unsubscribe(string(channel)) + if err != nil { + log.Warn("(redis:UnSubscribe) UnSubscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + delete(subCBMap, channel) + return nil +} + +// PSubscribe 模式匹配消息 +func PSubscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:PSubscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.PSubscribe(string(channel)) + if err != nil { + log.Warn("(redis:PSubscribe) PSubscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + subCBMap[channel] = cb + return nil +} + +// PUnsubscribe 模式取消匹配消息 +func PUnsubscribe(channel RCType, cb SubscribeCallback) *srvdef.SrvErr { + if pubsub == nil { + log.Warn("(redis:PUnsubscribe) subscribe is not enabled!") + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + err := pubsub.PUnsubscribe(string(channel)) + if err != nil { + log.Warn("(redis:PUnsubscribe) PUnsubscribe failed!", log.Any("err", err)) + return srvdef.New(srvdef.ErrRdsOperError, "", "") + } + cbMapLock.Lock() + defer cbMapLock.Unlock() + delete(subCBMap, channel) + return nil +} diff --git a/pub/redis/options.go b/pub/redis/options.go new file mode 100644 index 0000000..fed9708 --- /dev/null +++ b/pub/redis/options.go @@ -0,0 +1,9 @@ +package redis + +// Redis 配置项 +type Options struct { + Addr string `json:"addr"` // 地址 + Psw string `json:"psw"` // 密码 + Db int `json:"db"` // 库 + Subscribe bool `json:"subscribe"` // 是否有订阅功能 +} diff --git a/pub/redis/redis.go b/pub/redis/redis.go new file mode 100644 index 0000000..d504209 --- /dev/null +++ b/pub/redis/redis.go @@ -0,0 +1,275 @@ +package redis + +import ( + "errors" + "fmt" + "time" + sErr "videoweb/pub/common/errors" + "videoweb/pub/common/log" + + goredis "github.com/go-redis/redis" +) + +type ZMember = goredis.Z + +// Set 设置键值对 +func Set(key string, value interface{}, expireTime time.Duration) (err error) { + if err = redisCli.Set(key, value, expireTime).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// Incr 增加1 +func Incr(key string, expireTime time.Duration) (val int64, err error) { + val, err = redisCli.Incr(key).Result() + if err != nil { + return + } + duration := redisCli.TTL(key).Val() + //log.Info("Incr Test", log.Any("duration", duration)) + if duration.Seconds() == -1 { + redisCli.Expire(key, expireTime) + } + return +} + +func SAdd(key string, members ...interface{}) (err error) { + _, err = redisCli.SAdd(key, members...).Result() + return +} + +// 无序集合操作 随机删除集合中某一元素 +func SPop(key string, members ...interface{}) (string, error) { + val, err := redisCli.SPop(key).Result() + if err != nil { + log.Error(fmt.Sprintf("SPop failed %+v:", err)) + } + return val, err +} + +func SRem(key string, members ...interface{}) (err error) { + if err = redisCli.SRem(key, members...).Err(); err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// ScanSlice 返回集合成员 +func SScanSlice(key string, container interface{}) (err error) { + err = redisCli.SMembers(key).ScanSlice(container) + return err +} + +// SMembers +func SMembers(key string) (list []string, err error) { + result := redisCli.SMembers(key) + if result.Err() != nil { + err = sErr.MakeWithErr(sErr.RedisGet, result.Err()) + return + } + list = result.Val() + return +} + +// RPush +func RPush(key string, value string) error { + if key == "" { + return errors.New("key should not empty") + } + if err := redisCli.RPush(key, value).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisRPush, err) + } + return nil +} + +// RPush +func RPushValues(key string, value ...interface{}) error { + if key == "" { + return errors.New("key should not empty") + } + if err := redisCli.RPush(key, value...).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisRPush, err) + } + return nil +} + +// LPop +func LPop(key string) (*string, error) { + if key == "" { + return nil, errors.New("key should not empty") + } + + cmd := redisCli.LPop(key) + if cmd.Err() != nil { + return nil, sErr.MakeWithErr(sErr.RedisLPop, cmd.Err()) + } + + data, err := cmd.Result() + if err != nil { + if err == goredis.Nil { + return nil, nil + } + return nil, err + } + + return &data, nil +} + +// Get 根据键 获取值 +func Get(key string) (*string, error) { + if key == "" { + return nil, errors.New("key should not empty") + } + data, err := redisCli.Get(key).Result() + if err != nil { + if err == goredis.Nil { + return nil, nil + } + return nil, err + } else { + return &data, nil + } +} + +// Get 根据键 获取值 +func GetBytes(key string) (bytes []byte, err error) { + bytes, err = redisCli.Get(key).Bytes() + if err != nil { + return nil, sErr.MakeWithErr(sErr.RedisGet, err) + } + return +} + +// Del 删除 +func Del(keys ...string) (err error) { + if err = redisCli.Del(keys...).Err(); err != nil { + return sErr.MakeWithErr(sErr.RedisDel, err) + } + return +} + +// Exists keys exists? +func Exists(keys ...string) (has bool, err error) { + intcmd := redisCli.Exists(keys...) + if err = intcmd.Err(); err != nil { + return + } + has = intcmd.Val() > 0 + return +} + +func Keys(pattern string) (keys []string, err error) { + list := redisCli.Keys(pattern) + keys = list.Val() + err = list.Err() + return +} + +// Setnx 设置键值对 +func Setnx(key string, value interface{}, expirTime time.Duration) (err error) { + if err = redisCli.SetNX(key, value, expirTime).Err(); err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// Setnx 设置键值对 正确的封装 +func Setnx_NewOK(key string, value interface{}, expirTime time.Duration) (v bool, err error) { + cmd := redisCli.SetNX(key, value, expirTime) + return cmd.Val(), cmd.Err() +} + +func Expire(key string, expirTime time.Duration) (err error) { + if err = redisCli.Expire(key, expirTime).Err(); err != nil { + err = sErr.MakeWithErr(sErr.RedisSet, err) + } + return +} + +// Redis `SRANDMEMBER key count` command. +func SRandMemberN(key string, count int64) (list []string, err error) { + result := redisCli.SRandMemberN(key, count) + if result.Err() != nil { + return + } + list = result.Val() + return +} + +// 有序集合操作 添加多个有序集合 +func ZAdd(key string, z ...ZMember) (int64, error) { + count, err := redisCli.ZAdd(key, z...).Result() + if err != nil { + log.Error(fmt.Sprintf("ZAdd failed %+v:", err)) + } + return count, err +} + +// 有序集合 数量 +func ZCard(key string) (int64, error) { + count, err := redisCli.ZCard(key).Result() + if err != nil { + log.Error(fmt.Sprintf("ZCard failed %+v:", err)) + } + return count, err +} + +// 返回区间成员 +func ZRange(key string, start, end int64) ([]string, error) { + data, err := redisCli.ZRange(key, start, end).Result() + if err != nil { + log.Error(fmt.Sprintf("ZRange failed %+v:", err)) + } + return data, err +} + +// 按照元素排序 删除指定排名的元素 +func ZRemRangeByRank(key string, start, end int64) (int64, error) { + count, err := redisCli.ZRemRangeByRank(key, start, end).Result() + if err != nil { + log.Error(fmt.Sprintf("ZRemRangeByRank failed %+v:", err)) + } + return count, err +} + +// 按照元素排序 删除按指定的分数区间的的元素 +func ZRemRangeByScore(key, start, end string) (int64, error) { + count, err := redisCli.ZRemRangeByScore(key, start, end).Result() + if err != nil { + log.Error(fmt.Sprintf("ZRemRangeByScore failed %+v:", err)) + } + return count, err +} + +// Publish 发布消息 +func Publish(channel RCType, message string) error { + if err := redisCli.Publish(string(channel), message).Err(); err != nil { + log.Error(fmt.Sprintf("Publish failed %+v:", err)) + return err + } + return nil +} + +func SendMsgToTg(message string) error { + return Publish(RCT_tgmsg_send_msg, message) +} + +// SCard 发布消息 +func SCard(key string) (int64, error) { + count, err := redisCli.SCard(key).Result() + if err != nil { + log.Error(fmt.Sprintf("SCard failed %+v:", err)) + } + + return count, err +} + +// 获取key的剩余生存时间 +func TTL(key string) time.Duration { + durCmd := redisCli.TTL(key) + if durCmd != nil { + return durCmd.Val() + } + return 0 +} diff --git a/pub/redis/zset.go b/pub/redis/zset.go new file mode 100644 index 0000000..c3d1e7c --- /dev/null +++ b/pub/redis/zset.go @@ -0,0 +1,136 @@ +package redis + +import ( + "github.com/go-redis/redis" + "strconv" + "time" +) + +type Z = redis.Z + +type ZSet struct { + key string + client *redis.Client +} + +func NewZSet(client *redis.Client, key string) *ZSet { + return &ZSet{ + key: key, + client: client, + } +} + +func (s *ZSet) Key() string { + return s.key +} + +// Expire 设置集合过期时间 +func (s *ZSet) Expire(expiration time.Duration) (bool, error) { + return s.client.Expire(s.key, expiration).Result() +} + +// Add 添加一个或多个成员到有序集合 +func (s *ZSet) Add(members ...Z) (int64, error) { + return s.client.ZAdd(s.key, members...).Result() +} + +// Count 返回有序集合在指定分数范围内的成员数量 +func (s *ZSet) Count(min, max float64) (int64, error) { + return s.client.ZCount(s.key, strconv.FormatFloat(min, 'f', -1, 64), strconv.FormatFloat(max, 'f', -1, 64)).Result() +} + +// Score 获取成员的分数 +func (s *ZSet) Score(member string) (float64, error) { + return s.client.ZScore(s.key, member).Result() +} + +// Rank 获取成员的排名(从小到大,0开始) +func (s *ZSet) Rank(member string) (int64, error) { + return s.client.ZRank(s.key, member).Result() +} + +// RevRank 获取成员的排名(从大到小,0开始) +func (s *ZSet) RevRank(member string) (int64, error) { + return s.client.ZRevRank(s.key, member).Result() +} + +// Range 返回指定排名范围的成员 +func (s *ZSet) Range(start, stop int64) ([]string, error) { + return s.client.ZRange(s.key, start, stop).Result() +} + +// RangeWithScores 返回指定排名范围的成员和分数 +func (s *ZSet) RangeWithScores(start, stop int64) ([]Z, error) { + return s.client.ZRangeWithScores(s.key, start, stop).Result() +} + +// RangeByScore 返回指定分数范围的成员 +func (s *ZSet) RangeByScore(min, max float64) ([]string, error) { + opt := redis.ZRangeBy{ + Min: strconv.FormatFloat(min, 'f', -1, 64), + Max: strconv.FormatFloat(max, 'f', -1, 64), + } + return s.client.ZRangeByScore(s.key, opt).Result() +} + +// RangeByScoreWithScores 返回指定分数范围的成员和分数 +func (s *ZSet) RangeByScoreWithScores(min, max float64) ([]Z, error) { + opt := redis.ZRangeBy{ + Min: strconv.FormatFloat(min, 'f', -1, 64), + Max: strconv.FormatFloat(max, 'f', -1, 64), + } + return s.client.ZRangeByScoreWithScores(s.key, opt).Result() +} + +// Remove 删除一个或多个成员 +func (s *ZSet) Remove(members ...string) (int64, error) { + return s.client.ZRem(s.key, members).Result() +} + +// RemoveRangeByRank 删除指定排名范围的成员 +func (s *ZSet) RemoveRangeByRank(start, stop int64) (int64, error) { + return s.client.ZRemRangeByRank(s.key, start, stop).Result() +} + +// RemoveRangeByScore 删除指定分数范围的成员 +func (s *ZSet) RemoveRangeByScore(min, max float64) (int64, error) { + return s.client.ZRemRangeByScore(s.key, strconv.FormatFloat(min, 'f', -1, 64), strconv.FormatFloat(max, 'f', -1, 64)).Result() +} + +// Len 返回有序集合的成员数 +func (s *ZSet) Len() (int64, error) { + return s.client.ZCard(s.key).Result() +} + +// IncScore 为成员的分数加上增量 +func (s *ZSet) IncScore(member string, increment float64) (float64, error) { + return s.client.ZIncrBy(s.key, increment, member).Result() +} + +// Scan 扫描有序集合 +func (s *ZSet) Scan(match string, count int64, callback func(member string, score float64) error) error { + cursor := uint64(0) + for { + keys, newCursor, err := s.client.ZScan(s.key, cursor, match, count).Result() + if err != nil { + return err + } + + for i := 0; i < len(keys); i += 2 { + score, err := strconv.ParseFloat(keys[i+1], 64) + if err != nil { + return err + } + err = callback(keys[i], score) + if err != nil { + return err + } + } + + if newCursor == 0 { + break + } + cursor = newCursor + } + return nil +} diff --git a/pub/seo/googleindex/googleindex.go b/pub/seo/googleindex/googleindex.go new file mode 100644 index 0000000..49cf609 --- /dev/null +++ b/pub/seo/googleindex/googleindex.go @@ -0,0 +1,187 @@ +// Package googleindex 对接 Google Indexing API,在文章发布/更新/删除时推送具体页面 URL。 +// 需要在 Google Cloud Console 创建服务账号并开启 Indexing API, +// 同时在 Search Console 中把该服务账号邮箱添加为站点「所有者」。 +package googleindex + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + + "google.golang.org/api/googleapi" + "google.golang.org/api/indexing/v3" + "google.golang.org/api/option" +) + +// 推送动作类型 +const ( + TypeUpdated = "URL_UPDATED" // 新增或更新 + TypeDeleted = "URL_DELETED" // 已删除 +) + +// defaultDailyQuota Google Indexing API 默认每天 200 个 URL 请求 +const defaultDailyQuota = int64(200) + +// ErrQuotaExceeded 配额已用尽,可能来自本地计数拦截,也可能是 Google 返回的 429。 +// 两者都只代表「现在不能推」,调用方应保留任务等下次,而不是计为失败重试。 +var ErrQuotaExceeded = errors.New("googleindex: daily quota exceeded") + +// ErrNotEnabled 未启用或未初始化 +var ErrNotEnabled = errors.New("googleindex: not enabled") + +// Client Indexing API 客户端,内置每日配额保护 +type Client struct { + svc *indexing.Service + quota *quotaGuard +} + +var ( + defaultClient *Client + defaultMu sync.RWMutex +) + +// Init 初始化全局客户端,未启用时不报错,仅记录日志 +func Init(ctx context.Context) { + client, err := NewClient(ctx) + if err != nil { + log.Error("googleindex: init failed", log.E(err)) + return + } + defaultMu.Lock() + defaultClient = client + defaultMu.Unlock() + log.Info("googleindex: ready", log.Any("dailyQuota", client.quota.limit)) +} + +// NewClient 依据配置创建客户端 +func NewClient(ctx context.Context) (*Client, error) { + credentials, err := loadCredentials() + if err != nil { + return nil, err + } + svc, err := indexing.NewService(ctx, option.WithCredentialsJSON(credentials)) + if err != nil { + return nil, fmt.Errorf("create indexing service failed: %w", err) + } + limit := defaultDailyQuota + return &Client{svc: svc, quota: "aGuard{limit: limit}}, nil +} + +func loadCredentials() ([]byte, error) { + credentialsFile := "composed-anvil-470512-r8-fd674ac0d7dc.json" + b, err := os.ReadFile(credentialsFile) + if err != nil { + return nil, fmt.Errorf("read credentials file %s failed: %w", credentialsFile, err) + } + return b, nil +} + +// Publish 使用全局客户端推送单个 URL +func Publish(ctx context.Context, pageURL, notifyType string) (*indexing.PublishUrlNotificationResponse, error) { + defaultMu.RLock() + client := defaultClient + defaultMu.RUnlock() + if client == nil { + return nil, ErrNotEnabled + } + return client.Publish(ctx, pageURL, notifyType) +} + +// Publish 推送单个 URL,notifyType 取 TypeUpdated / TypeDeleted +func (c *Client) Publish(ctx context.Context, pageURL, notifyType string) (*indexing.PublishUrlNotificationResponse, error) { + if strings.TrimSpace(pageURL) == "" { + return nil, errors.New("googleindex: empty url") + } + if notifyType != TypeUpdated && notifyType != TypeDeleted { + return nil, fmt.Errorf("googleindex: invalid notify type %s", notifyType) + } + if !c.quota.acquire() { + return nil, ErrQuotaExceeded + } + + resp, err := c.svc.UrlNotifications.Publish(&indexing.UrlNotification{ + Url: pageURL, + Type: notifyType, + }).Context(ctx).Do() + if err != nil { + // Google 已明确告知配额用尽,说明本地计数比实际用量宽松: + // 本地日界按服务器时区计算、且计数在进程重启后归零,都可能早于 Google 放行。 + // 此时把本地计数拉满,避免后续请求继续无效地打过去。 + if isQuotaError(err) { + c.quota.exhaust() + return resp, fmt.Errorf("%w: %v", ErrQuotaExceeded, err) + } + // 请求已消耗配额但失败,退回本地计数,避免本地计数与实际用量偏差过大 + c.quota.release() + return resp, err + } + return resp, nil +} + +// isQuotaError 判断错误是否为配额或频率限制。 +// 这类错误只代表「现在不能推」,调用方应保留任务等下次,而不是计为失败重试。 +// 注意 403 还用于站点所有权不足等需要人工介入的场景,因此只在 reason 明确是配额时才算。 +func isQuotaError(err error) bool { + var gerr *googleapi.Error + if !errors.As(err, &gerr) { + return false + } + if gerr.Code == http.StatusTooManyRequests { + return true + } + for _, item := range gerr.Errors { + switch item.Reason { + case "rateLimitExceeded", "quotaExceeded", "userRateLimitExceeded": + return true + } + } + return false +} + +// quotaGuard 按自然日计数的配额保护。 +// 计数仅存于内存,进程重启会归零;真正超配额时 Google 会返回 429,由调用方按失败重试处理。 +type quotaGuard struct { + mu sync.Mutex + limit int64 + day string + used int64 +} + +func (q *quotaGuard) acquire() bool { + q.mu.Lock() + defer q.mu.Unlock() + today := time.Now().Format("2006-01-02") + if q.day != today { + q.day = today + q.used = 0 + } + if q.used >= q.limit { + return false + } + q.used++ + return true +} + +func (q *quotaGuard) release() { + q.mu.Lock() + defer q.mu.Unlock() + if q.used > 0 { + q.used-- + } +} + +// exhaust 把当天计数置满,用于 Google 已返回配额超限时同步本地状态。 +// 代价是本地要等到下一个自然日才放行,可能晚于 Google 实际的配额重置时刻; +// 换来的是不再持续发送必定失败的请求。 +func (q *quotaGuard) exhaust() { + q.mu.Lock() + defer q.mu.Unlock() + q.day = time.Now().Format("2006-01-02") + q.used = q.limit +} diff --git a/pub/seo/indexnow/submitter.go b/pub/seo/indexnow/submitter.go new file mode 100644 index 0000000..1804fe6 --- /dev/null +++ b/pub/seo/indexnow/submitter.go @@ -0,0 +1,246 @@ +// Package indexnow 实现 IndexNow 协议提交,推送到官方入口后由 Bing / Yandex 等共享数据。 +// 调用方是 web 进程的 seopush,在文章发布 / 更新 / 删除时推送对应页面地址。 +package indexnow + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + "videoweb/pub/common/log" +) + +// DefaultKey IndexNow 默认密钥,与站点根目录下的 {key}.txt 验证文件对应 +const DefaultKey = "6d41643810d540ce924edb982c3d3171" + +// DefaultSearchEngine 官方入口,会自动分发给 Bing、Yandex 等搜索引擎 +const DefaultSearchEngine = "https://api.indexnow.org" + +// IndexNowRequest 提交请求结构体 +type IndexNowRequest struct { + Host string `json:"host"` + Key string `json:"key"` + KeyLocation string `json:"keyLocation,omitempty"` + URLList []string `json:"urlList"` +} + +// IndexNowConfig 配置结构体 +type IndexNowConfig struct { + Key string + Host string + KeyLocation string + SearchEngines []string + RetryCount int + RetryDelay time.Duration + // BatchInterval 多批次之间的间隔,避免触发频率限制;单批提交不生效 + BatchInterval time.Duration +} + +// BuildConfig 依据站点地址推导 IndexNow 配置 +func BuildConfig(webAddr string) (*IndexNowConfig, error) { + host, keyLocation, err := hostFromWebAddr(webAddr, DefaultKey) + if err != nil { + return nil, err + } + return &IndexNowConfig{ + Key: DefaultKey, + Host: host, + KeyLocation: keyLocation, + SearchEngines: []string{DefaultSearchEngine}, + RetryCount: 3, + RetryDelay: 2 * time.Second, + BatchInterval: 60 * time.Second, + }, nil +} + +func hostFromWebAddr(webAddr, key string) (host, keyLocation string, err error) { + normalized := webAddr + if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") { + normalized = "https://" + normalized + } + u, parseErr := url.Parse(normalized) + if parseErr != nil { + return "", "", parseErr + } + host = u.Hostname() + if host == "" { + return "", "", fmt.Errorf("invalid webAddr: %s", webAddr) + } + base := strings.TrimSuffix(normalized, "/") + keyLocation = fmt.Sprintf("%s/%s.txt", base, key) + return host, keyLocation, nil +} + +// BatchIndexNowSubmitter 批量提交器 +type BatchIndexNowSubmitter struct { + config *IndexNowConfig + client *http.Client +} + +// NewBatchIndexNowSubmitter 创建新的批量提交器 +func NewBatchIndexNowSubmitter(config *IndexNowConfig) *BatchIndexNowSubmitter { + if config.RetryCount == 0 { + config.RetryCount = 3 + } + if config.RetryDelay == 0 { + config.RetryDelay = 1 * time.Second + } + if config.BatchInterval == 0 { + config.BatchInterval = 60 * time.Second + } + + return &BatchIndexNowSubmitter{ + config: config, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// SubmitBatch 批量提交URL,返回成功提交的 URL 列表 +func (s *BatchIndexNowSubmitter) SubmitBatch(urls []string) *BatchResult { + result := &BatchResult{ + TotalURLs: len(urls), + SuccessCount: 0, + FailCount: 0, + Errors: make([]string, 0), + SuccessURLs: make([]string, 0), + StatusCodes: make([]int, 0), + } + + if len(urls) == 0 { + return result + } + + // 分批处理,每批最多100个URL(避免单次请求过大) + const batchSize = 100 + batches := s.splitIntoBatches(urls, batchSize) + for batchIndex, batchURLs := range batches { + codes, err := s.submitWithRetry(batchURLs) + result.StatusCodes = append(result.StatusCodes, codes...) + if err != nil { + result.FailCount += len(batchURLs) + result.Errors = append(result.Errors, + fmt.Sprintf("批次 %d 提交失败: %v", batchIndex+1, err)) + } else { + result.SuccessCount += len(batchURLs) + result.SuccessURLs = append(result.SuccessURLs, batchURLs...) + } + if batchIndex < len(batches)-1 { + time.Sleep(s.config.BatchInterval) + } + } + return result +} + +// submitWithRetry 带重试的提交,返回各引擎 HTTP 状态码 +func (s *BatchIndexNowSubmitter) submitWithRetry(urls []string) (statusCodes []int, err error) { + var lastError error + var lastCodes []int + + for attempt := 1; attempt <= s.config.RetryCount; attempt++ { + codes, submitErr := s.submitToAllEngines(urls) + lastCodes = codes + if submitErr != nil { + lastError = submitErr + if attempt < s.config.RetryCount { + log.Warn("indexnow: submit retry", + log.Any("attempt", attempt), + log.Any("delay", s.config.RetryDelay), + log.Any("statusCodes", codes), + log.E(submitErr)) + time.Sleep(s.config.RetryDelay) + continue + } + } else { + return codes, nil + } + } + + return lastCodes, fmt.Errorf("经过 %d 次重试后仍然失败: %v", s.config.RetryCount, lastError) +} + +// submitToAllEngines 向所有搜索引擎提交,返回各引擎 HTTP 状态码 +func (s *BatchIndexNowSubmitter) submitToAllEngines(urls []string) (statusCodes []int, err error) { + requestBody := IndexNowRequest{ + Host: s.config.Host, + Key: s.config.Key, + KeyLocation: s.config.KeyLocation, + URLList: urls, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("JSON编码失败: %v", err) + } + + var errors []string + statusCodes = make([]int, 0, len(s.config.SearchEngines)) + for _, engine := range s.config.SearchEngines { + endpoint := fmt.Sprintf("%s/indexnow", engine) + + req, reqErr := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) + if reqErr != nil { + errors = append(errors, fmt.Sprintf("创建请求失败: %v", reqErr)) + continue + } + req.Header.Set("Content-Type", "application/json") + + resp, doErr := s.client.Do(req) + if doErr != nil { + errors = append(errors, fmt.Sprintf("请求 %s 失败: %v", engine, doErr)) + continue + } + + statusCodes = append(statusCodes, resp.StatusCode) + log.Info("indexnow: api response", + log.Any("engine", engine), + log.Any("statusCode", resp.StatusCode), + log.Any("urlCount", len(urls))) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + errors = append(errors, fmt.Sprintf("搜索引擎 %s 返回错误状态码: %d", engine, resp.StatusCode)) + } + resp.Body.Close() + } + + if len(errors) > 0 { + return statusCodes, fmt.Errorf("%v", errors) + } + + return statusCodes, nil +} + +// splitIntoBatches 将URL列表分割成多个批次 +func (s *BatchIndexNowSubmitter) splitIntoBatches(urls []string, batchSize int) [][]string { + var batches [][]string + + for i := 0; i < len(urls); i += batchSize { + end := i + batchSize + if end > len(urls) { + end = len(urls) + } + batches = append(batches, urls[i:end]) + } + + return batches +} + +// BatchResult 批量提交结果 +type BatchResult struct { + TotalURLs int + SuccessCount int + FailCount int + Errors []string + SuccessURLs []string + StatusCodes []int // 各引擎 HTTP 状态码(按 SearchEngines 顺序,含重试后最后一次) +} + +// String 结果字符串表示 +func (r *BatchResult) String() string { + return fmt.Sprintf("提交完成: 总数=%d, 成功=%d, 失败=%d, 错误数=%d, statusCodes=%v", + r.TotalURLs, r.SuccessCount, r.FailCount, len(r.Errors), r.StatusCodes) +} diff --git a/pub/seo/sitemapdirty/dirty.go b/pub/seo/sitemapdirty/dirty.go new file mode 100644 index 0000000..b90e6f5 --- /dev/null +++ b/pub/seo/sitemapdirty/dirty.go @@ -0,0 +1,44 @@ +// Package sitemapdirty 在 web 与 app 之间传递「站点内容已变更」的标记。 +// web 进程负责文章的发布/更新/删除,而 sitemap 文件由 app 进程生成并对外提供, +// 两者独立部署且不互相调用,因此借数据库中的一条记录做跨进程通知。 +package sitemapdirty + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/syncfilemod" + + "go.mongodb.org/mongo-driver/bson" +) + +func recordFilter() bson.M { + return bson.M{syncfilemod.FN.SyncRecord.Job: syncfilemod.SitemapDirtyJob} +} + +// Mark 标记内容已变更,app 侧会在下一个检查周期重新生成 sitemap +func Mark(ctx context.Context) { + now := time.Now() + update := bson.M{ + "$set": bson.M{ + syncfilemod.FN.SyncRecord.RecordTime: now, + syncfilemod.FN.SyncRecord.UpdatedAt: now, + }, + "$setOnInsert": bson.M{ + syncfilemod.FN.SyncRecord.Job: syncfilemod.SitemapDirtyJob, + syncfilemod.FN.SyncRecord.CreatedAt: now, + }, + } + if serr := syncfilemod.UpsertOneSyncRecord(ctx, recordFilter(), update); serr != nil { + log.Warn("sitemapdirty: mark failed", log.E(serr)) + } +} + +// LastDirtyAt 读取最近一次内容变更时间,无记录时返回零值 +func LastDirtyAt(ctx context.Context) time.Time { + record, serr := syncfilemod.FindOneSyncRecord(ctx, recordFilter()) + if serr != nil || record == nil { + return time.Time{} + } + return record.RecordTime +} diff --git a/pub/storage/proxy/object.go b/pub/storage/proxy/object.go new file mode 100644 index 0000000..d56c3c4 --- /dev/null +++ b/pub/storage/proxy/object.go @@ -0,0 +1,532 @@ +package proxy + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "videoweb/pub/common/http" + "videoweb/pub/common/log" +) + +const ( + apiDomain = "http://resourcets.bbaazz.me/file/upload" +) + +var ( + header = map[string]string{"Content-Type": "application/json"} +) + +const ( + Success = 200 +) + +func UploadImg(fileName string, data []byte, domain string) (pathStr string, err error) { + r := objreq{ + Name: fileName, + Data: base64.StdEncoding.EncodeToString(data), + } + jsonbytes, err := json.Marshal(r) + if err != nil { + return + } + url := ImageUploadAddressRepair(domain, BucketImageAddressV2) + + resp, err := http.Post(url, header, bytes.NewReader(jsonbytes)) + if err != nil { + return + } + + var or objresp + if err = json.Unmarshal(resp.Data, &or); err != nil { + return + } + if or.Code != Success { + err = fmt.Errorf("上传失败:%s", or.Msg) + return + } + pathStr = or.Object.Name + return +} + +func UploadBase64Img(fileName string, data string, domain string) (pathStr string, err error) { + r := objreq{ + Name: fileName, + Data: data, + } + jsonbytes, err := json.Marshal(r) + if err != nil { + return + } + if len(domain) == 0 { + domain = apiDomain + } + url := domain + if !strings.Contains(domain, "/file/upload") { + url = domain + "/file/upload" + } + resp, err := http.Post(url, header, bytes.NewReader(jsonbytes)) + if err != nil { + return + } + + var or objresp + if err = json.Unmarshal(resp.Data, &or); err != nil { + return + } + if or.Code != Success { + err = fmt.Errorf("上传失败:%s", or.Msg) + return + } + pathStr = or.Object.Name + if strings.Contains(domain, "resourceimg.bbaazz.me") && + !strings.HasPrefix(pathStr, "v2/") { + pathStr = "v2/" + pathStr + } + return +} + +func fileServer() (url string) { + return "http://resourcets.bbaazz.me" +} + +func PostOk(baseInfo *ShardOk, domain string) (result *VideoOkResult, err error) { + result = &VideoOkResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + url := VideoOkAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(baseInfo) + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + fmt.Println(string(fr.Data)) + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(result) + return nil, err + } + fmt.Println(result) + return +} +func PostOkV2(baseInfo *ShardOk, domain string) (result *VideoOkResult, err error) { + result = &VideoOkResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + url := VideoOkAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(paramJson) + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(result) + return nil, err + } + fmt.Println(result) + return +} +func QueryMedia(baseInfo *MediaQueryeReq, domain string) (result *VideoQueryResult, err error) { + result = &VideoQueryResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/query") { + url = domain + "/file/web/media/query" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + + return +} + +func QueryAdvitiseLocation(baseInfo *AppAdvitiseInfoReq, domain string) (result *AdvitiseLocationQuerysResult, err error) { + result = &AdvitiseLocationQuerysResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/app/advitise/loca") { + url = domain + "/media/web/app/advitise/loca" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + return +} +func QueryMedias(baseInfo *MediaQueryesReq, domain string) (result *VideoQuerysResult, err error) { + result = &VideoQuerysResult{ + Data: make([]MediaResp, 0), + } + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/task/querys") { + url = domain + "/media/web/task/querys" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + return +} +func SyncMedia(baseInfo *MediaTaskSyncReq, domain string) (result *VideoQuerysResult, err error) { + result = &VideoQuerysResult{} + paramJson, err := json.Marshal(baseInfo) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/task/sync") { + url = domain + "/media/web/task/sync" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + fmt.Println(err) + return nil, err + } + + return +} + +func UploadVideo(domain string, body *ShardBody) (result *Result, err error) { + if body == nil { + return nil, errors.New("no video to upload") + } + url := VideoBodyAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(url) + + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := http.Post(url, header, bytes.NewBuffer(buf)) + if err != nil { + return nil, err + } + fmt.Println("resp-->", string(resp.Data)) + result = new(Result) + if err = json.Unmarshal(resp.Data, result); err != nil { + return nil, err + } + return +} + +func UploadVideoV2(domain string, body *ShardBodyV2) (result *Result, err error) { + if body == nil { + return nil, errors.New("no video to upload") + } + url := VideoBodyAddressRepair(domain, BucketVideoAddressV2) + fmt.Println(url) + + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := http.Post(url, header, bytes.NewBuffer(buf)) + if err != nil { + return nil, err + } + fmt.Println("resp-->", string(resp.Data)) + result = new(Result) + if err = json.Unmarshal(resp.Data, result); err != nil { + return nil, err + } + return +} + +func PostSearch(param *MediaSearchReq, domain string) (result *MediaSearchResult, err error) { + result = &MediaSearchResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/search") { + url = domain + "/file/web/media/search" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + //log.Info("proxy:test", log.Any("url", url), log.Any("result", result)) + return +} + +func PostUpdate(param *MediaUpdateReq, domain string) (result *MediaUpdateResult, err error) { + result = &MediaUpdateResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/update") { + url = domain + "/file/web/media/update" + } + fr, perr := http.Post(url, header, bytes.NewReader(paramJson)) + if perr != nil { + log.Error("PostUpdate Unmarshal", log.E(perr)) + err = perr + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + log.Error("PostUpdate Unmarshal", log.E(err), log.Any("data", string(fr.Data))) + return nil, err + } + return +} + +func PostDelete(param *MediaDelReq, domain string) (result *MediaDelResult, err error) { + result = &MediaDelResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/delete") { + url = domain + "/file/web/media/delete" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +func PostUpdatePreTime(param *MediaUpdatePreTime, domain string) (result *MediaDelResult, err error) { + result = &MediaDelResult{} + paramJson, err := json.Marshal(param) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/file/web/media/update/prefile") { + url = domain + "/file/web/media/update/prefile" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +func UploadVideoV2New(domain string, body *ShardBodyV2) (result *Result, err error) { + if body == nil { + return nil, errors.New("no video to upload") + } + if len(domain) == 0 { + domain = fileServer() + } + url := domain + if !strings.Contains(domain, "/file/web/proxy/videoBodyV2") { + url = domain + "/file/web/proxy/videoBodyV2" + } + fmt.Println(url) + + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := http.Post(url, header, bytes.NewBuffer(buf)) + if err != nil { + return nil, err + } + fmt.Println("resp-->", string(resp.Data)) + result = new(Result) + if err = json.Unmarshal(resp.Data, result); err != nil { + return nil, err + } + // if strings.Contains(domain, "resourcets.bbaazz.me") && !strings.HasPrefix(result.Data.VideoUri, "v2/") { + // result.Data.VideoUri = "v2/" + result.Data.VideoUri + // } + fmt.Println("result-->", result) + if result.Code != 200 { + err = fmt.Errorf("result code err") + } + return +} + +// SyncMediaFile 将相对路径 m3u8 登记到 media 切片服 +func SyncMediaFile(req *MediaFileSyncReq, domain string) (result *MediaAPIResult, err error) { + result = &MediaAPIResult{} + paramJson, err := json.Marshal(req) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/mediafile/sync") { + url = domain + "/media/web/mediafile/sync" + } + fr, err := http.Post(url, header, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +// AddMediaTask 提交 media 加水印切片任务 +func AddMediaTask(req *MediaTaskAddReq, domain string) (result *MediaAPIResult, err error) { + result = &MediaAPIResult{} + paramJson, err := json.Marshal(req) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/task/add") { + url = domain + "/media/web/task/add" + } + reqHeader := map[string]string{ + "Content-Type": "application/json", + "acc-id": "1", + "acc-name": "playlet-watermark", + "app-id": fmt.Sprintf("%d", req.AppId), + } + fr, err := http.Post(url, reqHeader, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +// UpdateMediaWatermark 按已有 media 任务重新加水印切片 +func UpdateMediaWatermark(req *MediaTaskUpdateWatermarkReq, domain string) (result *MediaAPIResult, err error) { + result = &MediaAPIResult{} + paramJson, err := json.Marshal(req) + if err != nil { + return + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/task/update/watermark") { + url = domain + "/media/web/task/update/watermark" + } + reqHeader := map[string]string{ + "Content-Type": "application/json", + "acc-id": "1", + "acc-name": "playlet-watermark", + "app-id": fmt.Sprintf("%d", req.AppId), + } + fr, err := http.Post(url, reqHeader, bytes.NewReader(paramJson)) + if err != nil { + return + } + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + return +} + +// QueryMediaSource 按原片 ID 查询 media source 状态 +func QueryMediaSource(sourceID string, appId int64, domain string) (*MediaSourceListItem, error) { + req := MediaSourceListReq{ + Id: sourceID, + AppId: appId, + WebAppId: appId, + PageNum: 1, + PageSize: 1, + } + paramJSON, err := json.Marshal(req) + if err != nil { + return nil, err + } + if len(domain) == 0 { + domain = DomainAddressRepair(domain, BucketVideoAddressV2) + } + url := domain + if !strings.Contains(domain, "/media/web/source/list") { + url = domain + "/media/web/source/list" + } + reqHeader := map[string]string{ + "Content-Type": "application/json", + "acc-id": "1", + "acc-name": "playlet-watermark", + "app-id": fmt.Sprintf("%d", appId), + } + fr, err := http.Post(url, reqHeader, bytes.NewReader(paramJSON)) + if err != nil { + return nil, err + } + result := &MediaSourceListResult{} + if err = json.Unmarshal(fr.Data, result); err != nil { + return nil, err + } + if result.Code != Success || len(result.Data.List) == 0 { + return nil, nil + } + return &result.Data.List[0], nil +} diff --git a/pub/storage/proxy/object_test.go b/pub/storage/proxy/object_test.go new file mode 100644 index 0000000..943b369 --- /dev/null +++ b/pub/storage/proxy/object_test.go @@ -0,0 +1 @@ +package proxy diff --git a/pub/storage/proxy/test.html b/pub/storage/proxy/test.html new file mode 100644 index 0000000..a7de28b --- /dev/null +++ b/pub/storage/proxy/test.html @@ -0,0 +1,3 @@ +
+

51吃瓜国内最新地址 https://51cga33.com

51吃瓜海外永久地址 https://51cg1.com

播放异常? 请刷新 或 使用51吃瓜App

苹果App白屏 请等待1分钟/切换网络/重新下载

发邮件获取 最新网址 👇长按复制保存👇
51cgfun@pm.me

曲阜师范大学学生【何鑫】的私密自拍视频流出,尺度炸裂,令人瞠目!这位清纯外表的学妹私下化身小母狗,为完成神秘“主人”的任务大秀骚浪本性!视频中,她身着宽松裸体背带裤,半遮半掩的布料勾勒出白嫩胴体,饱满小胸和粉嫩美穴若隐若现。

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 1

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 2

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 3

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 4

何鑫裸体自拍玩穴

她站在宿舍镜前,挑逗地拉下背带裤,露出湿漉漉的私处,手指大胆深入玩穴,湿滑水声混杂娇喘,镜头特写捕捉到她揉搓嫩穴的每一个细节,令人血脉喷张!

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 5

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 6

曲阜师范大学05年小母狗何鑫裸体自拍玩穴 7

何鑫足交伺候主人

何鑫还完成足交任务,赤裸坐在床边,用白嫩玉足轻抚主人粗大肉棒,脚趾灵活挑逗,油亮光泽的脚掌摩擦到主人爽到低吼!

关键词:#曲阜师范大学何鑫 #何鑫裸体自拍 #何鑫对镜玩穴自慰 #何鑫足交伺候主人 #何鑫不雅自拍视频 #曲阜师范大学 #何鑫 #自慰 #足交 #51吃瓜 #51吃瓜网 #黑料 #吃瓜

想看更多精彩完整版,下载51吃瓜APP一爽到底~

51吃瓜APP下载地址:
最新地址 https://url46.co/1222
安卓下载备用地址一: https://url46.co/1237
安卓下载备用地址二: https://url46.co/1238
iOS下载备用地址 https://url46.co/1239

+ \ No newline at end of file diff --git a/pub/storage/proxy/types.go b/pub/storage/proxy/types.go new file mode 100644 index 0000000..6c78ce0 --- /dev/null +++ b/pub/storage/proxy/types.go @@ -0,0 +1,537 @@ +package proxy + +import ( + "strings" + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type objreq struct { + Name string `json:"fileName"` + Data string `json:"fileData"` +} + +type Obj struct { + Domain string `json:"domain"` + Name string `json:"fileName"` +} + +type objresp struct { + Code int `json:"code"` + Object Obj `json:"data"` + Msg string `json:"msg"` +} + +type ShardBody struct { + TaskId string `json:"taskId,omitempty"` // 文档md5 + Pos int `json:"pos,omitempty"` // 位置 + FileData string `json:"fileData,omitempty"` // 数据 + Type string `json:"type,omitempty"` + TotalPos int `json:"totalPos,omitempty"` // 分片总数 +} + +type ShardBodyV2 struct { + TaskId string `json:"taskId,omitempty"` // 文档md5 + Pos int `json:"pos,omitempty"` // 位置 + FileData string `json:"fileData,omitempty"` // 数据 + Type string `json:"type,omitempty"` + TotalPos int `json:"totalPos,omitempty"` + FileName string `json:"fileName,omitempty"` + Publish string `json:"publish,omitempty"` + AppId int `json:"appId,omitempty"` + MP4Folder string `json:"mp4Folder"` // 保存mp4的文件夹名,为空表示不需要保存mp4 +} + +type Data struct { + Id string `json:"id,omitempty"` + VideoUri string `json:"videoUri,omitempty"` +} + +type Result struct { + Code int `json:"code,omitempty"` // + Data Data `json:"data,omitempty"` // + Msg string `json:"msg,omitempty"` // +} + +type VideoOkResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data string `json:"data,omitempty"` +} +type ShardOk struct { + TaskId string `json:"taskId,omitempty"` // 文档md5 + Id string `json:"id,omitempty"` // Id + Title *string `json:"title,omitempty"` // 标题 + CoverImg *string `json:"coverImg,omitempty"` // 封面 +} + +type MediaTaskSyncReq struct { + AppId int64 `bson:"appId" json:"appId"` // APPID + LastUpdateTime time.Time `bson:"lastUpdateTime" json:"lastUpdateTime"` // 最后更新时间 + PageSize int64 `bson:"pageSize" json:"pageSize"` +} + +type MediaQueryeReq struct { + Id string `json:"id"` + TaskId string `json:"taskId"` +} +type MediaQueryesReq struct { + Ids []string `json:"ids,omitempty"` + MediaSourceIds []string `json:"mediaSourceIds,omitempty"` + AppId int64 `json:"appId,omitempty"` +} +type VideoQueryResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaResp `json:"data,omitempty"` +} +type VideoQuerysResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data []MediaResp `json:"data,omitempty"` +} +type MediaSyncReq struct { + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` + Param *SyncParam `json:"param"` +} +type SyncParam struct { + Status string `bson:"status,omitempty" json:"status,omitempty"` // 当前状态 + FirstTag string `json:"firstTag,omitempty"` // 一级标签 + Publish string `json:"publish,omitempty"` // 视频发布者 +} +type VideoSyncResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaSearchResp `json:"data,omitempty"` +} + +// type MediaSearchResp struct { +// Total int64 `json:"total"` +// List []*MediaSync `json:"list"` +// } + +type MediaResp struct { + Id primitive.ObjectID `bson:"_id,omitempty" json:"id"` + MediaSourceId primitive.ObjectID `bson:"mediaSourceId,omitempty" json:"mediaSourceId"` // 原始Id + VideoTime int32 `bson:"videoTime,omitempty" json:"videoTime"` // 影片长度 + Size int64 `bson:"size,omitempty" json:"size"` // 文件size,单位byte + AppId int64 `bson:"appId,omitempty" json:"appId"` // 应用列表 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + VideoCover string `bson:"videoCover,omitempty" json:"videoCover"` // 视频封面 + Title string `bson:"title,omitempty" json:"title"` // 标题 + Publish string `bson:"publish,omitempty" json:"publish"` // 视频发布者 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + + Nodes []videoinformationmod.Node `bson:"nodes" json:"nodes"` // 节点 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + Status string `bson:"status,omitempty" json:"status"` // 当前状态 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + TaskStartTime time.Time `bson:"taskStartTime,omitempty" json:"taskStartTime"` // 任务开始时间 + SourceNameFs string `bson:"sourceNameFs,omitempty" json:"sourceNameFs"` // 远程源文件 + IsDelLocalDir bool `bson:"isDelLocalDir,omitempty" json:"isDelLocalDir"` // 是否删除本机文件 + WatermarkImage string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件 + WatermarkMedia string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件 + DownloadRetry int `bson:"downloadRetry,omitempty" json:"downloadRetry"` // 下载重试次数 + WatermarkRetry int `bson:"watermarkRetry" json:"watermarkRetry"` // 水印重试次数 + CompressRetry int `bson:"compressRetry" json:"compressRetry"` // 压缩重试次数 + SegmentRetry int `bson:"segmentRetry" json:"segmentRetry"` // 切片重试次数 + LinodeRetry int `bson:"linodeRetry" json:"linodeRetry"` // 上传重试次数 + + // 切片相关信息 + Filename string `bson:"filename,omitempty" json:"filename"` // 文件名称 + FieldNameFs string `bson:"fieldNameFs,omitempty" json:"fieldNameFs"` // H.264 m3u8 路径 + PreMp4FileNameFs string `bson:"preMp4FileNameFs,omitempty" json:"preMp4FileNameFs"` // 预览MP4文件 + UnencryptedPreMp4 string `bson:"unencryptedPreMp4,omitempty" json:"unencryptedPreMp4"` // 不加密的预览MP4 + TempFieldNameFs string `bson:"tempFieldNameFs,omitempty" json:"tempFieldNameFs"` // 临时存储 + Ext string `bson:"ext,omitempty" json:"ext"` // 视频后缀 + TaskID string `bson:"taskId,omitempty" json:"taskId"` // 项目组传来的唯一标示 + Width int64 `bson:"width,omitempty" json:"width"` // 分辨率宽 + Height int64 `bson:"height,omitempty" json:"height"` // 分辨率高 + FrameRate string `bson:"frameRate,omitempty" json:"frameRate"` // 视频贞数 + SyBitRate string `bson:"syBitRate,omitempty" json:"syBitRate"` // 音频bit率 + VidBitRate string `bson:"vidBitRate,omitempty" json:"vidBitRate"` // 视频bit率 + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updatedAt"` // 更新时间 + ProxyUpload *int32 `bson:"proxyUpload,omitempty" json:"proxyUpload"` // 代理上传 + Retry int `bson:"retry" json:"retry"` // 重试次数 默认为零 最大重试3次 + NotifySignal bool `bson:"notifySignal" json:"notifySignal"` //中止通知的信号 false 可以发送通知,true 中止发送通知 + + InfoTsName string `bson:"infoTsName" json:"infoTsName"` // 从该ts中获取视频分辨率等信息 + SyncFlag int64 `bson:"syncFlag" json:"syncFlag"` // 同步状态 + SrcKey string `bson:"srcKey,omitempty" json:"srcKey"` // 原始Key + MediaType int64 `bson:"mediaType" json:"mediaType"` // 资源类型:视频 + IsPlaylet bool `bson:"isPlaylet,omitempty" json:"isPlaylet,omitempty"` // 是否短剧视频 + IssuedAt time.Time `json:"issuedAt" bson:"issuedAt"` // 发行时间 + IssuedBrand string `json:"issuedBrand" bson:"issuedBrand"` // 发行商 + Bango string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 + UploadUserName string `bson:"uploadUserName" json:"uploadUserName,omitempty"` // 上传用户名 + OptionAccount string `bson:"optionAccount,omitempty" json:"optionAccount"` // 最后操作人 +} + +// PlayletKeys 短剧媒资比对用的 ID(任务 id / 源 id / taskId) +func (info MediaResp) PlayletKeys() []string { + keys := make([]string, 0, 3) + if !info.Id.IsZero() { + keys = append(keys, info.Id.Hex()) + } + if !info.MediaSourceId.IsZero() { + keys = append(keys, info.MediaSourceId.Hex()) + } + if taskID := strings.TrimSpace(info.TaskID); taskID != "" { + keys = append(keys, taskID) + } + return keys +} + +func (info *MediaResp) ToVideoInfo() *videoinformationmod.VideoInformation { + syncKey := info.Id.Hex() + if !info.MediaSourceId.IsZero() { + syncKey = info.MediaSourceId.Hex() + } + rsl := &videoinformationmod.VideoInformation{ + Id: mongos.Lid.VideoInfo.Get(), + Title: info.Title, + AddedTime: time.Time{}, + Keyworld: info.Keyworld, + FirstTag: info.FirstTag, + SecondTags: info.SecondTags, + CoverImg: info.CoverImg, + Desc: info.Desc, + Remark: info.Remark, + MediaType: videoinformationmod.MediaType_Video, + Status: pubdef.InformationStatusWaiting, + Tags: make([]int64, 0), + Real: pubdef.Flow{}, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + IssuedBrand: info.IssuedBrand, + IssuedAt: info.IssuedAt, + Bango: info.Bango, + OptionAccount: info.OptionAccount, + UploadUserName: info.UploadUserName, + UnencryptedImage: info.UnencryptedImage, + SyncId: syncKey, + } + rsl.Nodes = make([]videoinformationmod.Node, 0) + if info.Nodes != nil && len(info.Nodes) > 0 { + for _, node := range info.Nodes { + node.Videos = make([]videoinformationmod.VideoInfo, 0) + rsl.Nodes = append(rsl.Nodes, node) + } + } + if len(info.FieldNameFs) > 0 { + + rsl.Video = videoinformationmod.VideoInfo{ + TaskId: info.TaskID, + FileId: syncKey, + Url: info.FieldNameFs, + Cover: info.VideoCover, + Height: info.Height, + Width: info.Width, + VideoTime: info.VideoTime, + PreMp4FileNameFs: info.PreMp4FileNameFs, + UnencryptedPreMp4: info.UnencryptedPreMp4, + } + if !strings.HasSuffix(rsl.Video.Url, ".m3u8") { + rsl.Video.Url += ".m3u8" + } + } + return rsl +} + +type MediaType int + +const ( + Preview MediaType = 1 + Normal MediaType = 2 +) + +type FailReason string + +const ( + FailReasonNone FailReason = "" + FailReasonUploadTimeoutError FailReason = "UploadTimeoutError" // 上传超时 + FailReasonMergeError FailReason = "MergeError" // 合并错误 + FailReasonCompressError FailReason = "CompressError" // 压缩错误 + FailReasonSegmentError FailReason = "SegmentError" // 分片错误 + FailReasonLinodeError FailReason = "LinodeError" // Linode错误 +) + +type BaseInfo struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + SrcId int64 `bson:"srcId,omitempty" json:"srcId"` // 原始ID + Title string `bson:"title,omitempty" json:"title"` // 标题 + CheckSum string `bson:"checkSum,omitempty" json:"checkSum"` // md5校验码 + VideoTime int32 `bson:"videoTime,omitempty" json:"videoTime"` // 影片长度 + Publish string `json:"publish,omitempty" bson:"publish"` // 视频发布者 + Type string `bson:"type,omitempty" json:"type"` // 长视频/短视频 + Preview MediaType `bson:"preview,omitempty" json:"preview"` // 预览普通视频:1:预览 2:正片 + Country string `bson:"country,omitempty" json:"country"` // 国家 (国产、日本、欧美.... 前端固定) + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + PreFileName string `bson:"preFileName,omitempty" json:"preFileName"` // 预览文件 + Status string `bson:"status,omitempty" json:"status"` // 当前状态 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Size int64 `bson:"size,omitempty" json:"size"` // 文件size,单位byte + Bango string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 + Directors []string `bson:"directors,omitempty" json:"directors"` // 导演 + Actors []string `bson:"actors,omitempty" json:"actors"` // 演员 + Studio string `bson:"studio,omitempty" json:"studio"` // 工作室/所有者 + AddedTime time.Time `bson:"addedTime,omitempty" json:"addedTime"` // 上架时间 + AppIds []int64 `bson:"appids,omitempty" json:"appids,omitempty"` // 应用列表 + + // 切片相关信息 + Filename string `bson:"filename,omitempty" json:"filename"` // 文件名称 + FieldNameFs string `bson:"fieldNameFs,omitempty" json:"fieldNameFs"` // 文件名称 + Ext string `bson:"ext,omitempty" json:"ext"` // 视频后缀 + TaskID string `bson:"taskId,omitempty" json:"taskId"` // 项目组传来的唯一标示 + Width int `bson:"width,omitempty" json:"width"` // 分辨率宽 + Height int `bson:"height,omitempty" json:"height"` // 分辨率高 + Ratio float64 `bson:"ratio,omitempty" json:"ratio"` // 宽高比 + FrameRate string `bson:"frameRate,omitempty" json:"frameRate"` // 视频贞数 + SyBitRate string `bson:"syBitRate,omitempty" json:"syBitRate"` // 音频bit率 + VidBitRate string `bson:"vidBitRate,omitempty" json:"vidBitRate"` // 视频bit率 + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updatedAt"` // 更新时间 + ProxyUpload *int32 `bson:"proxyUpload,omitempty" json:"proxyUpload"` // 代理上传 + + IsAdd bool `bson:"isAdd,omitempty" json:"isAdd"` // 是否加入到本地 + Retry int `bson:"retry" json:"retry"` // 重试次数 默认为零 最大重试3次 + NotifySignal bool `bson:"notifySignal" json:"notifySignal"` //中止通知的信号 false 可以发送通知,true 中止发送通知 + ShowLocation int64 `bson:"showLocation" json:"showLocation"` // 显示位置 + Remark string `bson:"remark,omitempty" json:"remark"` + + MergeRetry int `bson:"mergeRetry" json:"mergeRetry"` //合并重试次数 + CompressRetry int `bson:"compressRetry" json:"compressRetry"` //压缩重试次数 + SegmentRetry int `bson:"segmentRetry" json:"segmentRetry"` //切片重试次数 + LinodeRetry int `bson:"linodeRetry" json:"linodeRetry"` //上传linode重试次数 + FailReason FailReason `bson:"failReason" json:"failReason"` //失败原因 + DeleteDisk bool `bson:"deleteDisk" json:"deleteDisk"` //是否删除了对于磁盘文件 + InfoTsName string `bson:"infoTsName" json:"infoTsName"` //从该ts中获取视频分辨率等信息 + UploadedPoses []int64 `bson:"uploadedPoses" json:"uploadedPoses"` //上传过的pos列表 +} + +type MediaUpdate struct { + ID string `json:"id,omitempty"` + Title *string `bson:"title,omitempty" json:"title,omitempty"` // 标题 + AddedTime *time.Time `bson:"addedTime,omitempty" json:"addedTime,omitempty"` // 上架时间 + Publish *string `json:"publish,omitempty" bson:"publish,omitempty"` // 视频发布者 + Country *string `bson:"country,omitempty" json:"country,omitempty"` // 国家 (国产、日本、欧美....) + Preview *MediaType `bson:"preview,omitempty" json:"preview,omitempty"` // 预览普通视频:1:预览 2:正片 + Type *string `bson:"type,omitempty" json:"type,omitempty"` // 长视频/短视频 + FirstTag *string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签 + SecondTags *[]string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签 + Bango *string `bson:"bango,omitempty" json:"bango,omitempty"` // 番号,唯一 + AppIds *[]int64 `bson:"appids,omitempty" json:"appids,omitempty"` // 应用列表 + Desc *string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述 + PreFileName *string `bson:"preFileName,omitempty" json:"preFileName,omitempty"` // 预览文件 + CoverImg *string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面 + UnencryptedImage *string `bson:"unencryptedImage,omitempty" json:"unencryptedImage,omitempty"` // 不加密封面 + CreatedAt *time.Time `bson:"createdAt,omitempty" json:"createdAt,omitempty"` // 创建时间 + UpdatedAt *time.Time `bson:"updatedAt,omitempty" json:"updatedAt,omitempty"` // 更新时间 + ShowLocation *int64 `bson:"showLocation,omitempty" json:"showLocation"` // 显示位置 + MoveInfo *MoveInfo `bson:"moveInfo,omitempty" json:"moveInfo,omitempty"` // 电影自动更新 +} +type MoveInfo struct { + Score float64 `json:"score" bson:"score"` // 评分 暂无评分=0 + SerialCount int64 `json:"serialCount" bson:"serialCount"` // 总集数 serialCount + SerialIndex int64 `json:"serialIndex" bson:"serialIndex"` // 集数 lastName + Likes int64 `json:"likes" bson:"likes"` // 点赞数量 favoriteCount + Watchs int64 `json:"watchs" bson:"watchs"` // 点赞数量 view + UpdateDate time.Time `json:"updateDate" bson:"updateDate"` // 更新时间 languagesplaylist 短剧和剧集有更新 电影 + MoveTag []string `json:"moveTag" bson:"moveTag"` // 标签:cidMapper + post_Year + regional +} +type VideoSearch struct { + ID string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` // 标题 + TaskID *string `json:"taskId,omitempty"` // 项目组传来的唯一标示 + Publish *string `bson:"publish,omitempty"` // 视频发布者 + Type *string `json:"type"` // 视频:长视频/短视频--时间阈值 + Preview *int32 `json:"preview"` // 预览:1:预览 2:普通视频 + Country *string `json:"country"` // 国家 (国产、日本、欧美.... 前端固定) + Appid *int64 `json:"appid"` // 应用ID + FirstTag *string `json:"firstTag,omitempty"` // 一级标签 + SecondTags *string `json:"secondTags,omitempty"` // 二级标签标签 + Status *string `json:"status,omitempty"` // 当前状态 + Directors *[]string `json:"director,omitempty"` // 导演 + Actors *[]string `json:"actors,omitempty"` // 演员 + Studio *string `json:"studio,omitempty"` // 工作室 + Bango *string `json:"bango,omitempty"` // 番号,唯一 + Via *string `json:"via,omitempty"` // 来源 + Remark *string `json:"remark,omitempty"` // 备注 + AddedTime *string `json:"addedTime,omitempty"` // 上架时间 + UploadTime *string `json:"uploadTime,omitempty"` // 上传时间 + Start *time.Time `json:"start"` // 开始时间 + End *time.Time `json:"end"` // 结束时间 + ShowLocation *int64 `json:"showLocation"` // 显示位置 +} + +type MediaSearchReq struct { + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` + Param VideoSearch `json:"param"` +} + +// MediaSearchReq 媒体搜索请求返回 +type MediaSearchResp struct { + Total int64 `json:"total"` + List []*BaseInfo `json:"list"` +} + +type MediaSearchResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaSearchResp `json:"data,omitempty"` +} + +type MediaDelReq struct { + ID string `json:"id,omitempty"` +} + +// MediasDelResp 请求批量删除媒体回复 +type MediaDelResp struct { +} + +type MediaUpdatePreTime struct { + ID string `json:"id,omitempty"` + PreFileTime int64 `bson:"preFileTime,omitempty" json:"preFileTime"` // 预览文件(开始时间) +} + +type MediaDelResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + // Data MediaDelResp `json:"data,omitempty"` +} + +type MediaUpdateReq struct { + //Ids []string `json:"id"` + MediaUpdate +} + +// MediasUpdateResp 请求批量更新媒体回复 +type MediaUpdateResp struct { +} + +type MediaUpdateResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data MediaUpdateResp `json:"data,omitempty"` +} + +type AppAdvitiseInfoReq struct { + Id int64 `json:"id" binding:"required"` +} +type AdvitiseInfo struct { + Id string `json:"id" bson:"_id"` // 广告id + Location int64 `json:"location" bson:"location"` // 位置id + Title string `json:"title" bson:"title"` // 广告标题 + Images string `json:"images" bson:"images"` // 视频banner + Link string `json:"link" bson:"link"` // 链接 +} + +type AdvitiseLocationQuerysResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data AdvitiseLocationDataResp `json:"data,omitempty"` +} +type AdvitiseLocationDataResp struct { + List []AdvitiseLocationData `json:"list" bson:"list"` // 数据 +} + +type AdvitiseLocationData struct { + LocationId int64 `json:"locationId" bson:"locationId"` // 位置ID + LocationName string `json:"locationName" bson:"locationName"` // 位置名称 + AdvitiseSize string `json:"advitiseSize" bson:"advitiseSize"` // 封面尺寸 + ImgSizeDesc string `json:"imgSizeDesc" bson:"imgSizeDesc"` // 该位置图片描述 + AdvitiseData []AdvitiseInfo `json:"advitiseData" bson:"advitiseData"` // 广告数据 +} + +// MediaFileSyncItem mediafile/sync 单条媒资项 +type MediaFileSyncItem struct { + ID string `json:"id"` + Title string `json:"title"` + TaskID string `json:"taskId"` + FieldNameFs string `json:"fieldNameFs"` + CoverImg string `json:"coverImg"` + Width int64 `json:"width"` + Height int64 `json:"height"` + VideoTime int64 `json:"videoTime"` + Size int64 `json:"size"` + MediaFileVersion string `json:"mediaFileVersion"` + IsPlaylet bool `json:"isPlaylet"` +} + +// MediaFileSyncReq 同步媒资库 m3u8 到 media 切片服 +type MediaFileSyncReq struct { + AppId int64 `json:"appId"` + AppKey string `json:"appKey"` + VideoList []MediaFileSyncItem `json:"videoList"` +} + +// MediaTaskAddReq 提交 media 加水印切片任务 +type MediaTaskAddReq struct { + MediaSourceId string `json:"mediaSourceId"` + AppId int64 `json:"appId"` + AppKey string `json:"appKey"` + Title string `json:"title"` + WatermarkImage string `json:"watermarkImage"` + WatermarkMedia string `json:"watermarkMedia"` + WatermarkMediaTop string `json:"watermarkMediaTop"` + WatermarkMediaTopRight string `json:"watermarkMediaTopRight"` + WatermarkMediaBottomLeft string `json:"watermarkMediaBottomLeft"` +} + +// MediaTaskUpdateWatermarkReq 按已有切片任务重新加水印 +type MediaTaskUpdateWatermarkReq struct { + Id string `json:"id"` + AppId int64 `json:"appId"` + AppKey string `json:"appKey"` + WatermarkImage *string `json:"watermarkImage,omitempty"` + WatermarkMedia *string `json:"watermarkMedia,omitempty"` + WatermarkMediaTop *string `json:"watermarkMediaTop,omitempty"` + WatermarkMediaTopRight *string `json:"watermarkMediaTopRight,omitempty"` + WatermarkMediaBottomLeft *string `json:"watermarkMediaBottomLeft,omitempty"` +} + +// MediaAPIResult media 通用响应 +type MediaAPIResult struct { + Code int `json:"code"` + Msg string `json:"msg"` + Tip string `json:"tip"` +} + +const MediaSourceCompletedFail = "CompletedFail" + +// MediaSourceListReq 按 ID 查询 media 原片状态 +type MediaSourceListReq struct { + Id string `json:"id"` + AppId int64 `json:"appId,omitempty"` + WebAppId int64 `json:"webAppId,omitempty"` + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` +} + +type MediaSourceListItem struct { + Status string `json:"status"` + FailReason string `json:"failReason"` +} + +type MediaSourceListResult struct { + Code int `json:"code"` + Msg string `json:"msg"` + Tip string `json:"tip"` + Data struct { + Total int64 `json:"total"` + List []MediaSourceListItem `json:"list"` + } `json:"data"` +} diff --git a/pub/storage/proxy/utils.go b/pub/storage/proxy/utils.go new file mode 100644 index 0000000..761e69d --- /dev/null +++ b/pub/storage/proxy/utils.go @@ -0,0 +1,141 @@ +package proxy + +import ( + "fmt" + "strings" +) + +const ( + // 视频桶地址 + BucketVideoAddressV1 = "http://ts.bbaazz.me" + // 图片桶地址 + BucketImageAddressV1 = "http://img.bbaazz.me" + BucketImageAddressV2 = "http://tong3yuanimg.bbaazz.me" + + // 上传路径 + ImageUploadPathV1 = "/file/upload" + ImageUploadPathV2 = "/file/upload" // "/file/web/media/uploadImg" + VideoBodyPathV1 = "/file/web/proxy/videoBody" + VideoBodyPathV2 = "/file/web/proxy/videoBodyV2" + VideoOkPathV1 = "/file/web/proxy/videoOk" + VideoOkPathV2 = "/file/web/proxy/videoOkv2" +) + +var ( + BucketVideoAddressV2 = "http://139.162.27.29:19996" //正式 + BucketVideoAddress = "http://tong3yuants.bbaazz.me" +) + +func InitBucketVideoAddress(address, addressV2 string) { + BucketVideoAddress = address + BucketVideoAddressV2 = addressV2 +} + +func DomainAddressRepair(domain string, defaultBucketAddress ...string) string { + if domain == "" { + if len(defaultBucketAddress) > 0 && defaultBucketAddress[0] != "" { + domain = defaultBucketAddress[0] + } else { + domain = BucketVideoAddressV2 + } + } + return domain +} + +func ImageUploadAddressRepair(domain string, defaultBucketAddress ...string) string { + domain = DomainAddressRepair(domain, defaultBucketAddress...) + if !strings.Contains(domain, ImageUploadPathV1) && !strings.Contains(domain, ImageUploadPathV2) { + if strings.HasPrefix(domain, BucketImageAddressV1) { + return domain + ImageUploadPathV1 + } else if strings.HasPrefix(domain, BucketImageAddressV2) { + return domain + ImageUploadPathV2 + } + return domain + ImageUploadPathV2 + } + return domain +} + +func VideoBodyAddressRepair(domain string, defaultBucketAddress ...string) string { + domain = DomainAddressRepair(domain, defaultBucketAddress...) + if strings.HasPrefix(domain, BucketVideoAddressV1) { + if !strings.Contains(domain, VideoBodyPathV1) { + return domain + VideoBodyPathV1 + } + return domain + } else if strings.HasPrefix(domain, BucketVideoAddressV2) { + if !strings.Contains(domain, VideoBodyPathV2) { + return domain + VideoBodyPathV2 + } + return domain + } else { // 未知存储桶,默认v2 + if !strings.Contains(domain, VideoBodyPathV2) { + return domain + VideoBodyPathV2 + } + return domain + } +} + +func VideoOkAddressRepair(domain string, defaultBucketAddress ...string) string { + domain = DomainAddressRepair(domain, defaultBucketAddress...) + if strings.HasPrefix(domain, BucketVideoAddressV1) { + if !strings.Contains(domain, VideoOkPathV1) { + return domain + VideoOkPathV1 + } + return domain + } else if strings.HasPrefix(domain, BucketVideoAddressV2) { + if !strings.Contains(domain, VideoOkPathV2) { + return domain + VideoOkPathV2 + } + return domain + } else { // 未知存储桶,默认v2 + if !strings.Contains(domain, VideoOkPathV2) { + return domain + VideoOkPathV2 + } + return domain + } +} + +func GetExistsUrlPathBucketAddress(path string) string { + if strings.HasPrefix(path, "v1/") { + return BucketVideoAddressV1 + } else if strings.HasPrefix(path, "v2/") { + return BucketVideoAddressV2 + } else if strings.HasPrefix(path, "v3/") { + return BucketVideoAddressV2 + } else { + return BucketVideoAddressV1 // 欲漫涩、微博、快看禁漫 默认用桶2,其他的app,默认用桶1 + } +} + +func AppendV2PrefixToUrlPath(name string) string { + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + name = fmt.Sprintf("v2/%s", name) + } + return name +} + +func AppendV1PrefixToUrlPath(name string) string { + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + name = fmt.Sprintf("v1/%s", name) + } + return name +} + +func AppendsMediaS3Version(name string) string { + // 默认使用V1 + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + return "/v2" + } + return "" +} +func AppendsMediaS3VersionV1(name string) string { + // 默认使用V1 + if name != "" && !strings.HasPrefix(name, "v2/") && !strings.HasPrefix(name, "v1/") && !strings.HasPrefix(name, "v3/") && + !strings.HasPrefix(name, "/v2") && !strings.HasPrefix(name, "/v1") && !strings.HasPrefix(name, "/v3") { + return "/v1" + } + return "" +} diff --git a/pub/storage/proxy/utils_test.go b/pub/storage/proxy/utils_test.go new file mode 100644 index 0000000..4bcabed --- /dev/null +++ b/pub/storage/proxy/utils_test.go @@ -0,0 +1,157 @@ +package proxy + +import "testing" + +func TestImageUploadAddressRepair(t *testing.T) { + type args struct { + domain string + defaultBucketAddress []string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "a", + args: args{ + domain: "http://aaa", + defaultBucketAddress: []string{}, + }, + want: "http://aaa/file/upload", + }, + { + name: "b", + args: args{ + domain: "http://ts.bbaazz.me", + defaultBucketAddress: []string{}, + }, + want: "http://ts.bbaazz.me/file/upload", + }, + { + name: "c", + args: args{ + domain: "http://resourcets.bbaazz.me", + defaultBucketAddress: []string{}, + }, + want: "http://resourcets.bbaazz.me/file/upload", + }, + { + name: "d", + args: args{ + domain: "", + defaultBucketAddress: []string{BucketImageAddressV1}, + }, + want: "http://ts.bbaazz.me/file/upload", + }, + { + name: "e", + args: args{ + domain: "", + defaultBucketAddress: []string{BucketImageAddressV2}, + }, + want: "http://resourcets.bbaazz.me/file/upload", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ImageUploadAddressRepair(tt.args.domain, tt.args.defaultBucketAddress...); got != tt.want { + t.Errorf("ImageUploadAddressRepair() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVideoBodyAddressRepair(t *testing.T) { + type args struct { + domain string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "a", + args: args{domain: "http://aaa"}, + want: "http://aaa/file/web/proxy/videoBodyV2", + }, + { + name: "b", + args: args{domain: "http://ts.bbaazz.me"}, + want: "http://ts.bbaazz.me/file/web/proxy/videoBody", + }, + { + name: "c", + args: args{domain: "http://resourcets.bbaazz.me"}, + want: "http://resourcets.bbaazz.me/file/web/proxy/videoBodyV2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := VideoBodyAddressRepair(tt.args.domain); got != tt.want { + t.Errorf("VideoBodyAddressRepair() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVideoOkAddressRepair(t *testing.T) { + type args struct { + domain string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "a", + args: args{domain: "http://aaa"}, + want: "http://aaa/file/web/proxy/videoOkv2", + }, + { + name: "b", + args: args{domain: "http://ts.bbaazz.me"}, + want: "http://ts.bbaazz.me/file/web/proxy/videoOk", + }, + { + name: "c", + args: args{domain: "http://resourcets.bbaazz.me"}, + want: "http://resourcets.bbaazz.me/file/web/proxy/videoOkv2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := VideoOkAddressRepair(tt.args.domain); got != tt.want { + t.Errorf("VideoOkAddressRepair() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppendV2PrefixToUrlPath(t *testing.T) { + type args struct { + name string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "a", + args: args{ + name: "abc/aaa", + }, + want: "v2/abc/aaa", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AppendV2PrefixToUrlPath(tt.args.name); got != tt.want { + t.Errorf("AppendV2PrefixToUrlPath() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pub/storage/upload.go b/pub/storage/upload.go new file mode 100644 index 0000000..bf20bf9 --- /dev/null +++ b/pub/storage/upload.go @@ -0,0 +1,275 @@ +package storage + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "mime/multipart" + "videoweb/pub/storage/proxy" + + "strconv" +) + +var ( +// buffer = bytes.Buffer{} +// mtx sync.Mutex +) + +// web后台上传使用, form-data方式上传 +func UploadImage(ctx context.Context, f *multipart.FileHeader, domain string) (path string, err error) { + if f == nil { + return "", errors.New("file is nil!") + } + var file multipart.File + if file, err = f.Open(); err != nil { + return + } + + defer file.Close() + + //mtx.Lock() + //defer mtx.Unlock() + buffer := bytes.Buffer{} + if _, err = buffer.ReadFrom(file); err != nil { + return + } + return proxy.UploadImg(f.Filename, buffer.Bytes(), domain) +} + +func UploadVideoBody(ctx context.Context, form *multipart.Form, domain string) (*proxy.Result, error) { + if form == nil { + return nil, errors.New("multipart from is nil") + } + sb, err := makeShardBodyByForm(form) + if err != nil { + return nil, err + } + return proxy.UploadVideo(domain, sb) +} + +func UploadVideoBodyV2(ctx context.Context, form *multipart.Form, + domain string, appIdIn int, publish string) (*proxy.Result, error) { + if form == nil { + return nil, errors.New("multipart from is nil") + } + sb, err := makeShardBodyByFormV2(form, appIdIn, publish) + if err != nil { + return nil, err + } + return proxy.UploadVideoV2(domain, sb) +} + +func VideoBody(ctx context.Context, taskId, typ, domain string, pos int, data []byte) (*proxy.Result, error) { + body := &proxy.ShardBody{ + TaskId: taskId, + Pos: pos, + FileData: base64.StdEncoding.EncodeToString(data), + Type: typ, + } + if typ == "1" { + body.Type = "AV" + } else if typ == "2" { + body.Type = "SP" + } + return proxy.UploadVideo(domain, body) +} + +func UploadVideoOK(ctx context.Context, in *proxy.ShardOk, domain string) (*proxy.VideoOkResult, error) { + return proxy.PostOk(in, domain) +} +func UploadVideoOKV2(ctx context.Context, in *proxy.ShardOk, domain string) (*proxy.VideoOkResult, error) { + return proxy.PostOkV2(in, domain) +} + +func QueryMedia(ctx context.Context, in *proxy.MediaQueryeReq, domain string) (*proxy.VideoQueryResult, error) { + return proxy.QueryMedia(in, domain) +} +func QueryMedias(ctx context.Context, in *proxy.MediaQueryesReq, domain string) (*proxy.VideoQuerysResult, error) { + return proxy.QueryMedias(in, domain) +} +func QueryAdvitiseLocationList(ctx context.Context, in *proxy.AppAdvitiseInfoReq, domain string) (*proxy.AdvitiseLocationQuerysResult, error) { + return proxy.QueryAdvitiseLocation(in, domain) +} + +func SyncMedia(in *proxy.MediaTaskSyncReq, domain string) (result *proxy.VideoQuerysResult, err error) { + return proxy.SyncMedia(in, domain) +} +func makeShardBodyByForm(form *multipart.Form) (*proxy.ShardBody, error) { + poss := form.Value["pos"] + taskIds := form.Value["taskId"] + types := form.Value["type"] + fs := form.File["file"] + totalPoss := form.Value["totalChunks"] + + var ( + pos, total int + taskId string + typ string + f *multipart.FileHeader + err error + ) + + if len(fs) == 0 || fs[0] == nil { + return nil, errors.New("no file") + } + f = fs[0] + + if len(poss) == 0 || poss[0] == "" { + return nil, errors.New("no pos") + } + if pos, err = strconv.Atoi(poss[0]); err != nil { + return nil, fmt.Errorf("invalid pos") + } + if len(totalPoss) == 0 || totalPoss[0] == "" { + return nil, errors.New("no totalPos") + } + if total, err = strconv.Atoi(totalPoss[0]); err != nil { + return nil, fmt.Errorf("invalid totalPos") + } + + if len(taskIds) == 0 || taskIds[0] == "" { + return nil, errors.New("no taskId") + } + taskId = taskIds[0] + + if len(types) == 0 || types[0] == "" { + return nil, errors.New("no type") + } + if types[0] == "1" { + typ = "AV" + } else if types[0] == "2" { + typ = "SP" + } else if types[0] == "3" { + typ = "AI" + } else { + typ = types[0] + } + + file, ne := f.Open() + if ne != nil { + return nil, ne + } + defer file.Close() + //mtx.Lock() + //defer mtx.Unlock() + buffer := bytes.Buffer{} + if _, err = buffer.ReadFrom(file); err != nil { + return nil, err + } + + resp := &proxy.ShardBody{ + TaskId: taskId, + Pos: pos, + FileData: base64.StdEncoding.EncodeToString(buffer.Bytes()), + Type: typ, + TotalPos: total, + } + return resp, nil +} + +func makeShardBodyByFormV2(form *multipart.Form, appIdIn int, publishIn string) ( + *proxy.ShardBodyV2, error) { + taskIds := form.Value["taskId"] + types := form.Value["type"] + poss := form.Value["pos"] + fs := form.File["file"] + totalPoss := form.Value["totalChunks"] + fileNames := form.Value["filename"] + appIds := form.Value["appId"] + publishs := form.Value["publish"] + + var ( + pos, total int + taskId string + f *multipart.FileHeader + fileName string + typ string + publish string + appId int + err error + ) + + if len(fs) == 0 || fs[0] == nil { + return nil, errors.New("no file") + } + f = fs[0] + + if len(poss) == 0 || poss[0] == "" { + return nil, errors.New("no pos") + } + if pos, err = strconv.Atoi(poss[0]); err != nil { + return nil, fmt.Errorf("invalid pos") + } + if len(totalPoss) == 0 || totalPoss[0] == "" { + return nil, errors.New("no totalPos") + } + if total, err = strconv.Atoi(totalPoss[0]); err != nil { + return nil, fmt.Errorf("invalid totalPos") + } + + if len(taskIds) == 0 || taskIds[0] == "" { + return nil, errors.New("no taskId") + } + taskId = taskIds[0] + + if len(types) == 0 || types[0] == "" { + return nil, errors.New("no type") + } + if types[0] == "1" { + typ = "AV" + } else if types[0] == "2" { + typ = "SP" + } else if types[0] == "3" { + typ = "AI" + } else { + typ = types[0] + } + + if len(fileNames) == 0 || fileNames[0] == "" { + return nil, errors.New("no fileName") + } + fileName = fileNames[0] + + if len(publishs) == 0 || publishs[0] == "" { + return nil, errors.New("no publish") + } + publish = publishs[0] + if publish == "" { + publish = publishIn + } + + if len(appIds) == 0 || appIds[0] == "" { + return nil, errors.New("no appId") + } + if appId, err = strconv.Atoi(appIds[0]); err != nil { + return nil, fmt.Errorf("invalid appId") + } + + if appId == 0 { + appId = appIdIn + } + + file, ne := f.Open() + if ne != nil { + return nil, ne + } + defer file.Close() + buffer := bytes.Buffer{} + if _, err = buffer.ReadFrom(file); err != nil { + return nil, err + } + + resp := &proxy.ShardBodyV2{ + TaskId: taskId, + Pos: pos, + FileData: base64.StdEncoding.EncodeToString(buffer.Bytes()), + Type: typ, + TotalPos: total, + FileName: fileName, + AppId: appId, + Publish: publish, + } + return resp, nil +} diff --git a/read.md b/read.md new file mode 100644 index 0000000..e69de29 diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 0000000..c1a4fea --- /dev/null +++ b/web/Makefile @@ -0,0 +1,32 @@ + +GOPATH:=$(shell go env GOPATH) + +GIT_BRANCH:=$(shell git symbolic-ref --short -q HEAD) +GIT_VERSION:=$(shell git log --pretty=oneline -p -1 | head -n 1 | awk '{print $$1}') +BUILD_DATE:=$(shell date '+%Y-%m-%d %H:%M:%S %z') +BUILD_IP:=$(shell ifconfig | grep inet | grep -v grep | grep -v "inet6" | grep -v "127.0.0.1" | awk '{print $$2}') +VERSON_PATH:=miao/pub/srvversion + +WEB_BUILD_FLAG:=-ldflags "-X '$(VERSON_PATH).GIT_BRANCH=$(GIT_BRANCH)' -X '$(VERSON_PATH).GIT_VERSION=$(GIT_VERSION)' -X '$(VERSON_PATH).BUILD_DATE=$(BUILD_DATE)' -X '$(VERSON_PATH).BUILD_IP=$(BUILD_IP)'" + + +.PHONY: test deploy +deploy: + go clean + rm -f web_mdcom_web + env GOOS=linux GOARCH=amd64 go build $(WEB_BUILD_FLAG) -o web_mdcom_web + tar czf web_mdcom_web.tar.gz web_mdcom_web + rm -f web_mdcom_web +test: + go clean + rm -f web_mdcom_web + GOOS=linux GOARCH=amd64 go build $(WEB_BUILD_FLAG) -o web_mdcom_web + tar czf web_mdcom_web.tar.gz web_mdcom_web docs + rm -f web_mdcom_web + +localtest: + @echo "test, GOPATH=$(GOPATH)" + go build $(WEB_BUILD_FLAG) -o web + +swagger: + swag init --parseDependency --parseDepth 4 \ No newline at end of file diff --git a/web/api/proto/content_tag.go b/web/api/proto/content_tag.go new file mode 100644 index 0000000..20b371d --- /dev/null +++ b/web/api/proto/content_tag.go @@ -0,0 +1,92 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/pubdef" +) + +// ContentTagListReq web端统一标签分页列表请求 +type ContentTagListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` // 标签ID + Name string `form:"name" json:"name"` // 标签名称,支持模糊查询 + Type *contenttagmod.ContentTagType `form:"type" json:"type"` // 内容类型 1-视频 5-短剧 + Status *bool `form:"status" json:"status"` // 状态 true-启用 false-停用 +} + +// ContentTagListResp web端统一标签列表项 +type ContentTagListResp struct { + Id int64 `json:"id"` // ID + Name string `json:"name"` // 标签名称 + Slug string `json:"slug"` // slug + Types []contenttagmod.ContentTagType `json:"types"` // 支持类型 1-视频 5-短剧 + TypeNames []string `json:"typeNames"` // 支持类型名称 + Status bool `json:"status"` // 状态 true-启用 false-停用 + Rank int64 `json:"rank"` // 排序,越大越靠前 + VideoCount int64 `json:"videoCount"` // 绑定视频数量 + PlayletCount int64 `json:"playletCount"` // 绑定短剧数量 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + CreatedById int64 `json:"createdById"` // 创建人ID + CreatedByName string `json:"createdByName"` // 创建人名称 + UpdatedByName string `json:"updatedByName"` // 修改人名称 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 +} + +// ContentTagListPageResp web端统一标签分页列表响应 +type ContentTagListPageResp struct { + response.BasePageResp +} + +// ContentTagOptionReq web端统一标签下拉列表请求 +type ContentTagOptionReq struct { + Type *contenttagmod.ContentTagType `form:"type" json:"type"` // 内容类型 1-视频 5-短剧,不传返回全部启用标签 +} + +// ContentTagOptionResp web端统一标签下拉列表响应 +type ContentTagOptionResp struct { + List []*ContentTagOptionItem `json:"list"` // 标签下拉列表 +} + +// ContentTagOptionItem web端统一标签下拉项 +type ContentTagOptionItem struct { + Id int64 `json:"id"` // ID + Name string `json:"name"` // 标签名称 + Types []contenttagmod.ContentTagType `json:"types"` // 支持类型 1-视频 5-短剧 + TypeNames []string `json:"typeNames"` // 支持类型名称 + Rank int64 `json:"rank"` // 排序,越大越靠前 + BindCount int64 `json:"bindCount"` // 当前查询类型的绑定数量,不传type时返回0 +} + +// ContentTagAddReq web端新增统一标签请求 +type ContentTagAddReq struct { + Name string `json:"name" binding:"required"` // 标签名称 + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或名称自动生成 + Types []contenttagmod.ContentTagType `json:"types" binding:"required"` // 支持类型 1-视频 5-短剧 + Status *bool `json:"status" binding:"required"` // 状态 true-启用 false-停用 + Rank int64 `json:"rank"` // 排序,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// ContentTagAddResp web端新增统一标签响应 +type ContentTagAddResp struct { + Id int64 `json:"id"` // ID +} + +// ContentTagUpdateReq web端更新统一标签请求 +type ContentTagUpdateReq struct { + Id int64 `json:"id" binding:"required"` // ID + Name *string `json:"name"` // 标签名称 + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Types *[]contenttagmod.ContentTagType `json:"types"` // 支持类型 1-视频 5-短剧 + Status *bool `json:"status"` // 状态 true-启用 false-停用 + Rank *int64 `json:"rank"` // 排序,越大越靠前 + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// ContentTagDelReq web端删除统一标签请求 +type ContentTagDelReq struct { + Id int64 `json:"id" binding:"required"` // ID +} diff --git a/web/api/proto/home_banner.go b/web/api/proto/home_banner.go new file mode 100644 index 0000000..fefcf95 --- /dev/null +++ b/web/api/proto/home_banner.go @@ -0,0 +1,67 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" +) + +// HomeBannerListReq web端首页 banner 分页列表请求 +type HomeBannerListReq struct { + PageNum int64 `form:"pageNum" json:"pageNum"` // 页码,默认1 + PageSize int64 `form:"pageSize" json:"pageSize"` // 每页数量,默认10,最大100 + Id *int64 `form:"id" json:"id"` // 首页 banner ID + Name string `form:"name" json:"name"` // 名称,支持模糊查询 + Status *bool `form:"status" json:"status"` // 状态 true-启用 false-停用 +} + +// HomeBannerListResp web端首页 banner 列表项 +type HomeBannerListResp struct { + Id int64 `json:"id"` // ID + Name string `json:"name"` // 名称 + Image string `json:"image"` // 图片 + VerticalImage string `json:"verticalImage"` // 竖版图片 + Url string `json:"url"` // URL + Rank int64 `json:"rank"` // 排序 + Status bool `json:"status"` // 状态 true-启用 false-停用 + CreatedById int64 `json:"createdById"` // 创建人ID + CreatedByName string `json:"createdByName"` // 创建人名称 + UpdatedByName string `json:"updatedByName"` // 修改人名称 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 +} + +// HomeBannerListPageResp web端首页 banner 分页列表响应 +type HomeBannerListPageResp struct { + response.BasePageResp +} + +// HomeBannerAddReq web端新增首页 banner 请求 +type HomeBannerAddReq struct { + Name string `json:"name" binding:"required"` // 名称 + Image string `json:"image" binding:"required"` // 图片 + VerticalImage string `json:"verticalImage"` // 竖版图片 + Url string `json:"url"` // URL + Rank int64 `json:"rank"` // 排序 + Status *bool `json:"status" binding:"required"` // 状态 true-启用 false-停用 +} + +// HomeBannerAddResp web端新增首页 banner 响应 +type HomeBannerAddResp struct { + Id int64 `json:"id"` // ID +} + +// HomeBannerUpdateReq web端更新首页 banner 请求 +type HomeBannerUpdateReq struct { + Id int64 `json:"id" binding:"required"` // ID + Name *string `json:"name"` // 名称 + Image *string `json:"image"` // 图片 + VerticalImage *string `json:"verticalImage"` // 竖版图片 + Url *string `json:"url"` // URL + Rank *int64 `json:"rank"` // 排序 + Status *bool `json:"status"` // 状态 true-启用 false-停用 +} + +// HomeBannerDelReq web端删除首页 banner 请求 +type HomeBannerDelReq struct { + Id int64 `json:"id" binding:"required"` // ID +} diff --git a/web/api/proto/playlet.go b/web/api/proto/playlet.go new file mode 100644 index 0000000..70c2518 --- /dev/null +++ b/web/api/proto/playlet.go @@ -0,0 +1,355 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" +) + +// PlayletListReq web端短剧分页列表请求 +type PlayletListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` // 短剧ID + CategoryId *int64 `form:"categoryId" json:"categoryId"` // 分类ID + TagId *int64 `form:"tagId" json:"tagId"` // 标签ID + ThemeId *int64 `form:"themeId" json:"themeId"` // 题材ID + ContentType *playletmod.ContentType `form:"contentType" json:"contentType"` // 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + PayType *playletmod.PayType `form:"payType" json:"payType"` // 支付方式,0-免费、1-金币、2-VIP + Status *playletmod.Status `form:"status" json:"status"` // 上架状态,0-下架、1-上架 + IsFeatured *bool `form:"isFeatured" json:"isFeatured"` // 是否爆剧,true-是、false-否 + IsOriginal *bool `form:"isOriginal" json:"isOriginal"` // 是否原创:true只查原创,false只查非原创,不传查全部 + ShowOriginal *bool `form:"showOriginal" json:"showOriginal"` // 页面是否展示原创标识 不传查全部 + IsRecommend *bool `form:"isRecommend" json:"isRecommend"` // 是否推荐,true-推荐、false-不推荐 + ShowOnBanner *bool `form:"showOnBanner" json:"showOnBanner"` // 是否显示在首页banner,true-显示、false-不显示 + Title string `form:"title" json:"title"` // 短剧标题,支持模糊查询 + OperatorName string `form:"operatorName" json:"operatorName"` // 最后操作人名称,支持模糊查询 +} + +// PlayletListResp web端短剧列表项 +type PlayletListResp struct { + Id int64 `json:"id"` // 短剧ID + Status playletmod.Status `json:"status"` // 上架状态,0-下架、1-上架 + IsFeatured bool `json:"isFeatured"` // 是否爆剧,true-是、false-否 + IsOriginal bool `json:"isOriginal"` // 是否原创:后台新增为true,媒资导入为false + ShowOriginal bool `json:"showOriginal"` // 页面是否展示原创标识 + MediaBusiness string `json:"mediaBusiness"` // 媒资业务来源,按媒资标题前缀记录,如 CGDJ;空表示未标记 + IsRecommend bool `json:"isRecommend"` // 是否推荐,true-推荐、false-不推荐 + ShowOnBanner bool `json:"showOnBanner"` // 是否显示在首页banner,true-显示、false-不显示 + Banner string `json:"banner"` // banner图 + ReviewAt time.Time `json:"reviewAt"` // 审核时间 + OnlineAt time.Time `json:"onlineAt"` // 上架时间 + Title string `json:"title"` // 标题 + Slug string `json:"slug"` // slug + Desc string `json:"desc"` // 简介 + Anchor string `json:"anchor"` // 作者/演员 + Cover string `json:"cover"` // 封面原图 + CompressedCover string `json:"compressedCover"` // 压缩封面 + Preview string `json:"preview"` // 预告 + CategoryIds []int64 `json:"categoryIds"` // 分类ID集合 + CategoryBinds []PlayletCategoryBind `json:"categoryBinds"` // 各分类下的排序 + CategoryRank int64 `json:"categoryRank"` // 当前筛选分类下的排序 + CategoryNames []string `json:"categoryNames"` // 分类名称集合 + ThemeIds []int64 `json:"themeIds"` // 题材ID集合 + ThemeNames []string `json:"themeNames"` // 题材名称集合 + TagIds []int64 `json:"tagIds"` // 标签ID集合 + TagNames []string `json:"tagNames"` // 标签名称集合 + OldTags []string `json:"oldTags"` // 媒资库原标签名称 + ContentType playletmod.ContentType `json:"contentType"` // 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + PayType playletmod.PayType `json:"payType"` // 支付方式,0-免费、1-金币、2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时生效 + TotalEpisode int64 `json:"totalEpisode"` // 总集数 + FreeEpisodeCount int64 `json:"freeEpisodeCount"` // 官网开放集数 1至1000表示开放第1集到第N集 -1表示开放全集 历史未配置按2集返回 + UpdateStatus int `json:"updateStatus"` // 连载状态,1-连载中、2-已完结 + TopRank int64 `json:"topRank"` // 置顶排序 + RecommendRank int64 `json:"recommendRank"` // 推荐排序 + CountComment int64 `json:"countComment"` // 评论数 + CountBuy int64 `json:"countBuy"` // 购买数 + CountBrowse int64 `json:"countBrowse"` // 浏览数 + VirtualCountBrowse int64 `json:"virtualCountBrowse"` // 后台虚拟观看数 + DisplayCountBrowse int64 `json:"displayCountBrowse"` // 展示观看数 + HeatValue int64 `json:"heatValue"` // 热度值 + VirtualHeatValue int64 `json:"virtualHeatValue"` // 后台虚拟热度值 + DisplayHeatValue int64 `json:"displayHeatValue"` // 展示热度值 + CountCollect int64 `json:"countCollect"` // 收藏数 + CountLike int64 `json:"countLike"` // 真实点赞数 + VirtualCountLike int64 `json:"virtualCountLike"` // 虚拟点赞数 + DisplayCountLike int64 `json:"displayCountLike"` // 展示点赞数=真实点赞数+虚拟点赞数 + CountDisLike int64 `json:"countDisLike"` // 不喜欢数 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + OperatorId int64 `json:"operatorId"` // 最后操作人ID + OperatorName string `json:"operatorName"` // 最后操作人名称 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// PlayletListPageResp web端短剧分页列表响应 +type PlayletListPageResp struct { + response.BasePageResp +} + +// PlayletCategoryBind 短剧在指定分类下的排序 +type PlayletCategoryBind struct { + Id int64 `json:"id"` // 分类ID + Rank int64 `json:"rank"` // 排序值,越大越靠前,不填为0 +} + +// PlayletDelReq 删除短剧请求 +type PlayletDelReq struct { + Ids []int64 `json:"ids" binding:"required"` // 短剧ID集合 +} + +// PlayletAddReq web端新增短剧请求 +type PlayletAddReq struct { + CategoryIds []int64 `json:"categoryIds"` // 分类ID集合 + CategoryBinds []PlayletCategoryBind `json:"categoryBinds"` // 各分类下的排序,优先于categoryIds + ThemeIds []int64 `json:"themeIds"` // 题材ID集合 + Title string `json:"title" binding:"required"` // 标题 + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或标题自动生成 + Desc string `json:"desc"` // 简介 + Anchor string `json:"anchor"` // 作者/演员 + Cover string `json:"cover"` // 封面原图 + Preview string `json:"preview"` // 预告 + TagIds []int64 `json:"tagIds"` // 标签ID集合 + ContentType playletmod.ContentType `json:"contentType"` // 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + PayType playletmod.PayType `json:"payType"` // 支付方式,0-免费、1-金币、2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时生效 + TotalEpisode int64 `json:"totalEpisode"` // 总集数 + FreeEpisodeCount *int64 `json:"freeEpisodeCount"` // 官网开放集数 不传默认2集 1至1000表示开放第1集到第N集 -1表示开放全集 0无效 + TopRank int64 `json:"topRank"` // 置顶排序 + RecommendRank int64 `json:"recommendRank"` // 推荐排序 + Status playletmod.Status `json:"status"` // 上架状态,0-下架、1-上架 + IsFeatured bool `json:"isFeatured"` // 是否爆剧,true-是、false-否 + ShowOriginal bool `json:"showOriginal"` // 页面是否展示原创标识 + IsRecommend bool `json:"isRecommend"` // 是否推荐,true-推荐、false-不推荐 + ShowOnBanner bool `json:"showOnBanner"` // 是否显示在首页banner + Banner string `json:"banner"` // banner图 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletAddResp web端新增短剧响应 +type PlayletAddResp struct { + Id int64 `json:"id"` // 短剧ID +} + +// PlayletUpdateReq web端更新短剧请求 +type PlayletUpdateReq struct { + Id int64 `json:"id" binding:"required"` // 短剧ID + Status *playletmod.Status `json:"status"` // 上架状态,0-下架、1-上架 + IsFeatured *bool `json:"isFeatured"` // 是否爆剧,true-是、false-否 + ShowOriginal *bool `json:"showOriginal"` // 页面是否展示原创标识 不传保持原值 + IsRecommend *bool `json:"isRecommend"` // 是否推荐,true-推荐、false-不推荐 + ShowOnBanner *bool `json:"showOnBanner"` // 是否显示在首页banner + Banner *string `json:"banner"` // banner图 + CategoryIds *[]int64 `json:"categoryIds"` // 分类ID集合,传空数组表示清空分类 + CategoryBinds *[]PlayletCategoryBind `json:"categoryBinds"` // 各分类下的排序,优先于categoryIds + ThemeIds *[]int64 `json:"themeIds"` // 题材ID集合 + Title *string `json:"title"` // 标题 + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Desc *string `json:"desc"` // 简介 + Anchor *string `json:"anchor"` // 作者/演员 + Cover *string `json:"cover"` // 封面原图 + Preview *string `json:"preview"` // 预告 + TagIds *[]int64 `json:"tagIds"` // 标签ID集合,传空数组表示清空标签 + ContentType *playletmod.ContentType `json:"contentType"` // 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + PayType *playletmod.PayType `json:"payType"` // 支付方式,0-免费、1-金币、2-VIP + SingleCoins *int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时生效 + TotalEpisode *int64 `json:"totalEpisode"` // 总集数 + FreeEpisodeCount *int64 `json:"freeEpisodeCount"` // 官网可播放范围 不传则不修改 传1至1000时仅开放第1集至指定集数 传-1开放全部剧集 传0报参数错误 + TopRank *int64 `json:"topRank"` // 置顶排序 + RecommendRank *int64 `json:"recommendRank"` // 推荐排序 + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletPayTypeSetReq web端单/批量设置短剧默认支付方式请求 +type PlayletPayTypeSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + PayType playletmod.PayType `json:"payType"` // 支付方式,0-免费、1-金币、2-VIP + SingleCoins *int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时必填且需大于0 +} + +// PlayletWatermarkResetReq web端按短剧ID批量重置剧集水印请求 +type PlayletWatermarkResetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条,传多个表示批量 +} + +// PlayletChapterListReq web端短剧剧集分页列表请求 +type PlayletChapterListReq struct { + pubdef.Page + PlayletId *int64 `form:"playletId" json:"playletId"` // 短剧ID + Id *int64 `form:"id" json:"id"` // 剧集ID + WatermarkStatus *playletchaptermod.WatermarkStatus `form:"watermarkStatus" json:"watermarkStatus"` // 水印状态 0-未处理 1-已登记 2-处理中 3-已完成 4-处理失败 +} + +// PlayletChapterListResp web端短剧剧集列表项 +type PlayletChapterListResp struct { + Id int64 `json:"id"` // 剧集ID + PlayletId int64 `json:"playletId"` // 短剧ID + MediaContentId uint `json:"mediaContentId"` // 媒资中心剧集ID + CurrentEpisode int64 `json:"currentEpisode"` // 第几集 + Title string `json:"title"` // 剧集标题 + Md5 string `json:"md5"` // 媒体md5 + Hash string `json:"hash"` // 媒体hash + Height int64 `json:"height"` // 视频高度 + Width int64 `json:"width"` // 视频宽度 + Ratio float64 `json:"ratio"` // 宽高比 + VideoUrl string `json:"videoUrl"` // 原片视频地址 + WatermarkVideoUrl string `json:"watermarkVideoUrl"` // 加水印后的视频地址 + MediaSize int64 `json:"mediaSize"` // 资源大小 + MediaTime int64 `json:"mediaTime"` // 影片长度,单位秒 + CountComment int64 `json:"countComment"` // 评论数 + CountBuy int64 `json:"countBuy"` // 购买数 + CountBrowse int64 `json:"countBrowse"` // 浏览数 + CountCollect int64 `json:"countCollect"` // 收藏数 + CountLike int64 `json:"countLike"` // 点赞数 + CountDisLike int64 `json:"countDisLike"` // 不喜欢数 + PayType playletchaptermod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时生效 + WatermarkStatus playletchaptermod.WatermarkStatus `json:"watermarkStatus"` // 水印状态 0-未处理 1-已登记 2-处理中 3-已完成 4-处理失败 + MediaSourceId string `json:"mediaSourceId"` // media切片服原片ID + MediaTaskId string `json:"mediaTaskId"` // media切片服水印任务ID + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// PlayletChapterListPageResp web端短剧剧集分页列表响应 +type PlayletChapterListPageResp struct { + response.BasePageResp +} + +// PlayletChapterAddReq web端新增短剧剧集请求 +type PlayletChapterAddReq struct { + PlayletId int64 `json:"playletId" binding:"required"` // 短剧ID + CurrentEpisode int64 `json:"currentEpisode" binding:"required"` // 第几集 + Title string `json:"title"` // 剧集标题 + Md5 string `json:"md5"` // 媒体md5 + Hash string `json:"hash"` // 媒体hash + Height int64 `json:"height"` // 视频高度 + Width int64 `json:"width"` // 视频宽度 + Ratio float64 `json:"ratio"` // 宽高比 + VideoUrl string `json:"videoUrl"` // 原片视频地址 + MediaSize int64 `json:"mediaSize"` // 资源大小 + MediaTime int64 `json:"mediaTime"` // 影片长度,单位秒 + PayType playletchaptermod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时生效 + MediaSourceId string `json:"mediaSourceId"` // media切片服原片ID,上传正片已创建原片时直接沿用 +} + +// PlayletChapterAddResp web端新增短剧剧集响应 +type PlayletChapterAddResp struct { + Id int64 `json:"id"` // 剧集ID +} + +// PlayletChapterUpdateReq web端编辑短剧剧集请求 +type PlayletChapterUpdateReq struct { + Id int64 `json:"id" binding:"required"` // 剧集ID + CurrentEpisode *int64 `json:"currentEpisode"` // 第几集 + Title *string `json:"title"` // 剧集标题 + Md5 *string `json:"md5"` // 媒体md5 + Hash *string `json:"hash"` // 媒体hash + Height *int64 `json:"height"` // 视频高度 + Width *int64 `json:"width"` // 视频宽度 + Ratio *float64 `json:"ratio"` // 宽高比 + VideoUrl *string `json:"videoUrl"` // 原片视频地址 + MediaSize *int64 `json:"mediaSize"` // 资源大小 + MediaTime *int64 `json:"mediaTime"` // 影片长度,单位秒 + PayType *playletchaptermod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins *int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时生效 +} + +// PlayletChapterDelReq web端单/批量删除短剧剧集请求 +type PlayletChapterDelReq struct { + Ids []int64 `json:"ids"` // 剧集ID集合,传1个表示单条删除,传多个表示批量删除 +} + +// PlayletChapterSyncReq web端手动同步短剧剧集请求 +type PlayletChapterSyncReq struct { + PlayletIds []int64 `json:"playletIds" binding:"required"` // 短剧ID集合,传1个表示单条同步,传多个表示批量同步 +} + +// PlayletChapterSyncResult web端手动同步短剧剧集结果项 +type PlayletChapterSyncResult struct { + PlayletId int64 `json:"playletId"` // 短剧ID + NewChapterCount int `json:"newChapterCount"` // 本次新增剧集数量 + Success bool `json:"success"` // 是否同步成功 + Reason string `json:"reason"` // 失败原因 +} + +// PlayletChapterSyncResp web端手动同步短剧剧集响应 +type PlayletChapterSyncResp struct { + Total int `json:"total"` // 同步总数 + SuccessCount int `json:"successCount"` // 成功数量 + FailedCount int `json:"failedCount"` // 失败数量 + List []*PlayletChapterSyncResult `json:"list"` // 同步结果集合 +} + +// PlayletChapterPayTypeSetReq web端单/批量设置短剧剧集支付方式请求 +type PlayletChapterPayTypeSetReq struct { + Ids []int64 `json:"ids"` // 剧集ID集合,传1个表示单条设置,传多个表示批量设置 + PayType playletchaptermod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins *int64 `json:"singleCoins"` // 单集金币,支付方式为1-金币时必填且需大于0 +} + +// PlayletStatusSetReq web端单/批量设置短剧上下架请求 +type PlayletStatusSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + Status playletmod.Status `json:"status"` // 上架状态,0-下架、1-上架 +} + +// PlayletRecommendSetReq web端单/批量设置短剧推荐请求 +type PlayletRecommendSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + IsRecommend bool `json:"isRecommend"` // 是否推荐,true-推荐、false-不推荐 +} + +// PlayletFeaturedSetReq web端单/批量设置短剧是否爆剧请求 +type PlayletFeaturedSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + IsFeatured bool `json:"isFeatured"` // 是否爆剧,true-是、false-否 +} + +// PlayletBannerSetReq web端单/批量设置短剧是否显示在首页banner请求 +type PlayletBannerSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + ShowOnBanner bool `json:"showOnBanner"` // 是否显示在首页banner,true-显示、false-不显示 +} + +// PlayletCategorySetReq web端单/批量设置短剧分类请求 +type PlayletCategorySetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + CategoryIds *[]int64 `json:"categoryIds"` // 分类ID集合,可设置多个分类,传空数组表示清空分类,不传表示参数错误 +} + +// PlayletTagSetReq web端单/批量设置短剧标签请求 +type PlayletTagSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + TagIds *[]int64 `json:"tagIds"` // 标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误 +} + +// PlayletThemeSetReq web端单/批量设置短剧题材请求 +type PlayletThemeSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + ThemeIds []int64 `json:"themeIds"` // 题材ID集合,可设置多个题材 +} + +// PlayletVirtualLikeSetReq web端单/批量设置短剧虚拟点赞数请求 +type PlayletVirtualLikeSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + MinVirtualCountLike int64 `json:"minVirtualCountLike"` // 虚拟点赞数最小值,必须大于等于0 + MaxVirtualCountLike int64 `json:"maxVirtualCountLike"` // 虚拟点赞数最大值,必须大于等于最小值 +} + +// PlayletVirtualBrowseSetReq web端单/批量设置短剧虚拟观看数请求 +type PlayletVirtualBrowseSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合 + MinVirtualCountBrowse int64 `json:"minVirtualCountBrowse"` // 虚拟观看数最小值 + MaxVirtualCountBrowse int64 `json:"maxVirtualCountBrowse"` // 虚拟观看数最大值 +} + +// PlayletVirtualHeatSetReq web端单/批量设置短剧虚拟热度请求 +type PlayletVirtualHeatSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合 + MinVirtualHeatValue int64 `json:"minVirtualHeatValue"` // 虚拟热度最小值 + MaxVirtualHeatValue int64 `json:"maxVirtualHeatValue"` // 虚拟热度最大值 +} diff --git a/web/api/proto/playlet_category.go b/web/api/proto/playlet_category.go new file mode 100644 index 0000000..7a776f6 --- /dev/null +++ b/web/api/proto/playlet_category.go @@ -0,0 +1,67 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// PlayletCategoryListReq web端分类分页列表请求 +type PlayletCategoryListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` // 分类ID + Name string `form:"name" json:"name"` // 分类名称,支持模糊查询 + Status *bool `form:"status" json:"status"` // 启用状态 +} + +// PlayletCategoryListResp web端分类列表项 +type PlayletCategoryListResp struct { + Id int64 `json:"id"` // 分类ID + Name string `json:"name"` // 分类名称 + Slug string `json:"slug"` // slug + Icon string `json:"icon"` // 分类图标 + Status bool `json:"status"` // 启用状态 + Rank int64 `json:"rank"` // 排序,越大越靠前 + Remark string `json:"remark"` // 备注 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// PlayletCategoryListPageResp web端分类分页列表响应 +type PlayletCategoryListPageResp struct { + response.BasePageResp +} + +// PlayletCategoryAddReq web端新增分类请求 +type PlayletCategoryAddReq struct { + Name string `json:"name" binding:"required"` // 分类名称 + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或名称自动生成 + Icon string `json:"icon"` // 分类图标 + Status bool `json:"status"` // 启用状态 + Rank int64 `json:"rank"` // 排序,越大越靠前 + Remark string `json:"remark"` // 备注 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletCategoryAddResp web端新增分类响应 +type PlayletCategoryAddResp struct { + Id int64 `json:"id"` // 分类ID +} + +// PlayletCategoryUpdateReq web端更新分类请求 +type PlayletCategoryUpdateReq struct { + Id int64 `json:"id" binding:"required"` // 分类ID + Name *string `json:"name"` // 分类名称 + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Icon *string `json:"icon"` // 分类图标 + Status *bool `json:"status"` // 启用状态 + Rank *int64 `json:"rank"` // 排序,越大越靠前 + Remark *string `json:"remark"` // 备注 + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletCategoryDelReq web端删除分类请求 +type PlayletCategoryDelReq struct { + Id int64 `json:"id" binding:"required"` // 分类ID +} diff --git a/web/api/proto/playlet_home_config.go b/web/api/proto/playlet_home_config.go new file mode 100644 index 0000000..dbabb0b --- /dev/null +++ b/web/api/proto/playlet_home_config.go @@ -0,0 +1,114 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// PlayletHomeConfigListReq web端首页配置分页列表请求 +type PlayletHomeConfigListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` + Slug string `form:"slug" json:"slug"` // 页面路径标识 + Title string `form:"title" json:"title"` + Desc string `form:"desc" json:"desc"` + Label string `form:"label" json:"label"` + Style string `form:"style" json:"style"` + ModuleType *int64 `form:"moduleType" json:"moduleType"` + DataType *int64 `form:"dataType" json:"dataType"` // 1全部上架短剧 2全部上架视频 + RecommendType *int64 `form:"recommendType" json:"recommendType"` + CategoryId *int64 `form:"categoryId" json:"categoryId"` + Enabled *bool `form:"enabled" json:"enabled"` +} + +// PlayletHomeConfigListResp web端首页配置列表项 +type PlayletHomeConfigListResp struct { + Id int64 `json:"id"` + Slug string `json:"slug"` // 页面路径标识 + Title string `json:"title"` + Desc string `json:"desc"` + Label string `json:"label"` + Style string `json:"style"` + CardBackgroundImage string `json:"cardBackgroundImage"` + ModuleType int64 `json:"moduleType"` + DataType int64 `json:"dataType"` // 1全部上架短剧 2全部上架视频 + RecommendType int64 `json:"recommendType"` + CategoryId int64 `json:"categoryId"` + TopicIds []int64 `json:"topicIds"` // 专题ID集合 短剧为题材ID 视频为分类ID + Rank int64 `json:"rank"` + Enabled bool `json:"enabled"` + Limit int64 `json:"limit"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PlayletHomeConfigListPageResp struct { + response.BasePageResp +} + +// PlayletHomeTopicOptionsReq 首页专题下拉选项分页请求 +type PlayletHomeTopicOptionsReq struct { + pubdef.Page + DataType int64 `form:"dataType" json:"dataType" binding:"required"` // 1短剧题材 2视频分类 + ConfigId int64 `form:"configId" json:"configId"` // 编辑时传当前首页配置ID,用于回显已选项 +} + +// PlayletHomeTopicOption 首页专题下拉选项 +type PlayletHomeTopicOption struct { + Id int64 `json:"id"` // 题材或视频分类ID + Name string `json:"name"` // 题材或视频分类名称 + Status bool `json:"status"` // 上架状态,已选项下架时仍可回显 +} + +// PlayletHomeTopicOptionsResp 首页专题下拉选项分页响应 +type PlayletHomeTopicOptionsResp struct { + List []*PlayletHomeTopicOption `json:"list"` // 当前页可选项 + SelectedList []*PlayletHomeTopicOption `json:"selectedList"` // 当前配置已选项,独立于分页 + TotalCount int64 `json:"totalCount"` // 可选项总数 + PageNum int64 `json:"pageNum"` // 当前页码 + PageSize int64 `json:"pageSize"` // 每页数量 +} + +type PlayletHomeConfigAddReq struct { + Title string `json:"title" binding:"required"` + Slug string `json:"slug"` // 页面路径标识 不传则按标题自动生成 + Desc string `json:"desc"` + Label string `json:"label"` + Style string `json:"style" binding:"required"` + CardBackgroundImage string `json:"cardBackgroundImage"` + ModuleType int64 `json:"moduleType"` + DataType int64 `json:"dataType"` // 1全部上架短剧 2全部上架视频 + RecommendType int64 `json:"recommendType" binding:"required"` + CategoryId int64 `json:"categoryId"` + TopicIds []int64 `json:"topicIds"` // 专题ID集合 短剧为题材ID 视频为分类ID + Rank int64 `json:"rank"` + Enabled bool `json:"enabled"` + Limit int64 `json:"limit"` +} + +type PlayletHomeConfigAddResp struct { + Id int64 `json:"id"` +} + +type PlayletHomeConfigUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Slug *string `json:"slug"` // 不传保持原值 历史空值自动生成 + Title *string `json:"title"` + Desc *string `json:"desc"` + Label *string `json:"label"` + Style *string `json:"style"` + CardBackgroundImage *string `json:"cardBackgroundImage"` + ModuleType *int64 `json:"moduleType"` + DataType *int64 `json:"dataType"` // 1全部上架短剧 2全部上架视频 + RecommendType *int64 `json:"recommendType"` + CategoryId *int64 `json:"categoryId"` + TopicIds *[]int64 `json:"topicIds"` // 不传保持原值 传空数组清空专题 + Rank *int64 `json:"rank"` + Enabled *bool `json:"enabled"` + Limit *int64 `json:"limit"` +} + +type PlayletHomeConfigDelReq struct { + Ids []int64 `json:"ids" binding:"required"` +} diff --git a/web/api/proto/playlet_resource.go b/web/api/proto/playlet_resource.go new file mode 100644 index 0000000..16d4f7f --- /dev/null +++ b/web/api/proto/playlet_resource.go @@ -0,0 +1,91 @@ +package proto + +// PlayletResourceListReq web端短剧媒资列表请求 +type PlayletResourceListReq struct { + Title *string `form:"title" json:"title"` // 标题 + Tag *string `form:"tag" json:"tag"` // 标签 + MediaType *int `form:"media_type" json:"media_type"` // 媒体类型 1-动漫 2-漫画 4-漫画短剧 5-短剧 6-普通短剧 + SyncType *int `form:"sync_type" json:"sync_type"` // 同步类型 1-全量同步 2-增量同步 + PageNumber int `form:"pageNumber" json:"pageNumber"` // 页码 + PageSize int `form:"pageSize" json:"pageSize"` // 个数 +} + +// PlayletResourceListItem web端短剧媒资列表项 +type PlayletResourceListItem struct { + ID uint `json:"id"` + VloggerId uint `json:"vlogger_id"` + Title string `json:"title"` // 标题 + SaleCoin uint `json:"sale_coin"` // 整部售价 + HorizontalCover string `json:"horizontal_cover"` // 横版封面 + VerticalCover string `json:"vertical_cover"` // 竖版封面 + Summary string `json:"summary"` // 简介 + TotalEpisode uint `json:"total_episode"` // 总集数 + UpdateStatus int `json:"update_status"` // 更新状态 1-连载中 2-已完结 + MediaType int `json:"media_type"` // 媒体类型 + Anchor string `json:"anchor"` // 作者 + TagsText string `json:"tags_text"` // 标签文字 + DataVersion int8 `json:"data_version"` // 数据版本 + TerminalCdo string `json:"terminal_cdo"` // 上传者 + CreateTime int64 `json:"create_time"` // 创建时间 + UpdateTime int64 `json:"update_time"` // 修改时间 + IsAdd bool `json:"isAdd"` // 是否已加入后台数据 +} + +// PlayletResourceListResp web端短剧媒资列表响应 +type PlayletResourceListResp struct { + List []*PlayletResourceListItem `json:"list"` + Total uint `json:"total"` +} + +// PlayletResourceUseReq web端短剧媒资入库请求 +type PlayletResourceUseReq struct { + Ids []uint `json:"ids"` // 媒资中心资源ID集合 +} + +// PlayletResourceChapterListReq web端短剧媒资剧集列表请求 +type PlayletResourceChapterListReq struct { + Id uint `form:"id" json:"id" binding:"required"` // 媒资中心资源ID +} + +// PlayletResourceChapterItem web端短剧媒资剧集项 +type PlayletResourceChapterItem struct { + Id uint `json:"id"` // 媒资内容ID + EpisodeNumber int `json:"episodeNumber"` // 第几集 + Title string `json:"title"` // 章节名 + Cover string `json:"cover"` // 封面 + VideoUrl string `json:"videoUrl"` // 播放地址 + Height uint `json:"height"` // 高 + Width uint `json:"width"` // 宽 + MediaSize uint `json:"mediaSize"` // 资源大小 + PlayTime uint `json:"playTime"` // 播放时长(秒) + SaleCoin uint `json:"saleCoin"` // 单集售价 + HashId string `json:"hashId"` // 资源hash + CreateTime int64 `json:"createTime"` // 创建时间 + UpdateTime int64 `json:"updateTime"` // 更新时间 +} + +// PlayletResourceChapterListResp web端短剧媒资剧集列表响应 +type PlayletResourceChapterListResp struct { + MediaId uint `json:"mediaId"` // 媒资中心资源ID + Title string `json:"title"` // 短剧标题 + List []*PlayletResourceChapterItem `json:"list"` // 剧集列表 + Total int `json:"total"` // 剧集数量 +} + +// PlayletResourceUseSuccess web端短剧媒资入库成功项 +type PlayletResourceUseSuccess struct { + MediaId uint `json:"mediaId"` + PlayletId int64 `json:"playletId"` +} + +// PlayletResourceUseFailed web端短剧媒资入库失败项 +type PlayletResourceUseFailed struct { + MediaId uint `json:"mediaId"` + Reason string `json:"reason"` +} + +// PlayletResourceUseResp web端短剧媒资入库响应 +type PlayletResourceUseResp struct { + Success []*PlayletResourceUseSuccess `json:"success"` + Failed []*PlayletResourceUseFailed `json:"failed"` +} diff --git a/web/api/proto/playlet_tag.go b/web/api/proto/playlet_tag.go new file mode 100644 index 0000000..9df5fcb --- /dev/null +++ b/web/api/proto/playlet_tag.go @@ -0,0 +1,58 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// PlayletTagListReq web端标签分页列表请求 +type PlayletTagListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` + Name string `form:"name" json:"name"` + Status *bool `form:"status" json:"status"` +} + +// PlayletTagListResp web端标签列表项 +type PlayletTagListResp struct { + Id int64 `json:"id"` + Name string `json:"name"` + Status bool `json:"status"` + Rank int64 `json:"rank"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PlayletTagListPageResp web端标签分页列表响应 +type PlayletTagListPageResp struct { + response.BasePageResp +} + +// PlayletTagAddReq web端新增标签请求 +type PlayletTagAddReq struct { + Name string `json:"name" binding:"required"` + Status bool `json:"status"` + Rank int64 `json:"rank"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletTagAddResp web端新增标签响应 +type PlayletTagAddResp struct { + Id int64 `json:"id"` +} + +// PlayletTagUpdateReq web端更新标签请求 +type PlayletTagUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Name *string `json:"name"` + Status *bool `json:"status"` + Rank *int64 `json:"rank"` + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletTagDelReq web端删除标签请求 +type PlayletTagDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/api/proto/playlet_theme.go b/web/api/proto/playlet_theme.go new file mode 100644 index 0000000..0cd4e93 --- /dev/null +++ b/web/api/proto/playlet_theme.go @@ -0,0 +1,64 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// PlayletThemeListReq web端题材分页列表请求 +type PlayletThemeListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` // 题材ID + Name string `form:"name" json:"name"` // 题材名称,支持模糊查询 + Status *bool `form:"status" json:"status"` // 上架状态 +} + +// PlayletThemeListResp web端题材列表项 +type PlayletThemeListResp struct { + Id int64 `json:"id"` // 题材ID + Name string `json:"name"` // 题材名称 + Slug string `json:"slug"` // slug + Icon string `json:"icon"` // 题材图标 + Status bool `json:"status"` // 上架状态 + Rank int64 `json:"rank"` // 排序,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// PlayletThemeListPageResp web端题材分页列表响应 +type PlayletThemeListPageResp struct { + response.BasePageResp +} + +// PlayletThemeAddReq web端新增题材请求 +type PlayletThemeAddReq struct { + Name string `json:"name" binding:"required"` // 题材名称 + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或名称自动生成 + Icon string `json:"icon"` // 题材图标 + Status bool `json:"status"` // 上架状态 + Rank int64 `json:"rank"` // 排序,越大越靠前 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletThemeAddResp web端新增题材响应 +type PlayletThemeAddResp struct { + Id int64 `json:"id"` // 题材ID +} + +// PlayletThemeUpdateReq web端更新题材请求 +type PlayletThemeUpdateReq struct { + Id int64 `json:"id" binding:"required"` // 题材ID + Name *string `json:"name"` // 题材名称 + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Icon *string `json:"icon"` // 题材图标 + Status *bool `json:"status"` // 上架状态 + Rank *int64 `json:"rank"` // 排序,越大越靠前 + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletThemeDelReq web端删除题材请求 +type PlayletThemeDelReq struct { + Id int64 `json:"id" binding:"required"` // 题材ID +} diff --git a/web/api/proto/slug.go b/web/api/proto/slug.go new file mode 100644 index 0000000..f3c5a44 --- /dev/null +++ b/web/api/proto/slug.go @@ -0,0 +1,21 @@ +package proto + +// SlugSyncReq 后台补齐历史空 slug 请求 +type SlugSyncReq struct { + MaxSize int64 `json:"maxSize"` // 本次最多处理的空 slug 记录数,默认500,最大2000 + Type string `json:"type"` // 可选,指定类型;空则按顺序处理全部 +} + +// SlugSyncResp 后台补齐历史空 slug 响应 +type SlugSyncResp struct { + VideoUpdated int64 `json:"videoUpdated"` // 本次补齐的视频数 + VideoCategoryUpdated int64 `json:"videoCategoryUpdated"` // 本次补齐的视频分类数 + ActressUpdated int64 `json:"actressUpdated"` // 本次补齐的女优数 + VideoCreaterUpdated int64 `json:"videoCreaterUpdated"` // 本次补齐的作者数 + PlayletUpdated int64 `json:"playletUpdated"` // 本次补齐的短剧数 + PlayletCategoryUpdated int64 `json:"playletCategoryUpdated"` // 本次补齐的短剧分类数 + PlayletThemeUpdated int64 `json:"playletThemeUpdated"` // 本次补齐的短剧主题数 + ContentTagUpdated int64 `json:"contentTagUpdated"` // 本次补齐的统一标签数 + TotalUpdated int64 `json:"totalUpdated"` // 本次合计补齐数 + HasMore bool `json:"hasMore"` // 是否还有未处理的历史数据 +} diff --git a/web/api/web_acc/acc.go b/web/api/web_acc/acc.go new file mode 100644 index 0000000..444dbb9 --- /dev/null +++ b/web/api/web_acc/acc.go @@ -0,0 +1,356 @@ +package web_acc + +import ( + "time" + "videoweb/pub/common" + "videoweb/pub/common/crypt" + "videoweb/pub/common/srvdef" + "videoweb/pub/google" + "videoweb/pub/models/mwacc" + "videoweb/web/service/srv_acc" + "videoweb/web/service/srv_mwacc" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +// @Tags 账号 +// @Summary 账号-列表 +// @Description 账号-列表 +// @Accept json +// @Produce json +// @Param param body MwAccListReq true "参数列表" +// @Success 200 {object} MwAccListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/list [GET] +func AccGetList(c *gin.Context) { + + var req MwAccListReq + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + search := mwacc.MakeSearch(req.KeyWords, req.Status, req.Page) + sort := mwacc.MakeSort(req.PageNum, req.PageSize) + + total, data, _ := mwacc.GetAccList(c, search, sort) + + resp := MwAccListResp{ + TotalCount: total, + AccRec: make([]*MwAccRec, 0), + } + + for _, v := range data { + resp.AccRec = append(resp.AccRec, &MwAccRec{ + Id: v.Id, + UserName: v.Username, + Acc: v.Acc, + Pwd: v.Pwd, + Email: v.Email, + RoleId: v.RoleId, + RoleName: v.RoleName, + // Base32: v.Base32, + IsBase32: v.IsBase32, + IsEnb: v.IsEnb, + FeedBack: v.Feedback, + CreatedAt: v.CreatedAt.Unix(), + UpdatedAt: v.UpdatedAt.Unix(), + LimitIP: v.LimitIP, + }) + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// @Tags 账号 +// @Summary 账号-添加 +// @Description 账号-添加 +// @Accept json +// @Produce json +// @Param param body MwAccAddReq true "参数列表" +// @Success 200 {object} MwAccAddResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/add [POST] +func AccAdd(c *gin.Context) { + var req MwAccAddReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + accCount, err := mwacc.AccCountByFilter(c, bson.M{ + mwacc.FN.MwAcc.Acc: req.Acc, + }) + if accCount > 0 || err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "用户已存在") + return + } + + secret := google.NewGoogleAuth().GetSecret() + + insertDoc := &mwacc.AccInsertDoc{ + UserName: req.Name, + Pwd: crypt.StrToMd5(req.Pwd), //md5V(req.Pwd), + Acc: req.Acc, + Email: req.Email, + RoleId: req.RoleId, + Base32: secret, + IsBase32: false, + IsEnb: req.IsEnb, + LimitIP: req.LimitIP, + } + + _, _, err = mwacc.AccAdd(c, insertDoc) + if err != nil { + common.ServerJSON(c, srvdef.Failure, MwAccAddResp{Result: false}) + return + } + common.ServerJSON(c, srvdef.Success, MwAccAddResp{Result: true}) + return +} + +// @Tags 账号 +// @Summary 账号-删除 +// @Description 账号-删除 +// @Accept json +// @Produce json +// @Param param body MwAccDelReq true "参数列表" +// @Success 200 {object} MwAccDelResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/del [POST] +func AccDel(c *gin.Context) { + + var req MwAccDelReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + err := mwacc.DelAccByFilter(c, bson.M{ + mwacc.FN.MwAcc.Id: req.Id, + }) + // 删除对应账号的token + _ = mwacc.DeleteOneToken(c, bson.M{ + mwacc.FN.MwAccToken.AccId: req.Id, + }) + + if err != nil { + common.ServerJSON(c, srvdef.Failure, MwAccDelResp{Result: true}) + return + } + common.ServerJSON(c, srvdef.Success, MwAccDelResp{Result: true}) + return +} + +// @Tags 账号 +// @Summary 账号-获取信息 +// @Description 账号-获取信息 +// @Accept json +// @Produce json +// @Param param body MwAccGetInfoReq true "参数列表" +// @Success 200 {object} MwAccGetInfoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/getinfo [GET] +func AccGetInfo(c *gin.Context) { + var req MwAccGetInfoReq + + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + rest, err := mwacc.FindOneAccByFilter(c, bson.M{ + mwacc.FN.MwAcc.Id: req.Id, + }) + if err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + resq := MwAccGetInfoResp{ + Id: rest.Id, + UserName: rest.UserName, + Acc: rest.Acc, + Pwd: rest.Pwd, + Email: rest.Email, + RoleId: rest.RoleId, + IsEnb: rest.IsEnb, + LimitIP: rest.LimitIP, + } + common.ServerJSON(c, srvdef.Success, resq) + return +} + +// @Tags 账号 +// @Summary 账号-更新 +// @Description 账号-更新 +// @Accept json +// @Produce json +// @Param param body MwAccUpReq true "参数列表" +// @Success 200 {object} MwAccUpResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/up [POST] +func AccUp(c *gin.Context) { + var req MwAccUpReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + _, err := srv_mwacc.AccUp(c, &srv_mwacc.AccUpDoc{ + Id: req.Id, + UserName: req.UserName, + Acc: req.Acc, + Email: req.Email, + RoleId: req.RoleId, + IsEnb: req.IsEnb, + LimitIP: req.LimitIP, + }) + if err != nil { + common.ServerJSON(c, srvdef.Failure, MwAccUpResp{Result: false}) + return + } + common.ServerJSON(c, srvdef.Success, MwAccUpResp{Result: true}) + return +} + +// @Tags 账号 +// @Summary 账号-获取状态 +// @Description 账号-获取状态 +// @Accept json +// @Produce json +// @Param param body MwAccGetStatusReq true "参数列表" +// @Success 200 {object} MwAccGetStatusResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/getstatus [GET] +func AccGetStatus(c *gin.Context) { + var req MwAccGetStatusReq + + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + rest, err := mwacc.FindOneAccByFilter(c, bson.M{ + mwacc.FN.MwAcc.Id: req.Id, + }) + + if err != nil { + common.ServerJSON(c, srvdef.Failure, err) + return + } + + resp := MwAccGetStatusResp{ + Id: rest.Id, + UserName: rest.UserName, + IsEnb: rest.IsEnb, + FeedBack: rest.Feedback, + } + + common.ServerJSON(c, srvdef.Success, resp) + return +} + +// @Tags 账号 +// @Summary 账号-保存状态 +// @Description 账号-保存状态 +// @Accept json +// @Produce json +// @Param param body MwAccSaveStatusReq true "参数列表" +// @Success 200 {object} MwAccSaveStatusResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/savestatus [POST] +func AccSaveStatus(c *gin.Context) { + + var req MwAccSaveStatusReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + err := mwacc.UpOneAccByFilter(c, bson.M{ + mwacc.FN.MwAcc.Id: req.Id, + }, bson.M{ + mwacc.FN.MwAcc.UserName: req.UserName, + mwacc.FN.MwAcc.IsEnb: req.IsEnb, + mwacc.FN.MwAcc.Feedback: req.FeedBack, + mwacc.FN.MwAcc.UpdatedAt: time.Now(), + }) + if err != nil { + common.ServerJSON(c, srvdef.Failure, "") + return + } + // 封禁时,删除token + if !req.IsEnb { + mwacc.DeleteOneToken(c, bson.M{mwacc.FN.MwAccToken.AccId: req.Id}) + } + + common.ServerJSON(c, srvdef.Success, MwAccSaveStatusResp{Result: true}) + return +} + +// @Tags 账号 +// @Summary 账号-获取操作记录 +// @Description 账号-获取操作记录 +// @Accept json +// @Produce json +// @Param param body AccOpLogReq true "参数列表" +// @Success 200 {object} AccOpLogResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/ops [POST] +func GetAccOpRecords(ctx *gin.Context) { + var req AccOpLogReq + + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + + total, data, err := srv_acc.GetOpLogs(ctx, &req.OpLogSearch) + if err != nil { + common.ServerJSON(ctx, srvdef.Failure, err.Error()) + return + } + resp := &AccOpLogResp{total, data} + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 账号 +// @Summary 账号-更新密码 +// @Description 账号-更新密码 +// @Accept json +// @Produce json +// @Param param body MwAccUpPwdReq true "参数列表" +// @Success 200 {object} MwAccUpPwdReq "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/acc/uppwd [POST] +func AccUpdatePwd(c *gin.Context) { + var req MwAccUpPwdReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + _, err := srv_mwacc.AccUpPwd(c, &srv_mwacc.AccUpPwdDoc{ + Id: req.Id, + Pwd: req.Pwd, + }) + if err != nil { + common.ServerJSON(c, srvdef.Failure, MwAccUpPwdResp{Result: false}) + return + } + common.ServerJSON(c, srvdef.Success, MwAccUpPwdResp{Result: true}) + return +} diff --git a/web/api/web_acc/login.go b/web/api/web_acc/login.go new file mode 100644 index 0000000..6918de7 --- /dev/null +++ b/web/api/web_acc/login.go @@ -0,0 +1,114 @@ +package web_acc + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/mwacc" + "videoweb/web/service/srv_mwacc" + + "github.com/gin-gonic/gin" +) + +// @Tags 登录 +// @Summary 登录-登录 +// @Description 登录-登录 +// @Accept json +// @Produce json +// @Param param body MainWebLoginReq true "参数列表" +// @Success 200 {object} MainWebLoginResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/web/login/acc [POST] +func AccLogin(c *gin.Context) { + var req MainWebLoginReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + accInfo, err := srv_mwacc.AccLogin(c, req.Acc, req.Pwd, req.Code) + if err != nil || accInfo == nil { + common.ServerJSON(c, err.Code, err.Msg) + return + } + + resp := MainWebLoginResp{} + + if len(accInfo.Qurl) != 0 { + resp.Qurl = accInfo.Qurl + } else { + // 成功保存token + if ok, _ := mwacc.SaveToken(c, accInfo.Id, accInfo.Token); ok != true { + common.ServerJSON(c, srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg()) + } + + resp.AccId = accInfo.Id + resp.Acc = accInfo.Acc + resp.RoleId = accInfo.RoleId + resp.Token = accInfo.Token + } + common.ServerJSON(c, srvdef.Success, resp) + return +} + +// SLogin doc +// @Summary 管理员sso登陆 +// @Description 管理员sso登陆 +// @Tags web-单点登陆 +// @Accept mpfd,json +// @Produce json,html +// @Param name formData string true "账号" +// @Param password formData string true "密码" +// @Success 200 {string} json "{"msg": "管理员信息""}" +// @Failure 400 {string} json "{"msg": "操作失败"}" +// @Router /api/web/login/slogin [post] +func SLogin(c *gin.Context) { + var req MainWebLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + accInfo, err := srv_mwacc.AccSLogin(c, req.Acc, req.Pwd) + if err != nil || accInfo == nil { + common.ServerJSON(c, err.Code, err.Msg) + return + } + resp := MainWebLoginResp{} + // 成功保存token + if ok, _ := mwacc.SaveToken(c, accInfo.Id, accInfo.Token); ok != true { + common.ServerJSON(c, srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg()) + } + resp.AccId = accInfo.Id + resp.Acc = accInfo.Acc + resp.RoleId = accInfo.RoleId + resp.Token = accInfo.Token + + common.ServerJSON(c, srvdef.Success, resp) + return +} + +// @Tags 登录 +// @Summary 登录-绑定二维码 +// @Description 登录-绑定二维码 +// @Accept json +// @Produce json +// @Param param body BindQurlReq true "参数列表" +// @Success 200 {object} BindQurlResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/web/login/bind [POST] +func BindQurl(c *gin.Context) { + var req BindQurlReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, "tone AccLogin arg error "+err.Error()) + return + } + + ok, _ := mwacc.BindAccQurl(c, req.Acc) + if !ok { + common.ServerJSON(c, srvdef.Failure, BindQurlResp{Result: false}) + return + } + common.ServerJSON(c, srvdef.Success, BindQurlResp{Result: true}) + return +} diff --git a/web/api/web_acc/protodef.go b/web/api/web_acc/protodef.go new file mode 100644 index 0000000..1eff43b --- /dev/null +++ b/web/api/web_acc/protodef.go @@ -0,0 +1,479 @@ +package web_acc + +import ( + "time" + "videoweb/pub/pubdef" + "videoweb/web/service/srv_acc" +) + +/////////////////////////登录////////////////////////// +/******************主后台登录***********************/ +// POST /web/login/acc + +// 请求参数 +type MainWebLoginReq struct { + // 账户 + Acc string `json:"acc,omitempty"` + // 密码 + Pwd string `json:"pwd,omitempty"` + // 验证码 + Code string `json:"code,omitempty"` +} + +// 返回参数 +type MainWebLoginResp struct { + // 账户id + AccId int64 `json:"accId"` + // 账户 + Acc string `json:"acc"` + // 角色id + RoleId int64 `json:"roleId"` + // token + Token string `json:"token"` + // 二维码 + Qurl []byte `json:"qurl"` +} + +/******************绑定二维码***********************/ +// POST /web/login/bind + +// 请求参数 +type BindQurlReq struct { + //账户 + Acc string `json:"acc,omitempty"` +} + +// 返回参数 +type BindQurlResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************绑定二维码***********************/ + +/////////////////////////角色////////////////////////// +// GET /web/role/list +// 请求参数 + +// 角色记录 +type MwRoleRec struct { + // 角色Id + Id int64 `json:"id"` + // 角色名称 + Name string `json:"name"` + // 描述 + Desc string `json:"desc"` + // 是否启用 false:禁用 true:启用 + IsEnb bool `json:"isEnb"` + // 已有权限的api + ApiMenus []string `json:"apiMenus"` + // 创建时间 + CreatedAt time.Time `json:"createdAt"` + // 更新时间 + UpdatedAt time.Time `json:"updatedAt"` +} + +// 返回参数 +type MwRoleListResp struct { + // 总共数量 + TotalCount int64 `json:"totalCount"` + // 当前页配置记录 + RoleRecs []*MwRoleRec `json:"roleRecs"` +} + +/******************角色添加***********************/ +// POST /web/role/add + +// 请求参数 +type MwRoleAddReq struct { + // 名称 + Name string `json:"name"` + // 描述 + Desc string `json:"desc"` + // 状态 + IsEnb bool `json:"isEnb"` +} + +// 返回参数 +type MwRoleAddResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************角色删除***********************/ +// POST /web/role/del + +// 请求参数 +type MwRoleDelReq struct { + // 角色id + Id int64 `json:"id,omitempty"` +} + +// 返回参数 +type MwRoleDelResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************角色获取信息***********************/ +// GET /web/role/getinfo + +// 请求参数 +type MwRoleGetInfoReq struct { + // 角色id + Id int64 `form:"id" json:"id,omitempty"` +} + +// 返回参数 +type MwRoleGetInfoResp struct { + RoleRec *MwRoleRec `json:"roleRec"` +} + +/******************角色更新***********************/ +// POST /web/role/up + +// 请求参数 +type MwRoleUpReq struct { + // 角色id + Id int64 `json:"id"` + // 名称 + Name string `json:"name"` + // 描述 + Desc string `json:"desc"` + // 状态 + IsEnb bool `json:"isEnb"` +} + +// 返回参数 +type MwRoleUpResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************角色使能***********************/ +// POST /web/role/enable + +// 请求参数 +type MwRoleEnableReq struct { + // 角色id + Id int64 `json:"id"` + // 状态 + IsEnb bool `json:"isEnb"` +} + +// 返回参数 +type MwRoleEnableResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************角色下拉***********************/ +// GET /web/role/drop + +// 请求参数 + +type MwRoleDrop struct { + // 角色Id + Id int64 `json:"id,omitempty"` + // 角色名称 + Name string `json:"name,omitempty"` +} + +// 返回参数 +type MwRoleDropResp struct { + // 是否成功 true:成功 false:未成功 + RoleDrop []*MwRoleDrop `json:"roleDrop"` +} + +/******************获取权限***********************/ +// GET /web/role/getauth + +type Router_Meta struct { + // + Title string `json:"title"` + // + Icon string `json:"icon"` + // + NoCache bool `json:"noCache"` +} + +type Router_ApiUrl struct { + // + Url []string `json:"url,omitempty"` + // + Desc string `json:"desc,omitempty"` + // + Status bool `json:"status,omitempty"` + // + Method []string `json:"method,omitempty"` +} + +type Router struct { + // + Path string `json:"path"` + // + Component string `json:"component"` + // + Redirect string `json:"redirect"` + // + Name string `json:"name"` + // + IsShow bool `json:"isShow"` + // + Hidden bool `json:"hidden"` + // + Meta *Router_Meta `json:"meta"` + // + Children []*Router `json:"children"` + ApiUrl []*Router_ApiUrl `json:"apiUrl"` +} + +// 请求参数 +type MwRoleGetAuthReq struct { + // 角色id + Id int64 `form:"id" json:"id,omitempty"` +} + +// 返回参数 +type MwRoleGetAuthResp struct { + // 角色id + Id int64 `json:"id,omitempty"` + // + Router []*Router `json:"router,omitempty"` +} + +/******************更新权限***********************/ +// POST /web/role/upauth + +// 请求参数 +type MwRoleUpAuthReq struct { + // 角色id + Id int64 `json:"id,omitempty"` + Router []*Router `json:"router,omitempty"` +} + +// 返回参数 +type MwRoleUpAuthResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/////////////////////////账号////////////////////////// + +type MwAccRec struct { + // 账号Id + Id int64 `json:"id"` + // 用户名 + UserName string `json:"userName"` + // 账号 + Acc string `json:"acc"` + // 密码 + Pwd string `json:"pwd"` + // 邮箱 + Email string `json:"email"` + // 角色Id + RoleId int64 `json:"roleId"` + // 角色名 + RoleName string `json:"roleName"` + // // 二步签名 + // Base32 string `json:"base32"` + // 是否已签名 + IsBase32 bool `json:"isBase32"` + // 使能 + IsEnb bool `json:"isEnb"` + //禁用原因 + FeedBack string `json:"feedBack"` + // 创建时间 + CreatedAt int64 `json:"createdAt"` + // 更新时间 + UpdatedAt int64 `json:"updatedAt"` + // 允许访问的IP + LimitIP []string `json:"limitIP"` +} + +/******************账号列表***********************/ +// GET /web/acc/list + +// 请求参数 +type MwAccListReq struct { + // 搜索条件 + KeyWords string `json:"keyWords" form:"keyWords"` + //状态 1正常 2 禁用 + Status int64 `json:"status" form:"status"` + // 页码 + pubdef.Page +} + +// 返回参数 +type MwAccListResp struct { + // 总共数量 + TotalCount int64 `json:"totalCount"` + // 当前页配置记录 + AccRec []*MwAccRec `json:"accRec"` +} + +/******************账号添加***********************/ +// POST /web/acc/add + +// 请求参数 +type MwAccAddReq struct { + // 名称 + Name string `json:"name"` + // 密码 + Pwd string `json:"pwd"` + // 账户 + Acc string `json:"acc"` + // 邮箱 + Email string `json:"email"` + // 角色ID + RoleId int64 `json:"roleId"` + // 使能 + IsEnb bool `json:"isEnb"` + // 允许访问的IP + LimitIP []string `json:"limitIP"` +} + +type MwAccAddResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************账号删除***********************/ +// POST web/mw/acc/del + +// 请求参数 +type MwAccDelReq struct { + // 账号Id + Id int64 `json:"id,omitempty"` +} + +// 返回参数 +type MwAccDelResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************账号获取信息***********************/ +// GET /web/acc/getinfo +// 请求参数 +type MwAccGetInfoReq struct { + // 角色id + Id int64 `form:"id" json:"id,omitempty"` +} + +// 返回参数 +type MwAccGetInfoResp struct { + // 账号Id + Id int64 `json:"id"` + // 用户名 + UserName string `json:"userName"` + // 账号 + Acc string `json:"acc"` + // 密码 + Pwd string `json:"pwd"` + // 邮箱 + Email string `json:"email"` + // 角色Id + RoleId int64 `json:"roleId"` + // 使能 + IsEnb bool `json:"isEnb"` + // 允许访问的IP + LimitIP []string `json:"limitIP"` +} + +/******************角色更新***********************/ +// POST web/mw/acc/up + +// 请求参数 +type MwAccUpReq struct { + // 账号Id + Id int64 `json:"id"` + // 用户名 + UserName string `json:"userName"` + // 账号 + Acc string `json:"acc"` + // 密码 + Pwd string `json:"pwd"` + // 邮箱 + Email string `json:"email"` + // 角色Id + RoleId int64 `json:"roleId"` + // 使能 + IsEnb bool `json:"isEnb"` + // 允许访问的IP + LimitIP []string `json:"limitIP"` +} + +// 返回参数 +type MwAccUpResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************账号获取状态***********************/ +// GET web/mw/acc/getstatus + +// 请求参数 +type MwAccGetStatusReq struct { + // 角色id + Id int64 `form:"id" json:"id,omitempty"` +} + +// 返回参数 +type MwAccGetStatusResp struct { + + // 账号Id + Id int64 `json:"id"` + // 用户名 + UserName string `json:"userName"` + // 使能 + IsEnb bool `json:"isEnb"` + //禁用原因 + FeedBack string `json:"feedBack"` +} + +/******************账号保存状态***********************/ +// POST web/mw/acc/savestatus +// 请求参数 +type MwAccSaveStatusReq struct { + // 账号Id + Id int64 `json:"id"` + // 用户名 + UserName string `json:"userName"` + // 使能 + IsEnb bool `json:"isEnb"` + //禁用原因 + FeedBack string `json:"feedBack"` +} + +// 返回参数 +type MwAccSaveStatusResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} + +/******************获取账号操作列表***********************/ +// POST web/acc/ops +// 请求参数 +type AccOpLogReq struct { + srv_acc.OpLogSearch +} + +// AccOpLogResp 返回参数 +type AccOpLogResp struct { + Total int64 `json:"total" bson:"total"` + List []*srv_acc.OpLogInfo `json:"list" bson:"list"` +} + +// 修改账号密码 +type MwAccUpPwdReq struct { + // 账号Id + Id int64 `json:"id" binding:"required"` + // 密码 + Pwd string `json:"pwd"` +} + +type MwAccUpPwdResp struct { + // 是否成功 true:成功 false:未成功 + Result bool `json:"result,omitempty"` +} diff --git a/web/api/web_acc/role.go b/web/api/web_acc/role.go new file mode 100644 index 0000000..db21f58 --- /dev/null +++ b/web/api/web_acc/role.go @@ -0,0 +1,400 @@ +package web_acc + +import ( + "videoweb/pub/common" + "videoweb/pub/common/pageopt" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/mwacc" + "videoweb/pub/pubdef" + "videoweb/web/service/srv_mwacc" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +// @Tags 角色 +// @Summary 角色-列表 +// @Description 角色-列表 +// @Accept json +// @Produce json +// @Param param body pubdef.Page true "参数列表" +// @Success 200 {object} MwRoleListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/list [GET] +func RoleGetList(c *gin.Context) { + var req pubdef.Page + + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + + skip := (req.PageNum - 1) * req.PageSize + limit := req.PageSize + + // 获取分页信息 + var mats []pageopt.Matcher + dbRoleRec, err := mwacc.GetRoleList(c, skip, limit, mats...) + if err != nil { + common.ServerJSON(c, srvdef.Failure, "") + return + } + + // 获取总条数 + roleCount, err := mwacc.RoleCountByFilter(c, bson.M{}) + + if err != nil { + common.ServerJSON(c, srvdef.Failure, "") + return + } + + webRoleRec := make([]*MwRoleRec, 0) + for _, v := range dbRoleRec { + webRoleRec = append(webRoleRec, &MwRoleRec{ + Id: v.Id, + Name: v.Name, + Desc: v.Desc, + IsEnb: v.IsEnb, + ApiMenus: v.ApiMenus[:], + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + }) + } + + // 返回正确信息 + common.ServerJSON(c, srvdef.Success, MwRoleListResp{TotalCount: roleCount, RoleRecs: webRoleRec}) + return +} + +// @Tags 角色 +// @Summary 角色-添加 +// @Description 角色-添加 +// @Accept json +// @Produce json +// @Param param body MwRoleAddReq true "参数列表" +// @Success 200 {object} MwRoleAddResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/add [POST] +func RoleAdd(c *gin.Context) { + var req MwRoleAddReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + ok, _ := mwacc.RoleAdd(c, req.Name, req.Desc, req.IsEnb) + if !ok { + common.ServerJSON(c, srvdef.Failure, "") + return + } + // 返回正确信息 + common.ServerJSON(c, srvdef.Success, MwRoleAddResp{Result: true}) + return +} + +// @Tags 角色 +// @Summary 角色-删除 +// @Description 角色-删除 +// @Accept json +// @Produce json +// @Param param body MwRoleDelReq true "参数列表" +// @Success 200 {object} MwRoleDelResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/del [POST] +func RoleDel(c *gin.Context) { + var req MwRoleDelReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + ok, err := srv_mwacc.RoleDel(c, req.Id) + if !ok { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + common.ServerJSON(c, srvdef.Success, MwRoleDelResp{Result: true}) + return +} + +// @Tags 角色 +// @Summary 角色-获取信息 +// @Description 角色-获取信息 +// @Accept json +// @Produce json +// @Param param body MwRoleGetInfoReq true "参数列表" +// @Success 200 {object} MwRoleGetInfoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/getinfo [GET] +func RoleGetInfo(c *gin.Context) { + + var req MwRoleGetInfoReq + + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + roleInfo, err := srv_mwacc.RoleGetInfo(c, req.Id) + + if err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + resq := &MwRoleGetInfoResp{ + RoleRec: &MwRoleRec{ + Id: roleInfo.Id, + Name: roleInfo.Name, + Desc: roleInfo.Desc, + IsEnb: roleInfo.IsEnb, + ApiMenus: roleInfo.ApiMenus[:], + CreatedAt: roleInfo.CreatedAt, + UpdatedAt: roleInfo.UpdatedAt, + }, + } + common.ServerJSON(c, srvdef.Success, resq) + return +} + +// @Tags 角色 +// @Summary 角色-更新 +// @Description 角色-更新 +// @Accept json +// @Produce json +// @Param param body MwRoleUpReq true "参数列表" +// @Success 200 {object} MwRoleUpResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/up [POST] +func RoleUp(c *gin.Context) { + + var req MwRoleUpReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + // RoleUp + _, err := srv_mwacc.RoleUp(c, req.Id, req.Name, req.Desc, req.IsEnb) + if err != nil { + common.ServerJSON(c, srvdef.Failure, MwRoleUpResp{Result: false}) + return + } + + common.ServerJSON(c, srvdef.Success, MwRoleUpResp{Result: true}) + return +} + +// @Tags 角色 +// @Summary 角色-使能 +// @Description 角色-使能 +// @Accept json +// @Produce json +// @Param param body MwRoleEnableReq true "参数列表" +// @Success 200 {object} MwRoleEnableResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/enable [POST] +func RoleEnable(c *gin.Context) { + + var req MwRoleEnableReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + _, err := srv_mwacc.RoleEnable(c, req.Id, req.IsEnb) + if err != nil { + common.ServerJSON(c, err.Code, err.Msg) + return + } + common.ServerJSON(c, srvdef.Success, MwRoleEnableResp{Result: true}) + return +} + +// @Tags 角色 +// @Summary 角色-下拉列表 +// @Description 角色-下拉列表 +// @Accept json +// @Produce json +// @Success 200 {object} MwRoleDropResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/drop [POST] +func RoleGetDrop(c *gin.Context) { + data, _ := mwacc.RoleDrop(c) + + roleDropArr := make([]*MwRoleDrop, 0) + for _, v := range data { + roleDropArr = append(roleDropArr, &MwRoleDrop{Id: v.Id, Name: v.Name}) + } + common.ServerJSON(c, srvdef.Success, MwRoleDropResp{RoleDrop: roleDropArr}) + return +} + +func DbRouterToWebRouter(dbRouter *mwacc.Router) (webRouter *Router) { + if dbRouter == nil { + return + } + + webRouter = &Router{ + Path: dbRouter.Path, + Component: dbRouter.Component, + Redirect: dbRouter.Redirect, + Name: dbRouter.Name, + IsShow: dbRouter.IsShow, + Hidden: dbRouter.Hidden, + Meta: &Router_Meta{ + Title: dbRouter.Meta.Title, + Icon: dbRouter.Meta.Icon, + NoCache: dbRouter.Meta.NoCache, + }, + Children: make([]*Router, 0), + ApiUrl: make([]*Router_ApiUrl, 0), + } + for _, v := range dbRouter.Children { + webRouter.Children = append(webRouter.Children, DbRouterToWebRouter(&v)) + } + for _, v := range dbRouter.ApiUrl { + webRouter.ApiUrl = append(webRouter.ApiUrl, &Router_ApiUrl{ + Url: v.Url[:], + Desc: v.Desc, + Status: v.Status, + Method: v.Method[:], + }) + } + return +} + +// @Tags 角色 +// @Summary 角色-获取权限 +// @Description 角色-获取权限 +// @Accept json +// @Produce json +// @Param param body MwRoleGetAuthReq true "参数列表" +// @Success 200 {object} MwRoleGetAuthResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/getauth [GET] +func RoleGetAuth(c *gin.Context) { + var req MwRoleGetAuthReq + + if err := c.ShouldBindQuery(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + roleInfo, err := srv_mwacc.RoleGetInfo(c, req.Id) + if err != nil { + common.ServerJSON(c, srvdef.Failure, err.Error()) + return + } + + if roleInfo == nil { + common.ServerJSON(c, srvdef.Failure, "没找到角色") + return + } + + if roleInfo.Id == 0 { + common.ServerJSON(c, srvdef.Failure, "没找到角色2") + return + } + + resq := &MwRoleGetAuthResp{ + Id: req.Id, + Router: make([]*Router, 0), + } + + for _, v := range roleInfo.Router { + + resq.Router = append(resq.Router, DbRouterToWebRouter(&v)) + } + + common.ServerJSON(c, srvdef.Success, resq) + return +} + +// 网站Router转数据库Router +func WebRouterToDbRouter(webRouter *Router) (dbRouter *mwacc.Router) { + + if webRouter == nil { + return + } + + dbRouter = &mwacc.Router{ + Path: webRouter.Path, + Component: webRouter.Component, + Redirect: webRouter.Redirect, + Name: webRouter.Name, + IsShow: webRouter.IsShow, + Hidden: webRouter.Hidden, + Meta: mwacc.Meta{ + Title: webRouter.Meta.Title, + Icon: webRouter.Meta.Icon, + NoCache: webRouter.Meta.NoCache, + }, + Children: make([]mwacc.Router, 0), + ApiUrl: make([]mwacc.Apiurl, 0), + } + for _, v := range webRouter.Children { + dbRouter.Children = append(dbRouter.Children, *WebRouterToDbRouter(v)) + } + for _, v := range webRouter.ApiUrl { + dbRouter.ApiUrl = append(dbRouter.ApiUrl, mwacc.Apiurl{ + Url: v.Url[:], + Desc: v.Desc, + Status: v.Status, + Method: v.Method[:], + }) + } + return +} + +// @Tags 角色 +// @Summary 角色-更新权限 +// @Description 角色-更新权限 +// @Accept json +// @Produce json +// @Param param body MwRoleUpAuthReq true "参数列表" +// @Success 200 {object} MwRoleUpAuthResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/role/upauth [GET] +func RoleUpAuth(c *gin.Context) { + + var req MwRoleUpAuthReq + + if err := c.ShouldBindJSON(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + + dataSave := make([]*mwacc.Router, 0) + for _, v := range req.Router { + dataSave = append(dataSave, WebRouterToDbRouter(v)) + } + + _, err := srv_mwacc.UpAuth(c, req.Id, dataSave) + if err != nil { + common.ServerJSON(c, srvdef.Failure, MwRoleUpAuthResp{Result: false}) + return + } + + // auth.ClearRoleMap() + common.ServerJSON(c, srvdef.Success, MwRoleUpAuthResp{Result: true}) + return +} diff --git a/web/api/web_actress/actress.go b/web/api/web_actress/actress.go new file mode 100644 index 0000000..236262a --- /dev/null +++ b/web/api/web_actress/actress.go @@ -0,0 +1,109 @@ +package web_actress + +import ( + "github.com/gin-gonic/gin" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_actress" +) + +// @Tags 女优管理 +// @Summary 分组分页获取女优信息 +// @Description 分组分页获取女优信息 +// @Accept json +// @Produce json +// @Param param body srv_actress.ActressListReq true "参数列表" +// @Success 200 {object} srv_actress.ActressListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/actress/list [GET] +func Get(ctx *gin.Context) { + var req = &srv_actress.ActressListReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_actress.ActressList(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 女优管理 +// @Summary 添加一个女优信息 +// @Description 添加一个女优信息 +// @Accept json +// @Produce json +// @Param param body srv_actress.ActressAddReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/actress/add [POST] +func Add(ctx *gin.Context) { + var req = &srv_actress.ActressAddReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_actress.AddActress(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 女优管理 +// @Summary 删除一个女优信息 +// @Description 删除一个女优信息 +// @Accept json +// @Produce json +// @Param param body srv_actress.ActressDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/actress/del [POST] +func Del(ctx *gin.Context) { + var req = &srv_actress.ActressDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_actress.DeleteActress(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 女优管理 +// @Summary 更新一个女优信息 +// @Description 更新一个女优信息 +// @Accept json +// @Produce json +// @Param param body srv_actress.ActressUpdateReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/actress/update [POST] +func Update(ctx *gin.Context) { + var req = &srv_actress.ActressUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_actress.UpdateActress(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} \ No newline at end of file diff --git a/web/api/web_analytics/analytics.go b/web/api/web_analytics/analytics.go new file mode 100644 index 0000000..eb2b733 --- /dev/null +++ b/web/api/web_analytics/analytics.go @@ -0,0 +1,155 @@ +package webanalytics + +import ( + "time" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/analyticsdailystat" + "videoweb/pub/models/analyticsstat" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +// @Tags 获取统计数据 +// @Summary 统计数据 +// @Description 统计数据 +// @Accept json +// @Produce json +// @Success 200 {object} srv_translationcategory.TranslationCategoryResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/stat/analytics [POST] +func GetAnalyticsStat(ctx *gin.Context) { + // 当日数据 + resp := AnalyticsStat{ + TodayList: make([]LastWebStatInfo, 0), + WeekList: make([]LastWebStatInfo, 0), + YestodayList: make([]LastWebStatInfo, 0), + } + now := time.Now() + curStatTime := utils.RecentMinute(now, 30) // 按20分钟对齐 + YestodayTime := curStatTime.AddDate(0, 0, -1) + WeekTime := curStatTime.AddDate(0, 0, -7) + + dailyFilter := bson.M{ + analyticsdailystat.FN.AnalyticsDailyInfo.SumDate: bson.M{"$gt": now.AddDate(0, -1, -1)}, + } + dailyStatList, _ := analyticsdailystat.FindManyAnalyticsDailyInfo(ctx, dailyFilter) + + curFilter := bson.M{ + analyticsstat.FN.AnalyticsInfo.SumDate: curStatTime, + } + statList, _ := analyticsstat.FindManyAnalyticsInfo(ctx, curFilter) + for _, statInfo := range statList { + appStatInfo := LastWebStatInfo{ + SumDate: statInfo.SumDate, + AppId: statInfo.AppId, + AppName: statInfo.AppName, + ActiveUsers: statInfo.ActiveUsers, + YestodayPV: statInfo.YestodayPV, + YestodayUV: statInfo.YestodayUV, + YestodayActiveUsers: statInfo.YestodayActiveUsers, + YestodaySessions: statInfo.YestodaySessions, + YestodayNewUsers: statInfo.YestodayNewUsers, + } + for _, dailyStatInfo := range dailyStatList { + if dailyStatInfo.SumDate.Equal(statInfo.DaySumDate) && dailyStatInfo.AppId == statInfo.AppId { + appStatInfo.LeadNewCount = dailyStatInfo.LeadNewCount + appStatInfo.LeadAmount = dailyStatInfo.LeadAmount + } + } + resp.TodayList = append(resp.TodayList, appStatInfo) + } + + yesFilter := bson.M{ + analyticsstat.FN.AnalyticsInfo.SumDate: YestodayTime, + } + statList, _ = analyticsstat.FindManyAnalyticsInfo(ctx, yesFilter) + for _, statInfo := range statList { + appStatInfo := LastWebStatInfo{ + SumDate: statInfo.SumDate, + AppId: statInfo.AppId, + AppName: statInfo.AppName, + ActiveUsers: statInfo.ActiveUsers, + YestodayPV: statInfo.YestodayPV, + YestodayUV: statInfo.YestodayUV, + YestodayActiveUsers: statInfo.YestodayActiveUsers, + YestodaySessions: statInfo.YestodaySessions, + YestodayNewUsers: statInfo.YestodayNewUsers, + } + for _, dailyStatInfo := range dailyStatList { + if dailyStatInfo.SumDate.Equal(statInfo.DaySumDate) && dailyStatInfo.AppId == statInfo.AppId { + appStatInfo.LeadNewCount = dailyStatInfo.LeadNewCount + appStatInfo.LeadAmount = dailyStatInfo.LeadAmount + } + } + resp.YestodayList = append(resp.YestodayList, appStatInfo) + } + + weekFilter := bson.M{ + analyticsstat.FN.AnalyticsInfo.SumDate: WeekTime, + } + statList, _ = analyticsstat.FindManyAnalyticsInfo(ctx, weekFilter) + for _, statInfo := range statList { + appStatInfo := LastWebStatInfo{ + SumDate: statInfo.SumDate, + AppId: statInfo.AppId, + AppName: statInfo.AppName, + ActiveUsers: statInfo.ActiveUsers, + YestodayPV: statInfo.YestodayPV, + YestodayUV: statInfo.YestodayUV, + YestodayActiveUsers: statInfo.YestodayActiveUsers, + YestodaySessions: statInfo.YestodaySessions, + YestodayNewUsers: statInfo.YestodayNewUsers, + } + for _, dailyStatInfo := range dailyStatList { + if dailyStatInfo.SumDate.Equal(statInfo.DaySumDate) && dailyStatInfo.AppId == statInfo.AppId { + appStatInfo.LeadNewCount = dailyStatInfo.LeadNewCount + appStatInfo.LeadAmount = dailyStatInfo.LeadAmount + } + } + resp.WeekList = append(resp.WeekList, appStatInfo) + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 获取统计数据 +// @Summary 统计数据 +// @Description 统计数据 +// @Accept json +// @Produce json +// @Success 200 {object} srv_translationcategory.TranslationCategoryResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/stat/daily/analytics [POST] +func GetAnalyticsDailyStat(ctx *gin.Context) { + // 当日数据 + resp := AnalyticsDailyStat{ + List: make([]LastWebStatInfo, 0), + } + now := time.Now() + + curFilter := bson.M{ + analyticsdailystat.FN.AnalyticsDailyInfo.SumDate: bson.M{"$gt": utils.BeginOfMonth(now.AddDate(0, 0, -1)), "$lte": now}, + } + statList, _ := analyticsdailystat.FindManyAnalyticsDailyInfo(ctx, curFilter) + for _, statInfo := range statList { + appStatInfo := LastWebStatInfo{ + SumDate: statInfo.SumDate, + AppId: statInfo.AppId, + AppName: statInfo.AppName, + YestodayPV: statInfo.YestodayPV, + YestodayUV: statInfo.YestodayUV, + YestodayActiveUsers: statInfo.YestodayActiveUsers, + YestodaySessions: statInfo.YestodaySessions, + YestodayNewUsers: statInfo.YestodayNewUsers, + LeadNewCount: statInfo.LeadNewCount, + LeadAmount: statInfo.LeadAmount, + } + resp.List = append(resp.List, appStatInfo) + } + common.ServerJSON(ctx, srvdef.Success, resp) +} \ No newline at end of file diff --git a/web/api/web_analytics/proto.go b/web/api/web_analytics/proto.go new file mode 100644 index 0000000..a87ff70 --- /dev/null +++ b/web/api/web_analytics/proto.go @@ -0,0 +1,26 @@ +package webanalytics + +import "time" + +type LastWebStatInfo struct { + SumDate time.Time `json:"sumDate" bson:"sumDate"` // 时间半小时 + AppName string `json:"appName" bson:"appName"` // APP名称 + AppId int64 `json:"appId" bson:"appId"` // appId + ActiveUsers int64 `json:"activeUsers" bson:"activeUsers"` // 最近半小时活跃数 + YestodayPV int64 `json:"yestodayPV" bson:"yestodayPV"` // 页面浏览量 (PV) + YestodayUV int64 `json:"yestodayUV" bson:"yestodayUV"` // 独立用户数 (UV) + YestodayActiveUsers int64 `json:"yestodayActiveUsers" bson:"yestodayActiveUsers"` // 活跃用户数 + YestodaySessions int64 `json:"yestodaySessions" bson:"yestodaySessions"` // 会话数 + YestodayNewUsers int64 `json:"yestodayNewUsers" bson:"yestodayNewUsers"` // 新增数量 + LeadNewCount float64 `json:"leadNewCount" bson:"leadNewCount"` // 日导量新增 + LeadAmount float64 `json:"leadAmount" bson:"leadAmount"` // 日导量充值 +} + +type AnalyticsStat struct { + TodayList []LastWebStatInfo `json:"todayList"` //当日数据 + WeekList []LastWebStatInfo `json:"weekList"` //环比数据 + YestodayList []LastWebStatInfo `json:"yestodayList"` //同比数据 +} +type AnalyticsDailyStat struct { + List []LastWebStatInfo `json:"list"` //当月数据 +} \ No newline at end of file diff --git a/web/api/web_announcement/announcement.go b/web/api/web_announcement/announcement.go new file mode 100644 index 0000000..1b36739 --- /dev/null +++ b/web/api/web_announcement/announcement.go @@ -0,0 +1,109 @@ +package web_announcement + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_announcement" + + "github.com/gin-gonic/gin" +) + +// AnnouncementList +// @Tags 公告 +// @Summary 公告-列表 --by 王鸣 +// @Description 公告-列表 +// @Accept json +// @Produce json +// @Param param body srv_announcement.GetAnnouncementListReq true "参数列表" +// @Success 200 {object} srv_announcement.GetAnnouncementListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/announcement/list [POST] +func AnnouncementList(c *gin.Context) { + var req srv_announcement.GetAnnouncementListReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + resp, serr := srv_announcement.GetAnnouncementList(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// AnnouncementAdd +// @Tags 公告 +// @Summary 公告-增加 --by 王鸣 +// @Description 公告-增加 +// @Accept json +// @Produce json +// @Param param body srv_announcement.AddAnnouncementReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/announcement/add [POST] +func AnnouncementAdd(c *gin.Context) { + var req srv_announcement.AddAnnouncementReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_announcement.AddAnnouncement(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// AnnouncementDel +// @Tags 公告 +// @Summary 公告-删除 --by 王鸣 +// @Description 公告-删除 +// @Accept json +// @Produce json +// @Param param body srv_announcement.DelAnnouncementReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/announcement/del [POST] +func AnnouncementDel(c *gin.Context) { + var req srv_announcement.DelAnnouncementReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_announcement.DelAnnouncement(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// AnnouncementUpdate +// @Tags 公告 +// @Summary 公告-更新 --by 王鸣 +// @Description 公告-更新 +// @Accept json +// @Produce json +// @Param param body srv_announcement.UpdateAnnouncementReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/announcement/update [POST] +func AnnouncementUpdate(c *gin.Context) { + var req srv_announcement.UpdateAnnouncementReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_announcement.UpdateAnnouncement(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} diff --git a/web/api/web_app/app.go b/web/api/web_app/app.go new file mode 100644 index 0000000..76271bd --- /dev/null +++ b/web/api/web_app/app.go @@ -0,0 +1,78 @@ +package web_app + +import ( + "github.com/gin-gonic/gin" + "videoweb/pub/common" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/pubdef" + "videoweb/web/service/srv_app" +) + +func AppSyncReq(c *gin.Context) { + var in srv_app.AppVideoInfoSyncReq + if err := c.ShouldBindJSON(&in); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, nil) + return + } + if in.AppId != pubdef.AppId { + common.ServerJSON(c, srvdef.APPIDError, nil) + } + data, err := srv_app.AppVideoInfoSync(c, &in) + if err != nil { + log.Error("AppSyncReq AppVideoInfoSyncReq Failed", log.Any("err", err)) + common.ServerJSON(c, err.Code, "") + return + } + + common.ServerJSON(c, srvdef.Success, data) +} + +func AppCollectionSyncReq(c *gin.Context) { + var in srv_app.AppVideoCollectionSyncReq + if err := c.ShouldBindJSON(&in); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, nil) + return + } + if in.AppId != pubdef.AppId { + common.ServerJSON(c, srvdef.APPIDError, nil) + } + data, err := srv_app.AppVideoCollectionSync(c, &in) + if err != nil { + log.Error("AppCollectionSyncReq AppVideoCollectionSync Failed", log.Any("err", err)) + common.ServerJSON(c, err.Code, "") + return + } + + common.ServerJSON(c, srvdef.Success, data) +} + +func AppGetVideoCategory(c *gin.Context) { + data, err := srv_app.AppCategoryList(c) + if err != nil { + log.Error("AppGetVideoCategory AppCategoryList Failed", log.Any("err", err)) + common.ServerJSON(c, err.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +func AppGetVideoCreator(c *gin.Context) { + data, err := srv_app.AppCreatorList(c) + if err != nil { + log.Error("AppGetVideoCreator AppCategoryList Failed", log.Any("err", err)) + common.ServerJSON(c, err.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} + +func AppGetVideoTag(c *gin.Context) { + data, err := srv_app.AppTagList(c) + if err != nil { + log.Error("AppGetVideoTag AppCategoryList Failed", log.Any("err", err)) + common.ServerJSON(c, err.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, data) +} \ No newline at end of file diff --git a/web/api/web_cfg/cfg.go b/web/api/web_cfg/cfg.go new file mode 100644 index 0000000..94ef049 --- /dev/null +++ b/web/api/web_cfg/cfg.go @@ -0,0 +1,75 @@ +package web_cfg + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_cfg" + + "github.com/gin-gonic/gin" +) + +// @Tags 系统配置 +// @Summary 获取系统配置 +// @Description 获取系统配置 +// @Accept json +// @Produce json +// @Param param body GetSysConfigReq true "参数列表" +// @Success 200 {object} GetSysConfigResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/syscfg/get [GET] +func GetCfg(ctx *gin.Context) { + config, err := srv_cfg.GetSystemConfig(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Error()) + return + } + resp := &GetSysConfigResp{config} + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 系统配置 +// @Summary 修改系统配置 +// @Description 修改系统配置 +// @Accept json +// @Produce json +// @Param param body SysConfigUpdateReq true "参数列表" +// @Success 200 {object} SysConfigUpdateResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/syscfg/update [POST] +func UpdateCfg(ctx *gin.Context) { + var req SysConfigUpdateReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + if err := srv_cfg.UpdateSystemConfig(ctx, &req.SysCfgUpdate); err != nil { + common.ServerJSON(ctx, err.Code, err.Error()) + return + } + common.ServerJSON(ctx, srvdef.Success, &SysConfigUpdateResp{}) +} + +// @Tags 系统配置 +// @Summary 设置媒资库 appKey +// @Description 单独设置 sys_cfg.appKey,并刷新内存缓存 +// @Accept json +// @Produce json +// @Param param body SetAppKeyReq true "参数列表" +// @Success 200 {object} SetAppKeyResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/syscfg/appkey [POST] +func SetAppKey(ctx *gin.Context) { + var req SetAppKeyReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + if err := srv_cfg.SetAppKey(ctx, req.AppKey); err != nil { + common.ServerJSON(ctx, err.Code, err.Error()) + return + } + common.ServerJSON(ctx, srvdef.Success, &SetAppKeyResp{}) +} diff --git a/web/api/web_cfg/protodef.go b/web/api/web_cfg/protodef.go new file mode 100644 index 0000000..da1a171 --- /dev/null +++ b/web/api/web_cfg/protodef.go @@ -0,0 +1,24 @@ +package web_cfg + +import ( + "videoweb/pub/models/cfg" + "videoweb/web/service/srv_cfg" +) + +type GetSysConfigReq struct { +} + +type GetSysConfigResp struct { + *cfg.SysCfg +} + +type SysConfigUpdateReq struct { + srv_cfg.SysCfgUpdate +} +type SysConfigUpdateResp struct{} + +type SetAppKeyReq struct { + AppKey string `json:"appKey"` // 媒资库 appKey +} + +type SetAppKeyResp struct{} diff --git a/web/api/web_commend/commend.go b/web/api/web_commend/commend.go new file mode 100644 index 0000000..09821aa --- /dev/null +++ b/web/api/web_commend/commend.go @@ -0,0 +1,174 @@ +package web_commend + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_commend" + + "github.com/gin-gonic/gin" +) + +// SearchManyCommend +// @Tags 评论 +// @Summary 评论列表 +// @Description 评论列表 +// @Accept json +// @Produce json +// @Param param body srv_commend.CommendSearch true "参数列表" +// @Success 200 {object} srv_commend.CommendSearchResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/web/post/comment/list [POST] +func SearchManyCommend(ctx *gin.Context) { + var req = &srv_commend.CommendSearch{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + resp, serr := srv_commend.SearchComandList(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +type CommendDelReq struct { + Id int64 `json:"id" binding:"required"` +} +type CommendDelIpReq struct { + Ip string `json:"ip"` +} + +// DelCommend +// @Tags 评论 +// @Summary 删除一个评论 +// @Description 删除一个评论 +// @Accept json +// @Produce json +// @Param param body CommendDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/web/post/comment/del [POST] +func DelCommend(ctx *gin.Context) { + var req = &CommendDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_commend.DelCommend(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} + +// DelUserCommend +// @Tags 评论 +// @Summary 删除一个用户所有评论 +// @Description 删除一个用户所有评论 +// @Accept json +// @Produce json +// @Param param body CommendDelIpReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/web/post/comment/user/del [POST] +func DelUserCommend(ctx *gin.Context) { + var req = &CommendDelIpReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_commend.DelUserCommend(ctx, req.Ip) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} + +// ReplayPostComment +// @Tags 评论 +// @Summary 回复评论 +// @Description 回复评论 +// @Accept json +// @Produce json +// @Param param body srv_commend.CommendReplay true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/web/post/comment/replay [POST] +func ReplayPostComment(ctx *gin.Context) { + var req = &srv_commend.CommendReplay{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_commend.AddPostComment(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} + +type CommendReviewBatchReq struct { + Ids *[]int64 `json:"ids"` + ReviewStatus int `json:"reviewStatus"` +} + +// ReviewCommendBatch +// @Tags 评论 +// @Summary 批量审核评论 +// @Description 批量审核评论 +// @Accept json +// @Produce json +// @Param param body CommendReviewBatchReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/post/comment/reviewBatch [POST] +func ReviewCommendBatch(ctx *gin.Context) { + var req = &CommendReviewBatchReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_commend.ReviewCommendBatch(ctx, req.Ids, req.ReviewStatus) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} + +// ReviewComment +// @Tags 评论 +// @Summary 审核评论 +// @Description 审核评论 +// @Accept json +// @Produce json +// @Param param body srv_commend.CommendReviewReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/post/comment/review [POST] +func ReviewComment(ctx *gin.Context) { + var req = &srv_commend.CommendReviewReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_commend.ReviewComment(ctx, req.Id, req.ReviewStatus) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} diff --git a/web/api/web_contactus/contactus.go b/web/api/web_contactus/contactus.go new file mode 100644 index 0000000..f47a362 --- /dev/null +++ b/web/api/web_contactus/contactus.go @@ -0,0 +1,81 @@ +package web_contactus + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_contactus" + + "github.com/gin-gonic/gin" +) + +// @Tags 联系我们 +// @Summary 联系我们列表 +// @Description 联系我们列表 +// @Accept json +// @Produce json +// @Param param body srv_contactus.GetContactusListReq true "参数列表" +// @Success 200 {object} srv_contactus.GetContactusListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/web/contactus/list [POST] +func ContactusList(ctx *gin.Context) { + var req = &srv_contactus.GetContactusListReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_contactus.GetContactusList(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 联系我们 +// @Summary 删除一个联系我们 +// @Description 删除一个联系我们 +// @Accept json +// @Produce json +// @Param param body srv_contactus.DelOneContactusReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/web/contactus/del [POST] +func Del(ctx *gin.Context) { + var req = &srv_contactus.DelOneContactusReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_contactus.DelContactus(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} + +// @Tags 联系我们 +// @Summary 修改联系我们状态 +// @Description 修改联系我们状态 +// @Accept json +// @Produce json +// @Param param body srv_contactus.UpdateContactusReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/contactus/update [POST] +func Update(ctx *gin.Context) { + var req = &srv_contactus.UpdateContactusReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_contactus.UpdateContactus(ctx, req.Id, req.Status) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, nil) +} diff --git a/web/api/web_contentexcel/contentexcel.go b/web/api/web_contentexcel/contentexcel.go new file mode 100644 index 0000000..d6bc7fb --- /dev/null +++ b/web/api/web_contentexcel/contentexcel.go @@ -0,0 +1,62 @@ +package web_contentexcel + +import ( + "fmt" + "net/http" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_contentexcel" + + "github.com/gin-gonic/gin" +) + +// /api/web/contentexcel/export [GET] +func Export(ctx *gin.Context) { + req := &srv_contentexcel.ExportReq{} + if err := ctx.ShouldBindQuery(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + + ids, serr := srv_contentexcel.ParseExportIDs(ctx.QueryArray("ids")) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + if len(ids) > 0 { + req.Ids = ids + } + + data, filename, serr := srv_contentexcel.Export(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + ctx.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) +} + +// /api/web/contentexcel/import [POST] +func Import(ctx *gin.Context) { + contentType := ctx.PostForm("type") + fileHeader, err := ctx.FormFile("file") + if err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, "请上传 file 字段") + return + } + + file, err := fileHeader.Open() + if err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + defer file.Close() + + result, serr := srv_contentexcel.Import(ctx, contentType, file) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, result) +} diff --git a/web/api/web_contenttag/tag.go b/web/api/web_contenttag/tag.go new file mode 100644 index 0000000..7531045 --- /dev/null +++ b/web/api/web_contenttag/tag.go @@ -0,0 +1,131 @@ +package web_contenttag + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/middleware/auth" + "videoweb/web/service/srv_contenttag" + + "github.com/gin-gonic/gin" +) + +// List 统一标签列表 +// @Tags web统一标签相关接口 +// @Summary 统一标签列表 -- by 王鸣 +// @Description 分页查询统一标签配置 +// @Accept json +// @Produce json +// @Param params query proto.ContentTagListReq true "统一标签列表请求" +// @Success 200 {object} proto.ContentTagListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/content/tag/list [get] +func List(ctx *gin.Context) { + var req proto.ContentTagListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_contenttag.ListContentTag(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Option 统一标签下拉列表 +// @Tags web统一标签相关接口 +// @Summary 统一标签下拉列表 -- by 王鸣 +// @Description 查询启用的统一标签下拉列表,传type时按支持类型过滤 +// @Accept json +// @Produce json +// @Param params query proto.ContentTagOptionReq false "统一标签下拉列表请求" +// @Success 200 {object} proto.ContentTagOptionResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/content/tag/option [get] +func Option(ctx *gin.Context) { + var req proto.ContentTagOptionReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_contenttag.ListContentTagOption(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Add 新增统一标签 +// @Tags web统一标签相关接口 +// @Summary 新增统一标签 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.ContentTagAddReq true "新增统一标签请求" +// @Success 200 {object} proto.ContentTagAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/content/tag/add [post] +func Add(ctx *gin.Context) { + var req proto.ContentTagAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_contenttag.AddContentTag(ctx, &req, auth.GetAccount(ctx)) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Update 更新统一标签 +// @Tags web统一标签相关接口 +// @Summary 更新统一标签 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.ContentTagUpdateReq true "更新统一标签请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/content/tag/update [post] +func Update(ctx *gin.Context) { + var req proto.ContentTagUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_contenttag.UpdateContentTag(ctx, &req, auth.GetAccount(ctx)); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Del 删除统一标签 +// @Tags web统一标签相关接口 +// @Summary 删除统一标签 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.ContentTagDelReq true "删除统一标签请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/content/tag/del [post] +func Del(ctx *gin.Context) { + var req proto.ContentTagDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_contenttag.DelContentTag(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_detailnav/detailnav.go b/web/api/web_detailnav/detailnav.go new file mode 100644 index 0000000..9ac2188 --- /dev/null +++ b/web/api/web_detailnav/detailnav.go @@ -0,0 +1,109 @@ +package web_detailnav + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_detailnav" + + "github.com/gin-gonic/gin" +) + +// DetailNavList +// @Tags 详情页导航配置 +// @Summary 详情页导航配置-列表 +// @Description 详情页导航配置-列表 +// @Accept json +// @Produce json +// @Param param body srv_detailnav.GetDetailNavListReq true "参数列表" +// @Success 200 {object} srv_detailnav.GetDetailNavListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/detailnav/list [POST] +func DetailNavList(c *gin.Context) { + var req srv_detailnav.GetDetailNavListReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + resp, serr := srv_detailnav.GetDetailNavList(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// DetailNavAdd +// @Tags 详情页导航配置 +// @Summary 详情页导航配置-增加 +// @Description 详情页导航配置-增加 +// @Accept json +// @Produce json +// @Param param body srv_detailnav.AddDetailNavReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/detailnav/add [POST] +func DetailNavAdd(c *gin.Context) { + var req srv_detailnav.AddDetailNavReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_detailnav.AddDetailNav(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// DetailNavDel +// @Tags 详情页导航配置 +// @Summary 详情页导航配置-删除 +// @Description 详情页导航配置-删除 +// @Accept json +// @Produce json +// @Param param body srv_detailnav.DelDetailNavReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/detailnav/del [POST] +func DetailNavDel(c *gin.Context) { + var req srv_detailnav.DelDetailNavReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_detailnav.DelDetailNav(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// DetailNavUpdate +// @Tags 详情页导航配置 +// @Summary 详情页导航配置-更新 +// @Description 详情页导航配置-更新 +// @Accept json +// @Produce json +// @Param param body srv_detailnav.UpdateDetailNavReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/detailnav/update [POST] +func DetailNavUpdate(c *gin.Context) { + var req srv_detailnav.UpdateDetailNavReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_detailnav.UpdateDetailNav(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} diff --git a/web/api/web_file/file.go b/web/api/web_file/file.go new file mode 100644 index 0000000..4e82351 --- /dev/null +++ b/web/api/web_file/file.go @@ -0,0 +1,86 @@ +package web_file + +import ( + "io/ioutil" + "videoweb/pub/common" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/storage" + "videoweb/pub/storage/proxy" + + "github.com/gin-gonic/gin" +) + +// @Tags 文件上传 +// @Summary 图片上传 +// @Description 图片上传 +// @Accept json +// @Produce json +// @Param file formData file true "文件" +// @Success 200 {object}} UploadImageResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/web/file/uploadImg [POST] +func UploadImage(ctx *gin.Context) { + f, err := ctx.FormFile("file") + if err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + imageUpoadDomain := proxy.BucketImageAddressV2 + p, err := storage.UploadImage(ctx, f, imageUpoadDomain) + if err != nil { + common.ServerJSON(ctx, srvdef.ServerError, err.Error()) + return + } + resp := &UploadImageResp{Path: p} + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 文件上传 +// @Summary 图片批量上传 +// @Description 图片批量上传 +// @Accept json +// @Produce json +// @Param file[] formData file true "文件" +// @Success 200 {object}} UploadImagesResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/web/file/uploadImgs [POST] +func UploadImages(ctx *gin.Context) { + form, err := ctx.MultipartForm() + if err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + resp := &UploadImagesResp{ + Paths: make([]string, 0), + } + domain := proxy.BucketImageAddressV2 + + files := form.File["file[]"] + for _, f := range files { + fi, err := f.Open() + defer func() { + _ = fi.Close() + }() + if err != nil { + log.Warn("headers Open file wrong ", log.Any("Error", err)) + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + + byteData, err := ioutil.ReadAll(fi) + if err != nil { + log.Warn("request multipart wrong ", log.Any("Error", err)) + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + path, err := proxy.UploadImg(f.Filename, byteData, domain) + if err != nil { + log.Warn("request multipart wrong ", log.Any("Error", err)) + common.ServerJSON(ctx, srvdef.UploadToFileSrvFailed, err.Error()) + return + } + resp.Paths = append(resp.Paths, path) + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/web/api/web_file/proto.go b/web/api/web_file/proto.go new file mode 100644 index 0000000..03580af --- /dev/null +++ b/web/api/web_file/proto.go @@ -0,0 +1,14 @@ +package web_file + +type UploadImageResp struct { + Path string `json:"path"` +} + +type UploadImagesResp struct { + Paths []string `json:"paths"` +} + +type UploadVideoBodyResp struct { + Id string `json:"id"` // 文件服返回的上传文件Id + VideoUri string `json:"videoUri"` // 文件服返回的视频uri +} diff --git a/web/api/web_homebanner/banner.go b/web/api/web_homebanner/banner.go new file mode 100644 index 0000000..5add334 --- /dev/null +++ b/web/api/web_homebanner/banner.go @@ -0,0 +1,106 @@ +package web_homebanner + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/middleware/auth" + "videoweb/web/service/srv_homebanner" + + "github.com/gin-gonic/gin" +) + +// List 首页banner列表 +// @Tags web首页banner相关接口 +// @Summary 首页banner列表 -- by 王鸣 +// @Description 分页查询首页banner配置 +// @Accept json +// @Produce json +// @Param params query proto.HomeBannerListReq true "首页banner列表请求" +// @Success 200 {object} proto.HomeBannerListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/banner/list [get] +func List(ctx *gin.Context) { + var req proto.HomeBannerListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_homebanner.ListHomeBanner(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Add 新增首页banner +// @Tags web首页banner相关接口 +// @Summary 新增首页banner -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.HomeBannerAddReq true "新增首页banner请求" +// @Success 200 {object} proto.HomeBannerAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/banner/add [post] +func Add(ctx *gin.Context) { + var req proto.HomeBannerAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_homebanner.AddHomeBanner(ctx, &req, auth.GetAccount(ctx)) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Update 更新首页banner +// @Tags web首页banner相关接口 +// @Summary 更新首页banner -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.HomeBannerUpdateReq true "更新首页banner请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/banner/update [post] +func Update(ctx *gin.Context) { + var req proto.HomeBannerUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_homebanner.UpdateHomeBanner(ctx, &req, auth.GetAccount(ctx)); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Del 删除首页banner +// @Tags web首页banner相关接口 +// @Summary 删除首页banner -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.HomeBannerDelReq true "删除首页banner请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/banner/del [post] +func Del(ctx *gin.Context) { + var req proto.HomeBannerDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_homebanner.DelHomeBanner(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_hotnav/hotnav.go b/web/api/web_hotnav/hotnav.go new file mode 100644 index 0000000..fc7c946 --- /dev/null +++ b/web/api/web_hotnav/hotnav.go @@ -0,0 +1,109 @@ +package web_hotnav + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_hotnav" + + "github.com/gin-gonic/gin" +) + +// HotNavList +// @Tags 热门导航配置 +// @Summary 热门导航配置-列表 +// @Description 热门导航配置-列表 +// @Accept json +// @Produce json +// @Param param body srv_hotnav.GetHotNavListReq true "参数列表" +// @Success 200 {object} srv_hotnav.GetHotNavListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/hotnav/list [post] +func HotNavList(c *gin.Context) { + var req srv_hotnav.GetHotNavListReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + resp, serr := srv_hotnav.GetHotNavList(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// HotNavAdd +// @Tags 热门导航配置 +// @Summary 热门导航配置-增加 +// @Description 热门导航配置-增加 +// @Accept json +// @Produce json +// @Param param body srv_hotnav.AddHotNavReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/hotnav/add [post] +func HotNavAdd(c *gin.Context) { + var req srv_hotnav.AddHotNavReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_hotnav.AddHotNav(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// HotNavDel +// @Tags 热门导航配置 +// @Summary 热门导航配置-删除 +// @Description 热门导航配置-删除 +// @Accept json +// @Produce json +// @Param param body srv_hotnav.DelHotNavReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/hotnav/del [post] +func HotNavDel(c *gin.Context) { + var req srv_hotnav.DelHotNavReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_hotnav.DelHotNav(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// HotNavUpdate +// @Tags 热门导航配置 +// @Summary 热门导航配置-更新 +// @Description 热门导航配置-更新 +// @Accept json +// @Produce json +// @Param param body srv_hotnav.UpdateHotNavReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/hotnav/update [post] +func HotNavUpdate(c *gin.Context) { + var req srv_hotnav.UpdateHotNavReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + serr := srv_hotnav.UpdateHotNav(c, &req) + if serr != nil { + common.ServerJSON(c, serr.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} diff --git a/web/api/web_ipblack/ipblack.go b/web/api/web_ipblack/ipblack.go new file mode 100644 index 0000000..a8052cf --- /dev/null +++ b/web/api/web_ipblack/ipblack.go @@ -0,0 +1,83 @@ +package web_ipblack + +import ( + "github.com/gin-gonic/gin" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_ipblack" +) + +// IpBlackList +// @Tags IP黑名单 +// @Summary IP黑名单-列表 +// @Description IP黑名单-列表 +// @Accept json +// @Produce json +// @Param param body srv_ipblack.GetIPBlackListReq true "参数列表" +// @Success 200 {object} srv_ipblack.GetIPBlackListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/ipblack/list [POST] +func IpBlackList(c *gin.Context) { + var req srv_ipblack.GetIPBlackListReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + resp, err := srv_ipblack.GetIpBlackList(c, &req) + if err != nil { + common.ServerJSON(c, err.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, resp) +} + +// IpBlackAdd +// @Tags IP黑名单 +// @Summary IP黑名单-增加 +// @Description IP黑名单-增加 +// @Accept json +// @Produce json +// @Param param body srv_ipblack.AddIPBlackListReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/ipblack/add [POST] +func IpBlackAdd(c *gin.Context) { + var req srv_ipblack.AddIPBlackListReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + err := srv_ipblack.AddIpBlack(c, &req) + if err != nil { + common.ServerJSON(c, err.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} + +// IpBlackDel +// @Tags IP黑名单 +// @Summary IP黑名单-删除 +// @Description IP黑名单-删除 +// @Accept json +// @Produce json +// @Param param body srv_ipblack.DelIpBlackListReq true "参数列表" +// @Success 200 {object} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/ipblack/del [POST] +func IpBlackDel(c *gin.Context) { + var req srv_ipblack.DelIpBlackListReq + if err := c.ShouldBind(&req); err != nil { + common.ServerJSON(c, srvdef.ErrParamError, err.Error()) + return + } + err := srv_ipblack.DelIpBlack(c, &req) + if err != nil { + common.ServerJSON(c, err.Code, "") + return + } + common.ServerJSON(c, srvdef.Success, "") +} \ No newline at end of file diff --git a/web/api/web_mediaresource/resource.go b/web/api/web_mediaresource/resource.go new file mode 100644 index 0000000..4688c71 --- /dev/null +++ b/web/api/web_mediaresource/resource.go @@ -0,0 +1,87 @@ +package web_mediaresource + +import ( + "videoweb/pub/common" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_mediaresource" + + "github.com/gin-gonic/gin" +) + +// ResourceList web端短剧媒资列表 +// @Tags web媒资库相关接口 +// @Summary web端短剧媒资列表 -- by 王鸣 +// @Description 分页查询短剧媒资列表 +// @Accept json +// @Produce json +// @Param params query proto.PlayletResourceListReq true "短剧媒资列表请求" +// @Success 200 {object} proto.PlayletResourceListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/resource/list [get] +func ResourceList(ctx *gin.Context) { + var req proto.PlayletResourceListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_mediaresource.ListPlayletResource(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ResourceImport web端短剧媒资入库 +// @Tags web媒资库相关接口 +// @Summary web端短剧媒资入库 -- by 王鸣 +// @Description 提交媒资入库任务,后台从媒资中心同步短剧详情并写入短剧和章节集合 +// @Accept json +// @Produce json +// @Param params body proto.PlayletResourceUseReq true "短剧媒资入库请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/resource/import [post] +func ResourceImport(ctx *gin.Context) { + var req proto.PlayletResourceUseReq + if err := ctx.ShouldBindJSON(&req); err != nil { + log.Error("短剧媒资入库参数绑定失败", log.E(err)) + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_mediaresource.ImportPlayletResource(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ResourceChapters web端短剧媒资剧集列表 +// @Tags web媒资库相关接口 +// @Summary web端短剧媒资剧集列表 -- by 王鸣 +// @Description 按媒资ID查询剧集信息,复用入库时的 mediaSync 接口 +// @Accept json +// @Produce json +// @Param id query int true "媒资中心资源ID" +// @Success 200 {object} proto.PlayletResourceChapterListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/resource/chapters [get] +func ResourceChapters(ctx *gin.Context) { + var req proto.PlayletResourceChapterListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_mediaresource.ListPlayletResourceChapters(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/web/api/web_playlet/category.go b/web/api/web_playlet/category.go new file mode 100644 index 0000000..9107147 --- /dev/null +++ b/web/api/web_playlet/category.go @@ -0,0 +1,105 @@ +package web_playlet + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_playlet" + + "github.com/gin-gonic/gin" +) + +// CategoryList web端短剧分类列表 +// @Tags web短剧相关接口 +// @Summary web端短剧分类列表 -- by 王鸣 +// @Description 分页查询短剧分类 +// @Accept json +// @Produce json +// @Param params query proto.PlayletCategoryListReq true "短剧分类列表请求" +// @Success 200 {object} proto.PlayletCategoryListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/category/list [get] +func CategoryList(ctx *gin.Context) { + var req proto.PlayletCategoryListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletCategory(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// CategoryAdd web端新增短剧分类 +// @Tags web短剧相关接口 +// @Summary web端新增短剧分类 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletCategoryAddReq true "新增短剧分类请求" +// @Success 200 {object} proto.PlayletCategoryAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/category/add [post] +func CategoryAdd(ctx *gin.Context) { + var req proto.PlayletCategoryAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlayletCategory(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// CategoryUpdate web端更新短剧分类 +// @Tags web短剧相关接口 +// @Summary web端更新短剧分类 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletCategoryUpdateReq true "更新短剧分类请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/category/update [post] +func CategoryUpdate(ctx *gin.Context) { + var req proto.PlayletCategoryUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlayletCategory(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// CategoryDel web端删除短剧分类 +// @Tags web短剧相关接口 +// @Summary web端删除短剧分类 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletCategoryDelReq true "删除短剧分类请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/category/del [post] +func CategoryDel(ctx *gin.Context) { + var req proto.PlayletCategoryDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DelPlayletCategory(ctx, req.Id); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_playlet/home_config.go b/web/api/web_playlet/home_config.go new file mode 100644 index 0000000..dc96c8e --- /dev/null +++ b/web/api/web_playlet/home_config.go @@ -0,0 +1,130 @@ +package web_playlet + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_playlet" + + "github.com/gin-gonic/gin" +) + +// HomeConfigList web端首页配置列表 +// @Tags web短剧相关接口 +// @Summary web端首页配置列表 +// @Description 分页查询短剧首页配置 +// @Accept json +// @Produce json +// @Param params query proto.PlayletHomeConfigListReq true "首页配置列表请求" +// @Success 200 {object} proto.PlayletHomeConfigListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/home-config/list [get] +func HomeConfigList(ctx *gin.Context) { + var req proto.PlayletHomeConfigListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletHomeConfig(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HomeConfigTopicOptions web端首页专题下拉选项 +// @Tags web短剧相关接口 +// @Summary web端分页查询首页专题可选项 +// @Description dataType 1查询短剧题材 2查询视频分类,排除其他已启用模块占用的专题,configId用于回显当前配置已选项 +// @Accept json +// @Produce json +// @Param params query proto.PlayletHomeTopicOptionsReq true "首页专题选项请求" +// @Success 200 {object} proto.PlayletHomeTopicOptionsResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/home-config/topic/options [get] +func HomeConfigTopicOptions(ctx *gin.Context) { + var req proto.PlayletHomeTopicOptionsReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletHomeTopicOptions(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HomeConfigAdd web端新增首页配置 +// @Tags web短剧相关接口 +// @Summary web端新增首页配置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletHomeConfigAddReq true "新增首页配置请求" +// @Success 200 {object} proto.PlayletHomeConfigAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/home-config/add [post] +func HomeConfigAdd(ctx *gin.Context) { + var req proto.PlayletHomeConfigAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlayletHomeConfig(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HomeConfigUpdate web端更新首页配置 +// @Tags web短剧相关接口 +// @Summary web端更新首页配置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletHomeConfigUpdateReq true "更新首页配置请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/home-config/update [post] +func HomeConfigUpdate(ctx *gin.Context) { + var req proto.PlayletHomeConfigUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlayletHomeConfig(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// HomeConfigDel web端删除首页配置 +// @Tags web短剧相关接口 +// @Summary web端删除首页配置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletHomeConfigDelReq true "删除首页配置请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/home-config/del [post] +func HomeConfigDel(ctx *gin.Context) { + var req proto.PlayletHomeConfigDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DelPlayletHomeConfig(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_playlet/playlet.go b/web/api/web_playlet/playlet.go new file mode 100644 index 0000000..93e9ec0 --- /dev/null +++ b/web/api/web_playlet/playlet.go @@ -0,0 +1,543 @@ +package web_playlet + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/middleware/auth" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/service/srv_playlet" + + "github.com/gin-gonic/gin" +) + +// List web端短剧列表 +// @Tags web短剧相关接口 +// @Summary web端短剧列表 -- by 王鸣 +// @Description 分页查询短剧列表 +// @Accept json +// @Produce json +// @Param params query proto.PlayletListReq true "短剧列表请求" +// @Success 200 {object} proto.PlayletListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/list [get] +func List(ctx *gin.Context) { + var req proto.PlayletListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlaylet(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Add web端新增短剧 +// @Tags web短剧相关接口 +// @Summary web端新增短剧 -- by 王鸣 +// @Description 新增 playlet 集合短剧数据,支持分类、题材、标签、支付方式、推荐和 banner 配置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletAddReq true "新增短剧请求" +// @Success 200 {object} proto.PlayletAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/add [post] +func Add(ctx *gin.Context) { + var req proto.PlayletAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlaylet(ctx, &req, auth.GetAccount(ctx)) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Update web端编辑短剧 +// @Tags web短剧相关接口 +// @Summary web端编辑短剧 -- by 王鸣 +// @Description 按短剧ID编辑 playlet 集合短剧数据,只更新请求中传入的字段 +// @Accept json +// @Produce json +// @Param params body proto.PlayletUpdateReq true "编辑短剧请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/update [post] +func Update(ctx *gin.Context) { + var req proto.PlayletUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlaylet(ctx, &req, auth.GetAccount(ctx)); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Del 删除短剧及其关联数据 +// @Tags web短剧相关接口 +// @Summary web端单/批量删除短剧 +// @Description 删除短剧、剧集及关联用户数据,上架和下架短剧均可删除 +// @Accept json +// @Produce json +// @Param params body proto.PlayletDelReq true "删除短剧请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/del [post] +func Del(ctx *gin.Context) { + var req proto.PlayletDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DelPlaylet(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetStatus web端单/批量设置短剧上下架 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧上下架 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 status 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletStatusSetReq true "单/批量设置短剧上下架请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/status/set [post] +func SetStatus(ctx *gin.Context) { + var req proto.PlayletStatusSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletStatus(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetRecommend web端单/批量设置短剧是否推荐 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧是否推荐 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletRecommendSetReq true "单/批量设置短剧是否推荐请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/recommend/set [post] +func SetRecommend(ctx *gin.Context) { + var req proto.PlayletRecommendSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletRecommend(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetFeatured web端单/批量设置短剧是否爆剧 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧是否爆剧 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 isFeatured 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletFeaturedSetReq true "单/批量设置短剧是否爆剧请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/featured/set [post] +func SetFeatured(ctx *gin.Context) { + var req proto.PlayletFeaturedSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletFeatured(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetBanner web端单/批量设置短剧是否显示在首页banner +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧是否显示在首页banner -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 showOnBanner 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletBannerSetReq true "单/批量设置短剧是否显示在首页banner请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/banner/set [post] +func SetBanner(ctx *gin.Context) { + var req proto.PlayletBannerSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletBanner(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetTheme web端单/批量设置短剧题材 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧题材 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletThemeSetReq true "单/批量设置短剧题材请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/theme/set [post] +func SetTheme(ctx *gin.Context) { + var req proto.PlayletThemeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletTheme(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetTag web端单/批量设置短剧标签 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧标签 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 tagIds 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTagSetReq true "单/批量设置短剧标签请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/tag/set [post] +func SetTag(ctx *gin.Context) { + var req proto.PlayletTagSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletTag(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetCategory web端单/批量设置短剧分类 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧分类 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 categoryIds 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletCategorySetReq true "单/批量设置短剧分类请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/category/set [post] +func SetCategory(ctx *gin.Context) { + var req proto.PlayletCategorySetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletCategory(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetVirtualLike web端单/批量设置短剧虚拟点赞数 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧虚拟点赞数 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 virtualCountLike 字段,每条短剧从最小值和最大值之间随机取不重复数值入库 +// @Accept json +// @Produce json +// @Param params body proto.PlayletVirtualLikeSetReq true "单/批量随机设置短剧虚拟点赞数请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/like/virtual/set [post] +func SetVirtualLike(ctx *gin.Context) { + var req proto.PlayletVirtualLikeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletVirtualLike(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetVirtualBrowse web端单/批量设置短剧虚拟观看数 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧虚拟观看数 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletVirtualBrowseSetReq true "设置短剧虚拟观看数请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/browse/virtual/set [post] +func SetVirtualBrowse(ctx *gin.Context) { + var req proto.PlayletVirtualBrowseSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletVirtualBrowse(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetVirtualHeat web端单/批量设置短剧虚拟热度 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧虚拟热度 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletVirtualHeatSetReq true "设置短剧虚拟热度请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/heat/virtual/set [post] +func SetVirtualHeat(ctx *gin.Context) { + var req proto.PlayletVirtualHeatSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletVirtualHeat(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetPayType web端单/批量设置短剧默认支付方式 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧默认支付方式 -- by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 payType/singleCoins 字段 +// @Accept json +// @Produce json +// @Param params body proto.PlayletPayTypeSetReq true "单/批量设置短剧默认支付方式请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/pay-type/set [post] +func SetPayType(ctx *gin.Context) { + var req proto.PlayletPayTypeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletPayType(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ResetWatermark web端按短剧ID批量重置剧集水印 +// @Tags web短剧相关接口 +// @Summary web端按短剧ID批量重置剧集水印 -- by 王鸣 +// @Description 根据短剧ID集合重置剧集水印状态,保留已有水印地址以便换水印期间继续观看 +// @Accept json +// @Produce json +// @Param params body proto.PlayletWatermarkResetReq true "批量重置短剧水印请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/watermark/reset [post] +func ResetWatermark(ctx *gin.Context) { + var req proto.PlayletWatermarkResetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.ResetPlayletWatermark(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterList web端短剧剧集列表 +// @Tags web短剧相关接口 +// @Summary web端短剧剧集列表 -- by 王鸣 +// @Description 分页查询 playlet_chapter 集合短剧剧集数据,默认按集数升序返回 +// @Accept json +// @Produce json +// @Param params query proto.PlayletChapterListReq true "短剧剧集列表请求" +// @Success 200 {object} proto.PlayletChapterListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/list [get] +func ChapterList(ctx *gin.Context) { + var req proto.PlayletChapterListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletChapter(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterAdd web端新增短剧剧集 +// @Tags web短剧相关接口 +// @Summary web端新增短剧剧集 -- by 王鸣 +// @Description 新增 playlet_chapter 集合短剧剧集数据 +// @Accept json +// @Produce json +// @Param params body proto.PlayletChapterAddReq true "新增短剧剧集请求" +// @Success 200 {object} proto.PlayletChapterAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/add [post] +func ChapterAdd(ctx *gin.Context) { + var req proto.PlayletChapterAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlayletChapter(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterDel web端单/批量删除短剧剧集 +// @Tags web短剧相关接口 +// @Summary web端单/批量删除短剧剧集 +// @Description 根据剧集ID集合删除 playlet_chapter 记录,仅原创短剧支持删除 +// @Accept json +// @Produce json +// @Param params body proto.PlayletChapterDelReq true "单/批量删除短剧剧集请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/del [post] +func ChapterDel(ctx *gin.Context) { + var req proto.PlayletChapterDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DelPlayletChapter(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterSync web端手动同步短剧剧集 +// @Tags web短剧相关接口 +// @Summary web端手动单/批量同步短剧剧集 +// @Description 根据短剧ID集合从媒资中心同步新增剧集,playletIds传1个表示单条同步,传多个表示批量同步,允许已完结短剧补集或修正状态 +// @Accept json +// @Produce json +// @Param params body proto.PlayletChapterSyncReq true "手动单/批量同步短剧剧集请求" +// @Success 200 {object} proto.PlayletChapterSyncResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/sync [post] +func ChapterSync(ctx *gin.Context) { + var req proto.PlayletChapterSyncReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_mediaresource.SyncPlayletChapters(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterUpdate web端编辑短剧剧集 +// @Tags web短剧相关接口 +// @Summary web端编辑短剧剧集 -- by 王鸣 +// @Description 按剧集ID编辑 playlet_chapter 集合短剧剧集数据,只更新请求中传入的字段 +// @Accept json +// @Produce json +// @Param params body proto.PlayletChapterUpdateReq true "编辑短剧剧集请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/update [post] +func ChapterUpdate(ctx *gin.Context) { + var req proto.PlayletChapterUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlayletChapter(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterSetPayType web端单/批量设置短剧剧集支付方式 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧剧集支付方式 -- by 王鸣 +// @Description 根据剧集ID集合批量更新 playlet_chapter 集合 payType 和 singleCoins 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletChapterPayTypeSetReq true "单/批量设置短剧剧集支付方式请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/pay-type/set [post] +func ChapterSetPayType(ctx *gin.Context) { + var req proto.PlayletChapterPayTypeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletChapterPayType(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_playlet/tag.go b/web/api/web_playlet/tag.go new file mode 100644 index 0000000..c8088b4 --- /dev/null +++ b/web/api/web_playlet/tag.go @@ -0,0 +1,105 @@ +package web_playlet + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_playlet" + + "github.com/gin-gonic/gin" +) + +// TagList web端标签列表 +// @Tags web短剧相关接口 +// @Summary web端标签列表 -- by 王鸣 +// @Description 分页查询短剧标签 +// @Accept json +// @Produce json +// @Param params query proto.PlayletTagListReq true "标签列表请求" +// @Success 200 {object} proto.PlayletTagListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/tag/list [get] +func TagList(ctx *gin.Context) { + var req proto.PlayletTagListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletTag(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TagAdd web端新增标签 +// @Tags web短剧相关接口 +// @Summary web端新增标签 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTagAddReq true "新增标签请求" +// @Success 200 {object} proto.PlayletTagAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/tag/add [post] +func TagAdd(ctx *gin.Context) { + var req proto.PlayletTagAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlayletTag(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TagUpdate web端更新标签 +// @Tags web短剧相关接口 +// @Summary web端更新标签 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTagUpdateReq true "更新标签请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/tag/update [post] +func TagUpdate(ctx *gin.Context) { + var req proto.PlayletTagUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlayletTag(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// TagDel web端删除标签 +// @Tags web短剧相关接口 +// @Summary web端删除标签 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTagDelReq true "删除标签请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/tag/del [post] +func TagDel(ctx *gin.Context) { + var req proto.PlayletTagDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DelPlayletTag(ctx, req.Id); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_playlet/theme.go b/web/api/web_playlet/theme.go new file mode 100644 index 0000000..14aa38d --- /dev/null +++ b/web/api/web_playlet/theme.go @@ -0,0 +1,105 @@ +package web_playlet + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_playlet" + + "github.com/gin-gonic/gin" +) + +// ThemeList web端题材列表 +// @Tags web短剧相关接口 +// @Summary web端题材列表 -- by 王鸣 +// @Description 分页查询短剧题材 +// @Accept json +// @Produce json +// @Param params query proto.PlayletThemeListReq true "题材列表请求" +// @Success 200 {object} proto.PlayletThemeListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/theme/list [get] +func ThemeList(ctx *gin.Context) { + var req proto.PlayletThemeListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletTheme(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ThemeAdd web端新增题材 +// @Tags web短剧相关接口 +// @Summary web端新增题材 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletThemeAddReq true "新增题材请求" +// @Success 200 {object} proto.PlayletThemeAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/theme/add [post] +func ThemeAdd(ctx *gin.Context) { + var req proto.PlayletThemeAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlayletTheme(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ThemeUpdate web端更新题材 +// @Tags web短剧相关接口 +// @Summary web端更新题材 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletThemeUpdateReq true "更新题材请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/theme/update [post] +func ThemeUpdate(ctx *gin.Context) { + var req proto.PlayletThemeUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlayletTheme(ctx, &req); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ThemeDel web端删除题材 +// @Tags web短剧相关接口 +// @Summary web端删除题材 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.PlayletThemeDelReq true "删除题材请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/theme/del [post] +func ThemeDel(ctx *gin.Context) { + var req proto.PlayletThemeDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DelPlayletTheme(ctx, req.Id); err != nil { + common.ServerJSON(ctx, err, err.GetMsg()) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_questionnaire/questionnaire.go b/web/api/web_questionnaire/questionnaire.go new file mode 100644 index 0000000..35cc2f6 --- /dev/null +++ b/web/api/web_questionnaire/questionnaire.go @@ -0,0 +1,55 @@ +package web_questionnaire + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_questionnaire" + + "github.com/gin-gonic/gin" +) + +// @Tags 问卷调查 +// @Summary 查询问卷列表 +// @Description 支持分页及条件筛选(question_type、status、时间范围) +// @Accept json +// @Produce json +// @Param param body srv_questionnaire.ListReq true "查询参数" +// @Success 200 {object} srv_questionnaire.ListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/web/questionnaire/list [POST] +func List(ctx *gin.Context) { + var req srv_questionnaire.ListReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_questionnaire.List(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 问卷调查 +// @Summary 处理问卷 +// @Description 只有状态为"待处理"的问卷才允许更新 +// @Accept json +// @Produce json +// @Param param body srv_questionnaire.HandleReq true "处理参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/web/questionnaire/handle [POST] +func Handle(ctx *gin.Context) { + var req srv_questionnaire.HandleReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := srv_questionnaire.Handle(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, nil) +} diff --git a/web/api/web_richtextcontent/richtextcontent.go b/web/api/web_richtextcontent/richtextcontent.go new file mode 100644 index 0000000..023e08c --- /dev/null +++ b/web/api/web_richtextcontent/richtextcontent.go @@ -0,0 +1,111 @@ +package web_richtextcontent + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/richtextcontentmod" + "videoweb/web/service/srv_richtextcontent" + + "github.com/gin-gonic/gin" +) + +// @Tags 富文本内容 +// @Summary 分组分页获取富文本内容 +// @Description 分组分页获取富文本内容 +// @Accept json +// @Produce json +// @Param param body srv_richtextcontent.GetRichTextContentListReq true "参数列表" +// @Success 200 {object} srv_richtextcontent.GetRichTextContentResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/richtextcontent/list [POST] +func List(ctx *gin.Context) { + var req = &srv_richtextcontent.GetRichTextContentListReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_richtextcontent.GetRichTextContentList(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 富文本内容 +// @Summary 添加一个富文本内容 +// @Description 添加一个富文本内容 +// @Accept json +// @Produce json +// @Param param body richtextcontentmod.RichTextContentModel true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/richtextcontent/add [POST] +func Add(ctx *gin.Context) { + var req = &richtextcontentmod.RichTextContentModel{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_richtextcontent.AddRichTextContent(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 富文本内容 +// @Summary 删除一个富文本内容 +// @Description 删除一个富文本内容 +// @Accept json +// @Produce json +// @Param param body srv_richtextcontent.DelRichTextContentReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/richtextcontent/del [POST] +func Del(ctx *gin.Context) { + var req = &srv_richtextcontent.DelRichTextContentReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_richtextcontent.DelRichTextContent(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 富文本内容 +// @Summary 更新一个富文本内容 +// @Description 更新一个富文本内容 +// @Accept json +// @Produce json +// @Param param body srv_richtextcontent.UpdateRichTextContentReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/richtextcontent/update [POST] +func Update(ctx *gin.Context) { + var req = &srv_richtextcontent.UpdateRichTextContentReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_richtextcontent.UpdateRichTextContent(ctx, req.Id, &req.Param) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_slug/slug.go b/web/api/web_slug/slug.go new file mode 100644 index 0000000..2a9cac1 --- /dev/null +++ b/web/api/web_slug/slug.go @@ -0,0 +1,37 @@ +package web_slug + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_slug" + + "github.com/gin-gonic/gin" +) + +// SyncSlug 补齐历史空 slug +// @Tags web slug相关接口 +// @Summary 同步历史空 slug +// @Description 为视频、分类、女优、作者、短剧、统一标签等空 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。type 为空处理全部,也可指定 video/videoCategory/actress/videoCreater/playlet/playletCategory/playletTheme/contentTag。 +// @Accept json +// @Produce json +// @Param param body proto.SlugSyncReq false "同步参数" +// @Success 200 {object} proto.SlugSyncResp "成功" +// @Failure 400 {string} string "参数错误" +// @Security ApiKeyAuth +// @Router /api/web/slug/sync [POST] +func SyncSlug(ctx *gin.Context) { + var req proto.SlugSyncReq + if ctx.Request.ContentLength > 0 { + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + } + resp, serr := srv_slug.SyncEmptySlugs(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/web/api/web_translation/category.go b/web/api/web_translation/category.go new file mode 100644 index 0000000..8042d1f --- /dev/null +++ b/web/api/web_translation/category.go @@ -0,0 +1,111 @@ +package web_translation + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/translationcategorymod" + "videoweb/web/service/srv_translationcategory" + + "github.com/gin-gonic/gin" +) + +// @Tags 导航分类 +// @Summary 分组分页获取导航分类 +// @Description 分组分页获取导航分类 +// @Accept json +// @Produce json +// @Param param body srv_translationcategory.TranslationCategoryReq true "参数列表" +// @Success 200 {object} srv_translationcategory.TranslationCategoryResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translationcategory/list [POST] +func GetTranslationCategory(ctx *gin.Context) { + var req = &srv_translationcategory.TranslationCategoryReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_translationcategory.GetTranslationCategorys(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 导航分类 +// @Summary 添加一个导航分类 +// @Description 添加一个导航分类 +// @Accept json +// @Produce json +// @Param param body translationcategorymod.TranslationCategory true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translationcategory/add [POST] +func AddOnetTranslationCategory(ctx *gin.Context) { + var req = &translationcategorymod.TranslationCategory{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_translationcategory.AddTranslationCategory(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 导航分类 +// @Summary 删除一个导航分类 +// @Description 删除一个导航分类 +// @Accept json +// @Produce json +// @Param param body srv_translationcategory.TranslationCategoryDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translationcategory/del [POST] +func DelOnetTranslationCategory(ctx *gin.Context) { + var req = &srv_translationcategory.TranslationCategoryDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_translationcategory.DelTranslationCategory(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 导航分类 +// @Summary 更新一个导航分类 +// @Description 更新一个导航分类 +// @Accept json +// @Produce json +// @Param param body srv_translationcategory.TranslationCategoryUpdateReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translationcategory/update [POST] +func UpdateOnetTranslationCategory(ctx *gin.Context) { + var req = &srv_translationcategory.TranslationCategoryUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_translationcategory.UpdateTranslationCategory(ctx, req.Id, req.Param) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_translation/translation.go b/web/api/web_translation/translation.go new file mode 100644 index 0000000..765d9f5 --- /dev/null +++ b/web/api/web_translation/translation.go @@ -0,0 +1,111 @@ +package web_translation + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/translationmod" + "videoweb/web/service/srv_translation" + + "github.com/gin-gonic/gin" +) + +// @Tags 导航跳转信息 +// @Summary 分组分页获取导航跳转信息 +// @Description 分组分页获取导航跳转信息 +// @Accept json +// @Produce json +// @Param param body srv_translation.TranslationInfoReq true "参数列表" +// @Success 200 {object} srv_translation.TranslationInfoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translation/list [POST] +func GetTranslation(ctx *gin.Context) { + var req = &srv_translation.TranslationInfoReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_translation.GetTranslationInfos(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 导航跳转信息 +// @Summary 添加一个导航跳转信息 +// @Description 添加一个导航跳转信息 +// @Accept json +// @Produce json +// @Param param body translationmod.TranslationInfo true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translation/add [POST] +func AddOnetTranslation(ctx *gin.Context) { + var req = &translationmod.TranslationInfo{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_translation.AddTranslationInfo(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 导航跳转信息 +// @Summary 删除一个导航跳转信息 +// @Description 删除一个导航跳转信息 +// @Accept json +// @Produce json +// @Param param body srv_translation.TranslationInfoDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translation/del [POST] +func DelOnetTranslation(ctx *gin.Context) { + var req = &srv_translation.TranslationInfoDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_translation.DelTranslationInfo(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 导航跳转信息 +// @Summary 更新一个导航跳转信息 +// @Description 更新一个导航跳转信息 +// @Accept json +// @Produce json +// @Param param body srv_translation.TranslationInfoUpdateReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/translation/update [POST] +func UpdateOnetTranslation(ctx *gin.Context) { + var req = &srv_translation.TranslationInfoUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_translation.UpdateTranslationInfo(ctx, req.Id, req.Param) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_user/user.go b/web/api/web_user/user.go new file mode 100644 index 0000000..eb7e0ae --- /dev/null +++ b/web/api/web_user/user.go @@ -0,0 +1,52 @@ +package web_user + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_user" + + "github.com/gin-gonic/gin" +) + +// @Tags 用户管理 +// @Summary 查询用户列表 +// @Accept json +// @Produce json +// @Param param body srv_user.UserListReq true "查询参数" +// @Success 200 {object} srv_user.UserListResp "成功" +// @Security ApiKeyAuth +// @Router /api/web/user/list [POST] +func GetUserList(ctx *gin.Context) { + var req srv_user.UserListReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_user.GetUsers(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 用户管理 +// @Summary 更新用户信息 +// @Accept json +// @Produce json +// @Param param body srv_user.UserUpdateReq true "更新参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/user/update [POST] +func UpdateUser(ctx *gin.Context) { + var req srv_user.UserUpdateReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if serr := srv_user.UpdateUser(ctx, req.Id, req.Param); serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_video/category.go b/web/api/web_video/category.go new file mode 100644 index 0000000..41a345e --- /dev/null +++ b/web/api/web_video/category.go @@ -0,0 +1,111 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocategorymod" + "videoweb/web/service/srv_videocategory" + + "github.com/gin-gonic/gin" +) + +// GetVideoCategory +// @Tags 视频分类 +// @Summary 分组分页获取视频分类 +// @Description 分组分页获取视频分类 +// @Accept json +// @Produce json +// @Param param body srv_videocategory.VideoCategoryReq true "参数列表" +// @Success 200 {object} srv_videocategory.VideoCategoryResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocategory/list [POST] +func GetVideoCategory(ctx *gin.Context) { + var req = &srv_videocategory.VideoCategoryReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_videocategory.GetVideoCategorys(ctx, req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// AddOneVideoCategory +// @Tags 视频分类 +// @Summary 添加一个视频分类 +// @Description 添加一个视频分类 +// @Accept json +// @Produce json +// @Param param body videocategorymod.VideoCategory true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocategory/add [POST] +func AddOneVideoCategory(ctx *gin.Context) { + var req = &videocategorymod.VideoCategory{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + err := srv_videocategory.AddVideoCategory(ctx, req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// DelOneVideoCategory +// @Tags 视频分类 +// @Summary 删除一个视频分类 +// @Description 删除一个视频分类 +// @Accept json +// @Produce json +// @Param param body srv_videocategory.VideoCategoryDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocategory/del [POST] +func DelOneVideoCategory(ctx *gin.Context) { + var req = &srv_videocategory.VideoCategoryDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + err := srv_videocategory.DelVideoCategory(ctx, req.Id) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// UpdateOneVideoCategory +// @Tags 视频分类 +// @Summary 更新一个视频分类 +// @Description 更新一个视频分类 +// @Accept json +// @Produce json +// @Param param body srv_videocategory.VideoCategoryUpdate true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocategory/update [POST] +func UpdateOneVideoCategory(ctx *gin.Context) { + var req = &srv_videocategory.VideoCategoryUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + err := srv_videocategory.UpdateVideoCategory(ctx, req.Id, req.Param) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_video/creater.go b/web/api/web_video/creater.go new file mode 100644 index 0000000..17d7632 --- /dev/null +++ b/web/api/web_video/creater.go @@ -0,0 +1,111 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocreatermod" + "videoweb/web/service/srv_videocreater" + + "github.com/gin-gonic/gin" +) + +// @Tags 视频作者 +// @Summary 分组分页获取视频作者 +// @Description 分组分页获取视频作者 +// @Accept json +// @Produce json +// @Param param body srv_videocreater.VideoCreaterReq true "参数列表" +// @Success 200 {object} srv_videocreater.VideoCreaterResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocreater/list [POST] +func GetVideoCreater(ctx *gin.Context) { + var req = &srv_videocreater.VideoCreaterReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_videocreater.GetVideoCreaters(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 视频作者 +// @Summary 添加一个视频作者 +// @Description 添加一个视频作者 +// @Accept json +// @Produce json +// @Param param body videocreatermod.VideoCreaterInfo true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocreater/add [POST] +func AddOneVideoCreater(ctx *gin.Context) { + var req = &videocreatermod.VideoCreaterInfo{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videocreater.AddVideoCreater(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 视频作者 +// @Summary 删除一个视频作者 +// @Description 删除一个视频作者 +// @Accept json +// @Produce json +// @Param param body srv_videocreater.VideoCreaterDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocreater/del [POST] +func DelOneVideoCreater(ctx *gin.Context) { + var req = &srv_videocreater.VideoCreaterDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videocreater.DelVideoCreater(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 视频作者 +// @Summary 更新一个视频作者 +// @Description 更新一个视频作者 +// @Accept json +// @Produce json +// @Param param body srv_videocreater.VideoCreaterUpdate true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocreater/update [POST] +func UpdateOneVideoCreater(ctx *gin.Context) { + var req = &srv_videocreater.VideoCreaterUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_videocreater.UpdateVideoCreater(ctx, req.Id, req.Param) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_video/externallist.go b/web/api/web_video/externallist.go new file mode 100644 index 0000000..9aae43e --- /dev/null +++ b/web/api/web_video/externallist.go @@ -0,0 +1,24 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_videoinfo" + + "github.com/gin-gonic/gin" +) + +// VideoList 按日期查询已上架视频。 +func VideoList(ctx *gin.Context) { + param := srv_videoinfo.ExternalVideoListReq{} + if err := ctx.ShouldBindQuery(¶m); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + data, serr := srv_videoinfo.SearchExternalVideoList(ctx, ¶m) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, data) +} diff --git a/web/api/web_video/hottag.go b/web/api/web_video/hottag.go new file mode 100644 index 0000000..2d38e7b --- /dev/null +++ b/web/api/web_video/hottag.go @@ -0,0 +1,43 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_videotag" + + "github.com/gin-gonic/gin" +) + +// @Tags 标签管理 +// @Summary 查询播放量最多的标签 +// @Description 根据视频累计播放量聚合返回热门标签列表 +// @Accept json +// @Produce json +// @Param limit query int false "返回条数,默认10,最大1000" +// @Success 200 {object} srv_videotag.HotTagResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /wbapi/hjsq/hottag [GET] +func GetVideoPlayData(ctx *gin.Context) { + var req = &srv_videotag.HotTagReq{} + if err := ctx.ShouldBindQuery(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_videotag.GetHotTagsByPlayData(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags IP统计 +// @Summary 查询今日和昨日去重访客IP数 +// @Produce json +// @Success 200 {object} srv_videotag.IpStatReq "成功" +// @Router /wbapi/hjsq/ipstat [GET] +func GetIpStatData(ctx *gin.Context) { + fetchIpStat := srv_videotag.FetchIpStat(ctx) + common.ServerJSON(ctx, srvdef.Success, fetchIpStat) +} diff --git a/web/api/web_video/m3u8.go b/web/api/web_video/m3u8.go new file mode 100644 index 0000000..ec0efff --- /dev/null +++ b/web/api/web_video/m3u8.go @@ -0,0 +1,102 @@ +package web_video + +import ( + "bytes" + "go.mongodb.org/mongo-driver/bson" + "io" + "net/http" + "videoweb/pub/common" + "videoweb/pub/common/m3u8" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/cfg" + "videoweb/web/webg" + + "github.com/gin-gonic/gin" +) + +type fileHandler func(ctx *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) + +func Play(ctx *gin.Context) { + fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) { + m3u8Name := ctx.Param("key") + + var req m3u8.Req + if err = ctx.BindQuery(&req); err != nil { + return + } + req.Name = m3u8Name + cfgSys, _ := cfg.FindOneSysCfg(ctx, bson.M{}) + cdnDomain := webg.Conf.CdnDomain + if cfgSys != nil { + cdnDomain = cfgSys.MediaWebCdn + } + origDomain := webg.Conf.OrigDomain + _, buff, err := m3u8.TransFormWithSafely(&req, cdnDomain, origDomain, true, webg.Conf.TsAuthKey, webg.Conf.LsjMediaConf.M3u8Yuan, "") + if err != nil { + return + } + contentType = "application/vnd.apple.mpegurl" + reader = bytes.NewReader(buff.Bytes()) + contentLength = int64(buff.Len()) + extraHeaders = map[string]string{} + return + } + + handlerFile(ctx, fn) +} + +func Enkey(ctx *gin.Context) { + fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) { + buffer, err := m3u8.Enkey(c) + if err != nil { + return + } + contentLength = int64(buffer.Len()) + contentType = "application/octet-stream" + reader = buffer + extraHeaders = map[string]string{} + return + } + handlerFile(ctx, fn) +} + +// TranscodeM3u8 转码专用 m3u8 接口(不鉴权)。 +func TranscodeM3u8(ctx *gin.Context) { + fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) { + name := ctx.Param("key") + _, buff, err := m3u8.TransformForTranscode(name, webg.Conf.OrigDomain, webg.Conf.CdnDomain, webg.Conf.WebM3u8Host, webg.Conf.LsjMediaConf.M3u8Yuan, webg.Conf.LsjMediaConf.TsYuan) + if err != nil { + return + } + contentType = "application/vnd.apple.mpegurl" + reader = bytes.NewReader(buff.Bytes()) + contentLength = int64(buff.Len()) + extraHeaders = map[string]string{} + return + } + handlerFile(ctx, fn) +} + +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 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) +} diff --git a/web/api/web_video/taginfo.go b/web/api/web_video/taginfo.go new file mode 100644 index 0000000..7c75bb4 --- /dev/null +++ b/web/api/web_video/taginfo.go @@ -0,0 +1,111 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotagmod" + "videoweb/web/service/srv_videotag" + + "github.com/gin-gonic/gin" +) + +// @Tags 标签管理 +// @Summary 分组分页获取标签管理 +// @Description 分组分页获取标签管理 +// @Accept json +// @Produce json +// @Param param body srv_videotag.VideoTagInfoReq true "参数列表" +// @Success 200 {object} srv_videotag.VideoTagInfoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotag/list [POST] +func GetVideoTagInfo(ctx *gin.Context) { + var req = &srv_videotag.VideoTagInfoReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_videotag.GetVideoTagInfos(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 标签管理 +// @Summary 添加一个标签管理 +// @Description 添加一个标签管理 +// @Accept json +// @Produce json +// @Param param body videotagmod.VideoTagInfo true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotag/add [POST] +func AddOneVideoTagInfo(ctx *gin.Context) { + var req = &videotagmod.VideoTagInfo{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videotag.AddVideoTagInfo(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 标签管理 +// @Summary 删除一个标签管理 +// @Description 删除一个标签管理 +// @Accept json +// @Produce json +// @Param param body srv_videotag.VideoTagInfoDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotag/del [POST] +func DelOneVideoTagInfo(ctx *gin.Context) { + var req = &srv_videotag.VideoTagInfoDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videotag.DelVideoTagInfo(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 标签管理 +// @Summary 更新一个标签管理 +// @Description 更新一个标签管理 +// @Accept json +// @Produce json +// @Param param body srv_videotag.VideoTagInfoUpdate true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotag/update [POST] +func UpdateOneVideoTagInfo(ctx *gin.Context) { + var req = &srv_videotag.VideoTagInfoUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_videotag.UpdateVideoTagInfo(ctx, req.Id, req.Param) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_video/video.go b/web/api/web_video/video.go new file mode 100644 index 0000000..bd1d0e2 --- /dev/null +++ b/web/api/web_video/video.go @@ -0,0 +1,344 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/middleware/auth" + "videoweb/web/service/srv_videoinfo" + + "github.com/gin-gonic/gin" +) + +// @Tags 视频管理 +// @Summary 分组分页获取视频管理 +// @Description 分组分页获取视频管理 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.VideoInfoReq true "参数列表" +// @Success 200 {object} srv_videoinfo.VideoInfoResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/list [POST] +func GetVideoInfo(ctx *gin.Context) { + var req = &srv_videoinfo.VideoInfoReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_videoinfo.GetVideoInfos(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetVideoRecommend 单/批量设置视频是否推荐 +// @Tags 视频管理 +// @Summary 单/批量设置视频是否推荐 -- by 王鸣 +// @Description 根据视频ID集合批量更新 video_information 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.SetRecommendReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/recommend/set [post] +func SetVideoRecommend(ctx *gin.Context) { + req := &srv_videoinfo.SetRecommendReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + if err := srv_videoinfo.SetVideoRecommend(ctx, req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetVideoFakeLikes 单/批量设置视频假点赞数 +// @Tags 视频管理 +// @Summary 单/批量设置视频假点赞数 --by 旺鸣 +// @Description 根据ids批量随机设置视频fake.likes,随机范围包含最小值和最大值 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.SetFakeLikesReq true "参数列表" +// @Success 200 {object} srv_videoinfo.SetFakeCountResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/setFakeLikes [post] +func SetVideoFakeLikes(ctx *gin.Context) { + req := &srv_videoinfo.SetFakeLikesReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + resp, err := srv_videoinfo.SetVideoFakeLikes(ctx, req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetVideoFakeWatchTimes 单/批量设置视频假观看次数 +// @Tags 视频管理 +// @Summary 单/批量设置视频假观看次数 --by 旺鸣 +// @Description 根据ids批量随机设置视频fake.watchTimes,随机范围包含最小值和最大值 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.SetFakeWatchTimesReq true "参数列表" +// @Success 200 {object} srv_videoinfo.SetFakeCountResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/setFakeWatchTimes [post] +func SetVideoFakeWatchTimes(ctx *gin.Context) { + req := &srv_videoinfo.SetFakeWatchTimesReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + resp, err := srv_videoinfo.SetVideoFakeWatchTimes(ctx, req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetVideoHeat 单/批量设置视频热度 +// @Tags 视频管理 +// @Summary 单/批量设置视频热度 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.SetVideoHeatReq true "视频热度设置请求" +// @Success 200 {object} srv_videoinfo.SetFakeCountResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/heat/set [post] +func SetVideoHeat(ctx *gin.Context) { + req := &srv_videoinfo.SetVideoHeatReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + resp, err := srv_videoinfo.SetVideoHeat(ctx, req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// /api/web/videoinfo/fillZeroFakeFlow [post] +func FillZeroVideoFakeFlow(ctx *gin.Context) { + //临时注释,有需要再放开 + //resp, err := srv_videoinfo.FillZeroVideoFakeFlow(ctx) + //if err != nil { + // common.ServerJSON(ctx, err.Code, err.Tips) + // return + //} + //common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 视频管理 +// @Summary 从切片列表同步多个视频 +// @Description 从切片列表同步多个视频 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.AddVideoReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/sync [POST] +func SyncVideoInfo(ctx *gin.Context) { + var req = &srv_videoinfo.AddVideoReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videoinfo.SyncVideoInfo(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 视频管理 +// @Summary 从媒资库直同步到视频列表 +// @Description 从媒资库直同步到视频列表,后续切片同步时按 mediaSourceId 覆盖 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.SyncMediaFileReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/syncMediaFile [POST] +func SyncVideoFromMediaFile(ctx *gin.Context) { + var req = &srv_videoinfo.SyncMediaFileReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + result, serr := srv_videoinfo.SyncVideoFromMediaFile(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, result) +} + +// @Tags 视频管理 +// @Summary 删除一个视频管理 +// @Description 删除一个视频管理 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.VideoInfoDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/del [POST] +func DelOneVideoInfo(ctx *gin.Context) { + var req = &srv_videoinfo.VideoInfoDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videoinfo.DelVideoInfo(ctx, req.Id) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 视频管理 +// @Summary 更新一个视频管理 +// @Description 更新一个视频管理 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.VideoInfoUpdateReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/update [POST] +func UpdateOneVideoInfo(ctx *gin.Context) { + var req = &srv_videoinfo.VideoInfoUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.New(srvdef.ErrParamError, err.Error(), err.Error()), nil) + return + } + userId := ctx.GetInt64(auth.AccId) + serr := srv_videoinfo.UpdateVideoInfo(ctx, req.Id, userId, req.Param) + if serr != nil { + common.ServerJSON(ctx, videoUpdateErr(serr), nil) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// videoUpdateErr 把业务错误里的具体原因带到接口提示里。 +func videoUpdateErr(err *srvdef.SrvErr) *srvdef.SrvErr { + if err == nil { + return nil + } + if err.Tips == "" && err.Msg != "" && err.Msg != err.Code.Msg() { + return srvdef.New(err.Code, err.Msg, err.Msg) + } + return err +} + +// @Tags 视频管理 +// @Summary 新增一个视频合集 +// @Description 新增一个视频合集 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.AddVideoCollectionReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/addcoll [POST] +func AddOneVideoCollection(ctx *gin.Context) { + var req = &srv_videoinfo.AddVideoCollectionReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// AddVideoTags 批量追加视频标签 +// @Tags 视频管理 +// @Summary 批量追加视频标签 -- by 王鸣 +// @Description 根据视频ID集合批量追加统一标签,已存在的标签不会重复追加 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.VideoAddTagsReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/addTags [post] +func AddVideoTags(ctx *gin.Context) { + req := &srv_videoinfo.VideoAddTagsReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + if err := srv_videoinfo.AddVideoTags(ctx, req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetVideoTags 单/批量设置视频标签 +// @Tags 视频管理 +// @Summary 单/批量设置视频标签 -- by 王鸣 +// @Description 根据视频ID集合批量覆盖 video_information 集合 tags 字段,传1个ID表示单条设置,传多个ID表示批量设置,tagIds传空数组表示清空标签 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.VideoSetTagsReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/tag/set [post] +func SetVideoTags(ctx *gin.Context) { + req := &srv_videoinfo.VideoSetTagsReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + if err := srv_videoinfo.SetVideoTags(ctx, req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +type TranscodeVideoReq struct { + Id int64 `json:"id" binding:"required"` +} + +// @Tags 视频管理 +// @Summary 提交视频 H265 转码 +// @Router /api/web/videoinfo/transcode [POST] +func TranscodeVideo(ctx *gin.Context) { + var req TranscodeVideoReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if serr := srv_videoinfo.TranscodeVideo(ctx, req.Id); serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, nil) +} diff --git a/web/api/web_videocollection/videocollection.go b/web/api/web_videocollection/videocollection.go new file mode 100644 index 0000000..7881c95 --- /dev/null +++ b/web/api/web_videocollection/videocollection.go @@ -0,0 +1,83 @@ +package web_videocollection + +import ( + "github.com/gin-gonic/gin" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_videocollection" +) + +// @Tags 合集管理 +// @Summary 获取合集视频列表 +// @Description 获取合集视频列表 +// @Accept json +// @Produce json +// @Param param body srv_videocollection.GetCollectionMediaListReq true "参数列表" +// @Success 200 {object} srv_videocollection.GetCollectionMediaListResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocollection/media/list [POST] +func GetVideoCollectionMediaList(ctx *gin.Context) { + var req = &srv_videocollection.GetCollectionMediaListReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_videocollection.GetCollectionMediaList(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// @Tags 合集管理 +// @Summary 修改合集里的视频列表 +// @Description 修改合集里的视频列表 +// @Accept json +// @Produce json +// @Param param body srv_videocollection.VideoCollectionOptionMediaReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocollection/media/option [POST] +func UpdateVideoCollectionMediaList(ctx *gin.Context) { + var req = &srv_videocollection.VideoCollectionOptionMediaReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videocollection.VideoCollectionOptionMedia(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 合集管理 +// @Summary 修改合集里的视频排序 +// @Description 修改合集里的视频排序 +// @Accept json +// @Produce json +// @Param param body srv_videocollection.UpdateCollectionMediaRankReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videocollection/media/rank [POST] +func UpdateVideoCollectionMediaRank(ctx *gin.Context) { + var req = &srv_videocollection.UpdateCollectionMediaRankReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videocollection.UpdateCollectionMediaRank(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} \ No newline at end of file diff --git a/web/conf/m3u8.key b/web/conf/m3u8.key new file mode 100644 index 0000000..6cce2a7 --- /dev/null +++ b/web/conf/m3u8.key @@ -0,0 +1 @@ +—�ˆȒ š ÁC�„ \ No newline at end of file diff --git a/web/conf/web.yaml b/web/conf/web.yaml new file mode 100644 index 0000000..ff61555 --- /dev/null +++ b/web/conf/web.yaml @@ -0,0 +1,39 @@ +redis: + addr: 172.104.181.99:16789 + db: 3 + passwd: "B6vJHlYiTseLj77z" +mongodb: + addr: "mongodb://admin:TESTwfeZYP0kjBdy8DAUdwU@172.104.181.99:57017,172.104.181.99:57018,172.104.181.99:57019/web_mdcom?replicaSet=yctest&maxPoolSize=200&connectTimeout=30&authSource=admin" + logaddr: "mongodb://admin:TESTwfeZYP0kjBdy8DAUdwU@172.104.181.99:57017,172.104.181.99:57018,172.104.181.99:57019/web_mdcom_log?replicaSet=yctest&maxPoolSize=200&connectTimeout=30&authSource=admin" +es: + addr: "http://172.104.181.99:9200" + user: "elastic" + pass: "admin123456" +jwtKey: "2311b15a4da1c80dc541b99abb622233" +tgToken: "" +runMod: "dev" +tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN" +pprof: false +host: "172.104.181.99" +listenAddr: "25121" +prefetchSubmitUrl: "http://172.104.171.80:9067/master/submit" +aiCenterAppId: 25222 +aiUndressingCallbackUrl: "https://web_mdcomtest-ht.80sygtm.com/api/web/aiorder/strip/callback" +upAppId: 8 +swagger: + host: "172.104.181.99" + user: "mdc" + pass: "2@6A1#9e%9c2" +bucketVideoAddressV2: "http://139.162.22.207:24996" +bucketVideoAddress: "http://tong3yuants.bbaazz.me" + +# 媒资配置 +mediaResourceDBCfg: + baseUrl: "https://mediav2-api.htycpc.com" + merchat: "91porn" + secret: "08f1c3ab5e11e9b6c18aa24c06d1eb77" + +# 原创短剧数据回流到 AI 内容中台 +dataCenterBackflow: + key: "ld_XLWM9pojLnf_OZFQ1ISac4cELop-WnJH_CcjS5D_ROY" + selfSourceBaseUrl: "https://172.104.181.99:25128/api/web/m3u8" diff --git a/web/deploy_prod.sh b/web/deploy_prod.sh new file mode 100755 index 0000000..e518b5c --- /dev/null +++ b/web/deploy_prod.sh @@ -0,0 +1,11 @@ +#!/bin/bash +echo "开始" +rm -rf ./web_hghl_web ./web_hghl_web.tar.gz +GOOS=linux GOARCH=amd64 go build -o web_hghl_web +tar zvcf web_hghl_web.tar.gz web_hghl_web + + +scp web_hghl_web.tar.gz root@172.104.166.87:/data/uzip +ssh root@172.104.166.87 "/server/web_hghl/web/up_app.sh" + +rm -rf ./web_hghl_web ./web_hghl_web.tar.gz \ No newline at end of file diff --git a/web/docs/docs.go b/web/docs/docs.go new file mode 100644 index 0000000..c6b096d --- /dev/null +++ b/web/docs/docs.go @@ -0,0 +1,13396 @@ +// Package swag_web_docs Code generated by swaggo/swag. DO NOT EDIT +package swag_web_docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/web/acc/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-添加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-添加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccAddResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccDelResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/getinfo": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-获取信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-获取信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/getstatus": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-获取状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-获取状态", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetStatusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetStatusResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/ops": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-获取操作记录", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-获取操作记录", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.AccOpLogReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.AccOpLogResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/savestatus": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-保存状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-保存状态", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccSaveStatusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccSaveStatusResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/up": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/uppwd": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-更新密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-更新密码", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpPwdReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpPwdReq" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "添加一个女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "删除一个女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "分组分页获取女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_actress.ActressListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "更新一个女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/del": { + "post": { + "description": "删除一个联系我们", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们" + ], + "summary": "删除一个联系我们", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_contactus.DelOneContactusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/list": { + "post": { + "description": "联系我们列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们" + ], + "summary": "联系我们列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_contactus.GetContactusListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_contactus.GetContactusListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改联系我们状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们" + ], + "summary": "修改联系我们状态", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_contactus.UpdateContactusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "新增统一标签 -- by 王鸣", + "parameters": [ + { + "description": "新增统一标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContentTagAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContentTagAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "删除统一标签 -- by 王鸣", + "parameters": [ + { + "description": "删除统一标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContentTagDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询统一标签配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "统一标签列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "标签ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "标签名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "状态 true-启用 false-停用", + "name": "status", + "in": "query" + }, + { + "enum": [ + 1, + 5 + ], + "type": "integer", + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ], + "description": "内容类型 1-视频 5-短剧", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContentTagListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/option": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "查询启用的统一标签下拉列表,传type时按支持类型过滤", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "统一标签下拉列表 -- by 王鸣", + "parameters": [ + { + "enum": [ + 1, + 5 + ], + "type": "integer", + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ], + "description": "内容类型 1-视频 5-短剧,不传返回全部启用标签", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContentTagOptionResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "更新统一标签 -- by 王鸣", + "parameters": [ + { + "description": "更新统一标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContentTagUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-增加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-增加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.AddDetailNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.DelDetailNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.GetDetailNavListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_detailnav.GetDetailNavListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.UpdateDetailNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/file/uploadImg": { + "post": { + "description": "图片上传", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "文件上传" + ], + "summary": "图片上传", + "parameters": [ + { + "type": "file", + "description": "文件", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_file.UploadImageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/file/uploadImgs": { + "post": { + "description": "图片批量上传", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "文件上传" + ], + "summary": "图片批量上传", + "parameters": [ + { + "type": "file", + "description": "文件", + "name": "file[]", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_file.UploadImagesResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "新增首页banner -- by 王鸣", + "parameters": [ + { + "description": "新增首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.HomeBannerAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeBannerAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "删除首页banner -- by 王鸣", + "parameters": [ + { + "description": "删除首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.HomeBannerDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询首页banner配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "首页banner列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "首页 banner ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认10,最大100", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "状态 true-启用 false-停用", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeBannerListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "更新首页banner -- by 王鸣", + "parameters": [ + { + "description": "更新首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.HomeBannerUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-增加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-增加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.AddHotNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.DelHotNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.GetHotNavListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_hotnav.GetHotNavListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.UpdateHotNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/ipblack/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "IP黑名单-增加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "IP黑名单" + ], + "summary": "IP黑名单-增加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_ipblack.AddIPBlackListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/ipblack/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "IP黑名单-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "IP黑名单" + ], + "summary": "IP黑名单-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_ipblack.DelIpBlackListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/ipblack/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "IP黑名单-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "IP黑名单" + ], + "summary": "IP黑名单-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_ipblack.GetIPBlackListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_ipblack.GetIPBlackListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/login/acc": { + "post": { + "description": "登录-登录", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "登录" + ], + "summary": "登录-登录", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MainWebLoginReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MainWebLoginResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/login/bind": { + "post": { + "description": "登录-绑定二维码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "登录" + ], + "summary": "登录-绑定二维码", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.BindQurlReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.BindQurlResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/login/slogin": { + "post": { + "description": "管理员sso登陆", + "consumes": [ + "multipart/form-data", + "application/json" + ], + "produces": [ + "application/json", + "text/html" + ], + "tags": [ + "web-单点登陆" + ], + "summary": "管理员sso登陆", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "name", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "密码", + "name": "password", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"msg\": \"管理员信息\"\"}", + "schema": { + "type": "string" + } + }, + "400": { + "description": "{\"msg\": \"操作失败\"}", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "新增 playlet 集合短剧数据,支持分类、题材、标签、支付方式、推荐和 banner 配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧 -- by 王鸣", + "parameters": [ + { + "description": "新增短剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/banner/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 showOnBanner 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧是否显示在首页banner -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧是否显示在首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletBannerSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "新增短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端删除短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "删除短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧分类列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "分类ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "分类名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "启用状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 categoryIds 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategorySetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "更新短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "新增 playlet_chapter 集合短剧剧集数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧剧集 -- by 王鸣", + "parameters": [ + { + "description": "新增短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据剧集ID集合删除 playlet_chapter 记录,仅原创短剧支持删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量删除短剧剧集", + "parameters": [ + { + "description": "单/批量删除短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询 playlet_chapter 集合短剧剧集数据,默认按集数升序返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧剧集列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "剧集ID", + "name": "id", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "短剧ID", + "name": "playletId", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "x-enum-comments": { + "WatermarkStatusDone": "已完成", + "WatermarkStatusFailed": "处理失败", + "WatermarkStatusPending": "未处理(待 mediafile/sync)", + "WatermarkStatusProcessing": "处理中(已提交加水印任务,待回写)", + "WatermarkStatusRegistered": "已登记(原片已登记,待 task/add)" + }, + "x-enum-varnames": [ + "WatermarkStatusPending", + "WatermarkStatusRegistered", + "WatermarkStatusProcessing", + "WatermarkStatusDone", + "WatermarkStatusFailed" + ], + "description": "水印状态 0-未处理 1-已登记 2-处理中 3-已完成 4-处理失败", + "name": "watermarkStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/pay-type/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据剧集ID集合批量更新 playlet_chapter 集合 payType 和 singleCoins 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧剧集支付方式 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧剧集支付方式请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterPayTypeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合从媒资中心同步新增剧集,playletIds传1个表示单条同步,传多个表示批量同步,允许已完结短剧补集或修正状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端手动单/批量同步短剧剧集", + "parameters": [ + { + "description": "手动单/批量同步短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterSyncResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按剧集ID编辑 playlet_chapter 集合短剧剧集数据,只更新请求中传入的字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端编辑短剧剧集 -- by 王鸣", + "parameters": [ + { + "description": "编辑短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/featured/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 isFeatured 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧是否爆剧 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧是否爆剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletFeaturedSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/like/virtual/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 virtualCountLike 字段,每条短剧从最小值和最大值之间随机取不重复数值入库", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧虚拟点赞数 -- by 王鸣", + "parameters": [ + { + "description": "单/批量随机设置短剧虚拟点赞数请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletVirtualLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "分类ID", + "name": "categoryId", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "x-enum-comments": { + "ContentTypeAlbum": "普通短剧/影集(对应 media_type=6)", + "ContentTypeAnime": "动漫(对应 media_type=1)", + "ContentTypeComic": "漫画短剧(对应 media_type=4)", + "ContentTypeManga": "漫画(对应 media_type=2)", + "ContentTypeTheater": "短剧(对应 media_type=5)" + }, + "x-enum-varnames": [ + "ContentTypeTheater", + "ContentTypeAlbum", + "ContentTypeComic", + "ContentTypeAnime", + "ContentTypeManga" + ], + "description": "内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画", + "name": "contentType", + "in": "query" + }, + { + "type": "integer", + "description": "短剧ID", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "description": "是否爆剧,true-是、false-否", + "name": "isFeatured", + "in": "query" + }, + { + "type": "boolean", + "description": "是否原创:true只查原创,false只查非原创,不传查全部", + "name": "isOriginal", + "in": "query" + }, + { + "type": "boolean", + "description": "是否推荐,true-推荐、false-不推荐", + "name": "isRecommend", + "in": "query" + }, + { + "type": "string", + "description": "最后操作人名称,支持模糊查询", + "name": "operatorName", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ], + "description": "支付方式,0-免费、1-金币、2-VIP", + "name": "payType", + "in": "query" + }, + { + "type": "boolean", + "description": "是否显示在首页banner,true-显示、false-不显示", + "name": "showOnBanner", + "in": "query" + }, + { + "enum": [ + 0, + 1 + ], + "type": "integer", + "x-enum-comments": { + "StatusDown": "下架", + "StatusUp": "上架" + }, + "x-enum-varnames": [ + "StatusDown", + "StatusUp" + ], + "description": "上架状态,0-下架、1-上架", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID", + "name": "tagId", + "in": "query" + }, + { + "type": "integer", + "description": "题材ID", + "name": "themeId", + "in": "query" + }, + { + "type": "string", + "description": "短剧标题,支持模糊查询", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/pay-type/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 payType/singleCoins 字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧默认支付方式 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧默认支付方式请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletPayTypeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/recommend/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧是否推荐 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧是否推荐请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletRecommendSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/status/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 status 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧上下架 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧上下架请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletStatusSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增标签 -- by 王鸣", + "parameters": [ + { + "description": "新增标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletTagAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端删除标签 -- by 王鸣", + "parameters": [ + { + "description": "删除标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧标签", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端标签列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "string", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletTagListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 tagIds 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧标签 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新标签 -- by 王鸣", + "parameters": [ + { + "description": "更新标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增题材 -- by 王鸣", + "parameters": [ + { + "description": "新增题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端删除题材 -- by 王鸣", + "parameters": [ + { + "description": "删除题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧题材", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端题材列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "题材ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "题材名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "上架状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧题材 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新题材 -- by 王鸣", + "parameters": [ + { + "description": "更新题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按短剧ID编辑 playlet 集合短剧数据,只更新请求中传入的字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端编辑短剧 -- by 王鸣", + "parameters": [ + { + "description": "编辑短剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/watermark/reset": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合重置剧集水印状态,保留已有水印地址以便换水印期间继续观看", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端按短剧ID批量重置剧集水印 -- by 王鸣", + "parameters": [ + { + "description": "批量重置短剧水印请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletWatermarkResetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/del": { + "post": { + "description": "删除一个评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "删除一个评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_commend.CommendDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/list": { + "post": { + "description": "评论列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "评论列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_commend.CommendSearch" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_commend.CommendSearchResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/replay": { + "post": { + "description": "回复评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "回复评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_commend.CommendReplay" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/review": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "审核评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "审核评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_commend.CommendReviewReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/reviewBatch": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "批量审核评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "批量审核评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_commend.CommendReviewBatchReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/user/del": { + "post": { + "description": "删除一个用户所有评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "删除一个用户所有评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_commend.CommendDelIpReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/questionnaire/handle": { + "post": { + "description": "只有状态为\"待处理\"的问卷才允许更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "问卷调查" + ], + "summary": "处理问卷", + "parameters": [ + { + "description": "处理参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_questionnaire.HandleReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/questionnaire/list": { + "post": { + "description": "支持分页及条件筛选(question_type、status、时间范围)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "问卷调查" + ], + "summary": "查询问卷列表", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_questionnaire.ListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_questionnaire.ListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/resource/chapters": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按媒资ID查询剧集信息,复用入库时的 mediaSync 接口", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端短剧媒资剧集列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "媒资中心资源ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletResourceChapterListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/resource/import": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "提交媒资入库任务,后台从媒资中心同步短剧详情并写入短剧和章节集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端短剧媒资入库 -- by 王鸣", + "parameters": [ + { + "description": "短剧媒资入库请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletResourceUseReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/resource/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧媒资列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端短剧媒资列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "媒体类型 1-动漫 2-漫画 4-漫画短剧 5-短剧 6-普通短剧", + "name": "media_type", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNumber", + "in": "query" + }, + { + "type": "integer", + "description": "个数", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "同步类型 1-全量同步 2-增量同步", + "name": "sync_type", + "in": "query" + }, + { + "type": "string", + "description": "标签", + "name": "tag", + "in": "query" + }, + { + "type": "string", + "description": "标题", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletResourceListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "添加一个富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/richtextcontentmod.RichTextContentModel" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "删除一个富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_richtextcontent.DelRichTextContentReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "分组分页获取富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_richtextcontent.GetRichTextContentListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_richtextcontent.GetRichTextContentResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "更新一个富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_richtextcontent.UpdateRichTextContentReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-添加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-添加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleAddResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleDelResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/drop": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-下拉列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-下拉列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleDropResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/enable": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-使能", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-使能", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleEnableReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleEnableResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/getauth": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-获取权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-获取权限", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetAuthReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetAuthResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/getinfo": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-获取信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-获取信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pubdef.Page" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/up": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/upauth": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-更新权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-更新权限", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpAuthReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpAuthResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/slug/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "为视频、分类、女优、作者、短剧、统一标签等空 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。type 为空处理全部,也可指定 video/videoCategory/actress/videoCreater/playlet/playletCategory/playletTheme/contentTag。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web slug相关接口" + ], + "summary": "同步历史空 slug", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/proto.SlugSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.SlugSyncResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/stat/analytics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "统计数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "获取统计数据" + ], + "summary": "统计数据", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/stat/daily/analytics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "统计数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "获取统计数据" + ], + "summary": "统计数据", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/syscfg/get": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "获取系统配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "系统配置" + ], + "summary": "获取系统配置", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_cfg.GetSysConfigReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_cfg.GetSysConfigResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/syscfg/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改系统配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "系统配置" + ], + "summary": "修改系统配置", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_cfg.SysConfigUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_cfg.SysConfigUpdateResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "添加一个导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/translationmod.TranslationInfo" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "删除一个导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "分组分页获取导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "更新一个导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "添加一个导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/translationcategorymod.TranslationCategory" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "删除一个导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "分组分页获取导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "更新一个导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/user/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "查询用户列表", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.UserListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_user.UserListResp" + } + } + } + } + }, + "/api/web/user/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "更新用户信息", + "parameters": [ + { + "description": "更新参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.UserUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "添加一个视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocategorymod.VideoCategory" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "删除一个视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "分组分页获取视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "更新一个视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryUpdate" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocollection/media/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "获取合集视频列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合集管理" + ], + "summary": "获取合集视频列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocollection.GetCollectionMediaListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videocollection.GetCollectionMediaListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocollection/media/option": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改合集里的视频列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合集管理" + ], + "summary": "修改合集里的视频列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocollection.VideoCollectionOptionMediaReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocollection/media/rank": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改合集里的视频排序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合集管理" + ], + "summary": "修改合集里的视频排序", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocollection.UpdateCollectionMediaRankReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "添加一个视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocreatermod.VideoCreaterInfo" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "删除一个视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "分组分页获取视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "更新一个视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterUpdate" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/addTags": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据视频ID集合批量追加统一标签,已存在的标签不会重复追加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "批量追加视频标签 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoAddTagsReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/addcoll": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "新增一个视频合集", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "新增一个视频合集", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.AddVideoCollectionReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个视频管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "删除一个视频管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取视频管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "分组分页获取视频管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/recommend/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据视频ID集合批量更新 video_information 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频是否推荐 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetRecommendReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/setFakeLikes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据ids批量随机设置视频fake.likes,随机范围包含最小值和最大值", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频假点赞数 --by 旺鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeLikesReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeCountResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/setFakeWatchTimes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据ids批量随机设置视频fake.watchTimes,随机范围包含最小值和最大值", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频假观看次数 --by 旺鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeWatchTimesReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeCountResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "从切片列表同步多个视频", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "从切片列表同步多个视频", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.AddVideoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/syncMediaFile": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "从媒资库直同步到视频列表,后续切片同步时按 mediaSourceId 覆盖", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "从媒资库直同步到视频列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SyncMediaFileReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/tag/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据视频ID集合批量覆盖 video_information 集合 tags 字段,传1个ID表示单条设置,传多个ID表示批量设置,tagIds传空数组表示清空标签", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频标签 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoSetTagsReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/transcode": { + "post": { + "tags": [ + "视频管理" + ], + "summary": "提交视频 H265 转码", + "responses": {} + } + }, + "/api/web/videoinfo/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个视频管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "更新一个视频管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "添加一个标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videotagmod.VideoTagInfo" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "删除一个标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "分组分页获取标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "更新一个标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoUpdate" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/wbapi/hjsq/hottag": { + "get": { + "description": "根据视频累计播放量聚合返回热门标签列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "查询播放量最多的标签", + "parameters": [ + { + "type": "integer", + "description": "返回条数,默认10,最大1000", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.HotTagResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/wbapi/hjsq/ipstat": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "IP统计" + ], + "summary": "查询今日和昨日去重访客IP数", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.IpStatReq" + } + } + } + } + } + }, + "definitions": { + "actress.Actress": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "office": { + "description": "事务所", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "real": { + "description": "真实流量", + "allOf": [ + { + "$ref": "#/definitions/pubdef.Flow" + } + ] + }, + "realUpdateAt": { + "description": "播放相关数据更新时间", + "type": "string" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "actress.Meas": { + "type": "object", + "properties": { + "blood": { + "description": "血型", + "type": "string" + }, + "bust": { + "description": "胸围 英寸", + "type": "integer" + }, + "cup": { + "description": "罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I", + "type": "integer" + }, + "dia": { + "description": "直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45)", + "type": "integer" + }, + "gender": { + "description": "性别 0:女 1:男 2:其他", + "type": "integer" + }, + "height": { + "description": "身高 厘米", + "type": "integer" + }, + "hip": { + "description": "臀围 英寸", + "type": "integer" + }, + "len": { + "description": "长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145)", + "type": "integer" + }, + "waist": { + "description": "腰围 英寸", + "type": "integer" + }, + "weight": { + "description": "体重 公斤", + "type": "integer" + } + } + }, + "contactusmod.ContactUsModel": { + "type": "object", + "properties": { + "categoryName": { + "description": "主旨分类", + "type": "string" + }, + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "email": { + "description": "邮件", + "type": "string" + }, + "id": { + "description": "ID", + "type": "string" + }, + "ip": { + "description": "评论IP", + "type": "string" + }, + "name": { + "description": "名字", + "type": "string" + }, + "status": { + "description": "状态 0=初始 1=已回复 2=不予处理 3=已完结", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "contenttagmod.ContentTagType": { + "type": "integer", + "enum": [ + 1, + 5 + ], + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ] + }, + "detailnavmod.DetailNavModel": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "description": "导航名称", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "url": { + "description": "导航链接", + "type": "string" + } + } + }, + "hotnavmod.HotNav": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "intro": { + "description": "介绍", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + } + } + }, + "ipblackmod.IPBlackModel": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "备注", + "type": "string" + }, + "id": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "playletchaptermod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletchaptermod.WatermarkStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "WatermarkStatusDone": "已完成", + "WatermarkStatusFailed": "处理失败", + "WatermarkStatusPending": "未处理(待 mediafile/sync)", + "WatermarkStatusProcessing": "处理中(已提交加水印任务,待回写)", + "WatermarkStatusRegistered": "已登记(原片已登记,待 task/add)" + }, + "x-enum-varnames": [ + "WatermarkStatusPending", + "WatermarkStatusRegistered", + "WatermarkStatusProcessing", + "WatermarkStatusDone", + "WatermarkStatusFailed" + ] + }, + "playletmod.ContentType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "ContentTypeAlbum": "普通短剧/影集(对应 media_type=6)", + "ContentTypeAnime": "动漫(对应 media_type=1)", + "ContentTypeComic": "漫画短剧(对应 media_type=4)", + "ContentTypeManga": "漫画(对应 media_type=2)", + "ContentTypeTheater": "短剧(对应 media_type=5)" + }, + "x-enum-varnames": [ + "ContentTypeTheater", + "ContentTypeAlbum", + "ContentTypeComic", + "ContentTypeAnime", + "ContentTypeManga" + ] + }, + "playletmod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletmod.Status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "x-enum-comments": { + "StatusDown": "下架", + "StatusUp": "上架" + }, + "x-enum-varnames": [ + "StatusDown", + "StatusUp" + ] + }, + "proto.ContentTagAddReq": { + "type": "object", + "required": [ + "name", + "status", + "types" + ], + "properties": { + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + } + } + }, + "proto.ContentTagAddResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.ContentTagDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.ContentTagListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.ContentTagOptionItem": { + "type": "object", + "properties": { + "bindCount": { + "description": "当前查询类型的绑定数量,不传type时返回0", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "typeNames": { + "description": "支持类型名称", + "type": "array", + "items": { + "type": "string" + } + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + } + } + }, + "proto.ContentTagOptionResp": { + "type": "object", + "properties": { + "list": { + "description": "标签下拉列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentTagOptionItem" + } + } + } + }, + "proto.ContentTagUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + } + } + }, + "proto.HomeBannerAddReq": { + "type": "object", + "required": [ + "image", + "name", + "status" + ], + "properties": { + "image": { + "description": "图片", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "url": { + "description": "URL", + "type": "string" + }, + "verticalImage": { + "description": "竖版图片", + "type": "string" + } + } + }, + "proto.HomeBannerAddResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeBannerDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeBannerListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.HomeBannerUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "image": { + "description": "图片", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "url": { + "description": "URL", + "type": "string" + }, + "verticalImage": { + "description": "竖版图片", + "type": "string" + } + } + }, + "proto.PlayletAddReq": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "contentType": { + "description": "内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "cover": { + "description": "封面原图", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "isFeatured": { + "description": "是否爆剧,true-是、false-否", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐,true-推荐、false-不推荐", + "type": "boolean" + }, + "payType": { + "description": "支付方式,0-免费、1-金币、2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告", + "type": "string" + }, + "recommendRank": { + "description": "推荐排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或标题自动生成", + "type": "string" + }, + "status": { + "description": "上架状态,0-下架、1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topRank": { + "description": "置顶排序", + "type": "integer" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + } + } + }, + "proto.PlayletAddResp": { + "type": "object", + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletBannerSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "showOnBanner": { + "description": "是否显示在首页banner,true-显示、false-不显示", + "type": "boolean" + } + } + }, + "proto.PlayletCategoryAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "description": "分类图标", + "type": "string" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "description": "启用状态", + "type": "boolean" + } + } + }, + "proto.PlayletCategoryAddResp": { + "type": "object", + "properties": { + "id": { + "description": "分类ID", + "type": "integer" + } + } + }, + "proto.PlayletCategoryDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "分类ID", + "type": "integer" + } + } + }, + "proto.PlayletCategoryListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletCategorySetReq": { + "type": "object", + "properties": { + "categoryIds": { + "description": "分类ID集合,可设置多个分类,传空数组表示清空分类,不传表示参数错误", + "type": "array", + "items": { + "type": "integer" + } + }, + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletCategoryUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "icon": { + "description": "分类图标", + "type": "string" + }, + "id": { + "description": "分类ID", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "启用状态", + "type": "boolean" + } + } + }, + "proto.PlayletChapterAddReq": { + "type": "object", + "required": [ + "currentEpisode", + "playletId" + ], + "properties": { + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "视频高度", + "type": "integer" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaSourceId": { + "description": "media切片服原片ID,上传正片已创建原片时直接沿用", + "type": "string" + }, + "mediaTime": { + "description": "影片长度,单位秒", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "title": { + "description": "剧集标题", + "type": "string" + }, + "videoUrl": { + "description": "原片视频地址", + "type": "string" + }, + "width": { + "description": "视频宽度", + "type": "integer" + } + } + }, + "proto.PlayletChapterAddResp": { + "type": "object", + "properties": { + "id": { + "description": "剧集ID", + "type": "integer" + } + } + }, + "proto.PlayletChapterDelReq": { + "type": "object", + "properties": { + "ids": { + "description": "剧集ID集合,传1个表示单条删除,传多个表示批量删除", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletChapterListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletChapterPayTypeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "剧集ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时必填且需大于0", + "type": "integer" + } + } + }, + "proto.PlayletChapterSyncReq": { + "type": "object", + "required": [ + "playletIds" + ], + "properties": { + "playletIds": { + "description": "短剧ID集合,传1个表示单条同步,传多个表示批量同步", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletChapterSyncResp": { + "type": "object", + "properties": { + "failedCount": { + "description": "失败数量", + "type": "integer" + }, + "list": { + "description": "同步结果集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterSyncResult" + } + }, + "successCount": { + "description": "成功数量", + "type": "integer" + }, + "total": { + "description": "同步总数", + "type": "integer" + } + } + }, + "proto.PlayletChapterSyncResult": { + "type": "object", + "properties": { + "newChapterCount": { + "description": "本次新增剧集数量", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "reason": { + "description": "失败原因", + "type": "string" + }, + "success": { + "description": "是否同步成功", + "type": "boolean" + } + } + }, + "proto.PlayletChapterUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "视频高度", + "type": "integer" + }, + "id": { + "description": "剧集ID", + "type": "integer" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度,单位秒", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "title": { + "description": "剧集标题", + "type": "string" + }, + "videoUrl": { + "description": "原片视频地址", + "type": "string" + }, + "width": { + "description": "视频宽度", + "type": "integer" + } + } + }, + "proto.PlayletFeaturedSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isFeatured": { + "description": "是否爆剧,true-是、false-否", + "type": "boolean" + } + } + }, + "proto.PlayletListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletPayTypeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "payType": { + "description": "支付方式,0-免费、1-金币、2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时必填且需大于0", + "type": "integer" + } + } + }, + "proto.PlayletRecommendSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isRecommend": { + "description": "是否推荐,true-推荐、false-不推荐", + "type": "boolean" + } + } + }, + "proto.PlayletResourceChapterItem": { + "type": "object", + "properties": { + "cover": { + "description": "封面", + "type": "string" + }, + "createTime": { + "description": "创建时间", + "type": "integer" + }, + "episodeNumber": { + "description": "第几集", + "type": "integer" + }, + "hashId": { + "description": "资源hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "媒资内容ID", + "type": "integer" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "playTime": { + "description": "播放时长(秒)", + "type": "integer" + }, + "saleCoin": { + "description": "单集售价", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "updateTime": { + "description": "更新时间", + "type": "integer" + }, + "videoUrl": { + "description": "播放地址", + "type": "string" + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletResourceChapterListResp": { + "type": "object", + "properties": { + "list": { + "description": "剧集列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceChapterItem" + } + }, + "mediaId": { + "description": "媒资中心资源ID", + "type": "integer" + }, + "title": { + "description": "短剧标题", + "type": "string" + }, + "total": { + "description": "剧集数量", + "type": "integer" + } + } + }, + "proto.PlayletResourceListItem": { + "type": "object", + "properties": { + "anchor": { + "description": "作者", + "type": "string" + }, + "create_time": { + "description": "创建时间", + "type": "integer" + }, + "data_version": { + "description": "数据版本", + "type": "integer" + }, + "horizontal_cover": { + "description": "横版封面", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isAdd": { + "description": "是否已加入后台数据", + "type": "boolean" + }, + "media_type": { + "description": "媒体类型", + "type": "integer" + }, + "sale_coin": { + "description": "整部售价", + "type": "integer" + }, + "summary": { + "description": "简介", + "type": "string" + }, + "tags_text": { + "description": "标签文字", + "type": "string" + }, + "terminal_cdo": { + "description": "上传者", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "total_episode": { + "description": "总集数", + "type": "integer" + }, + "update_status": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "update_time": { + "description": "修改时间", + "type": "integer" + }, + "vertical_cover": { + "description": "竖版封面", + "type": "string" + }, + "vlogger_id": { + "type": "integer" + } + } + }, + "proto.PlayletResourceListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceListItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "proto.PlayletResourceUseReq": { + "type": "object", + "properties": { + "ids": { + "description": "媒资中心资源ID集合", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletStatusSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "description": "上架状态,0-下架、1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + } + } + }, + "proto.PlayletTagAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletTagAddResp": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletTagDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletTagListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletTagSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagIds": { + "description": "标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletTagUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "description": "题材图标", + "type": "string" + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddResp": { + "type": "object", + "properties": { + "id": { + "description": "题材ID", + "type": "integer" + } + } + }, + "proto.PlayletThemeDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "题材ID", + "type": "integer" + } + } + }, + "proto.PlayletThemeListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletThemeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "description": "题材ID集合,可设置多个题材", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletThemeUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "icon": { + "description": "题材图标", + "type": "string" + }, + "id": { + "description": "题材ID", + "type": "integer" + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合,传空数组表示清空分类", + "type": "array", + "items": { + "type": "integer" + } + }, + "contentType": { + "description": "内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "cover": { + "description": "封面原图", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧,true-是、false-否", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐,true-推荐、false-不推荐", + "type": "boolean" + }, + "payType": { + "description": "支付方式,0-免费、1-金币、2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告", + "type": "string" + }, + "recommendRank": { + "description": "推荐排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "上架状态,0-下架、1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合,传空数组表示清空标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topRank": { + "description": "置顶排序", + "type": "integer" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + } + } + }, + "proto.PlayletVirtualLikeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "maxVirtualCountLike": { + "description": "虚拟点赞数最大值,必须大于等于最小值", + "type": "integer" + }, + "minVirtualCountLike": { + "description": "虚拟点赞数最小值,必须大于等于0", + "type": "integer" + } + } + }, + "proto.PlayletWatermarkResetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条,传多个表示批量", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.SlugSyncReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多处理的空 slug 记录数,默认500,最大2000", + "type": "integer" + }, + "type": { + "description": "可选,指定类型;空则按顺序处理全部", + "type": "string" + } + } + }, + "proto.SlugSyncResp": { + "type": "object", + "properties": { + "actressUpdated": { + "description": "本次补齐的女优数", + "type": "integer" + }, + "contentTagUpdated": { + "description": "本次补齐的统一标签数", + "type": "integer" + }, + "hasMore": { + "description": "是否还有未处理的历史数据", + "type": "boolean" + }, + "playletCategoryUpdated": { + "description": "本次补齐的短剧分类数", + "type": "integer" + }, + "playletThemeUpdated": { + "description": "本次补齐的短剧主题数", + "type": "integer" + }, + "playletUpdated": { + "description": "本次补齐的短剧数", + "type": "integer" + }, + "totalUpdated": { + "description": "本次合计补齐数", + "type": "integer" + }, + "videoCategoryUpdated": { + "description": "本次补齐的视频分类数", + "type": "integer" + }, + "videoCreaterUpdated": { + "description": "本次补齐的作者数", + "type": "integer" + }, + "videoUpdated": { + "description": "本次补齐的视频数", + "type": "integer" + } + } + }, + "pubdef.CategoryDisplayPosition": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "CategoryDisplayPositionBoth": "TAB和首页都显示", + "CategoryDisplayPositionHomeOnly": "仅首页", + "CategoryDisplayPositionTabOnly": "仅TAB" + }, + "x-enum-varnames": [ + "CategoryDisplayPositionTabOnly", + "CategoryDisplayPositionHomeOnly", + "CategoryDisplayPositionBoth" + ] + }, + "pubdef.CategorySortType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "x-enum-comments": { + "CategorySortTypeAddedTimeAsc": "上架时间升序", + "CategorySortTypeAddedTimeDesc": "上架时间降序", + "CategorySortTypeLikeAsc": "点赞(收藏)次数升序", + "CategorySortTypeLikeDesc": "点赞(收藏)次数降序", + "CategorySortTypeSortType": "自定义排序", + "CategorySortTypeUpdateTimeAsc": "更新时间升序", + "CategorySortTypeUpdateTimeDesc": "更新时间降序", + "CategorySortTypeWatchAsc": "观看次数升序", + "CategorySortTypeWatchDesc": "观看次数降序" + }, + "x-enum-varnames": [ + "CategorySortTypeAddedTimeDesc", + "CategorySortTypeWatchDesc", + "CategorySortTypeLikeDesc", + "CategorySortTypeAddedTimeAsc", + "CategorySortTypeWatchAsc", + "CategorySortTypeLikeAsc", + "CategorySortTypeUpdateTimeDesc", + "CategorySortTypeUpdateTimeAsc", + "CategorySortTypeSortType" + ] + }, + "pubdef.CommentObjectType": { + "type": "integer", + "enum": [ + 0, + 4 + ], + "x-enum-comments": { + "CommentObjectTypePlaylet": "短剧", + "CommentObjectTypeVideo": "视频" + }, + "x-enum-varnames": [ + "CommentObjectTypeVideo", + "CommentObjectTypePlaylet" + ] + }, + "pubdef.DisplayMode": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "DisplayModeAvatarList": "头像列表", + "DisplayModeBigCover": "大封面列表(一大图两列)", + "DisplayModeCardCarousel": "卡片层叠轮播", + "DisplayModeDefault": "默认(上图下标题多列)", + "DisplayModeImageText": "图文列表(左图右标题多列)" + }, + "x-enum-varnames": [ + "DisplayModeDefault", + "DisplayModeCardCarousel", + "DisplayModeAvatarList", + "DisplayModeImageText", + "DisplayModeBigCover" + ] + }, + "pubdef.FakeFlow": { + "type": "object", + "properties": { + "likes": { + "description": "假点赞数", + "type": "integer" + }, + "watchTimes": { + "description": "假观看次数", + "type": "integer" + } + } + }, + "pubdef.Flow": { + "type": "object", + "properties": { + "comments": { + "description": "评论条数", + "type": "integer" + }, + "likes": { + "description": "真实点赞数(收藏总量)", + "type": "integer" + }, + "monthLikes": { + "description": "近30日收藏量", + "type": "integer" + }, + "monthWatchs": { + "description": "本月观看次数", + "type": "integer" + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "todayWatchs": { + "description": "当日观看次数", + "type": "integer" + }, + "watchTimes": { + "description": "真实观看次数", + "type": "integer" + }, + "weekLikes": { + "description": "近7日收藏量", + "type": "integer" + }, + "weekWatchs": { + "description": "本周观看次数", + "type": "integer" + }, + "yearLikes": { + "description": "近365日收藏量", + "type": "integer" + }, + "yearWatchs": { + "description": "本年观看次数", + "type": "integer" + } + } + }, + "pubdef.InformationStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "InformationStatusVerifyCancel": "下架", + "InformationStatusVerifyOK": "上架", + "InformationStatusWaiting": "初始化" + }, + "x-enum-varnames": [ + "InformationStatusWaiting", + "InformationStatusVerifyOK", + "InformationStatusVerifyCancel" + ] + }, + "pubdef.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeMobile": "手机登陆", + "LoginTypeQrCode": "二维码登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeMobile", + "LoginTypeQrCode", + "LoginTypeAccount" + ] + }, + "pubdef.Page": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "pubdef.SeoValue": { + "type": "object", + "properties": { + "description": { + "description": "网站SEO描述", + "type": "string" + }, + "keyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "网站SEO标题", + "type": "string" + } + } + }, + "pubdef.TranslationType": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "TranslationTypeBottom": "底部导航", + "TranslationTypeSub": "二级导航", + "TranslationTypeUrl": "URL模式" + }, + "x-enum-varnames": [ + "TranslationTypeUrl", + "TranslationTypeSub", + "TranslationTypeBottom" + ] + }, + "pubdef.UserStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "UserStatusDisable": "封禁", + "UserStatusNormal": "正常", + "UserStatusSlient": "禁言" + }, + "x-enum-varnames": [ + "UserStatusNormal", + "UserStatusDisable", + "UserStatusSlient" + ] + }, + "questionnairemod.Attachment": { + "type": "object", + "properties": { + "fileType": { + "description": "附件类型(img / video)", + "type": "string" + }, + "url": { + "description": "附件URL地址", + "type": "string" + } + } + }, + "questionnairemod.QuestionnaireModel": { + "type": "object", + "properties": { + "attachments": { + "description": "附件列表,最多3个", + "type": "array", + "items": { + "$ref": "#/definitions/questionnairemod.Attachment" + } + }, + "city": { + "description": "所在城市(选填)", + "type": "string" + }, + "contact": { + "description": "联系方式(选填)", + "type": "string" + }, + "createdAt": { + "description": "创建时间(自动生成)", + "type": "string" + }, + "description": { + "description": "问题描述(必填)", + "type": "string" + }, + "deviceModel": { + "description": "设备型号(选填)", + "type": "string" + }, + "deviceType": { + "description": "设备类型:iPhone、Android、iPad、其他", + "type": "string" + }, + "frequency": { + "description": "出现频率:每次都会、经常出现、偶尔出现、仅现一次", + "type": "string" + }, + "id": { + "description": "唯一标识(MongoDB自动生成)", + "type": "string" + }, + "networkType": { + "description": "网络类型:WI-FI、5G、4G、其他", + "type": "string" + }, + "operator": { + "description": "运营商:电信、联通、移动、广电", + "type": "string" + }, + "province": { + "description": "所在省份(必填)", + "type": "string" + }, + "questionType": { + "description": "问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题", + "type": "string" + }, + "remark": { + "description": "备注,处理问卷时填写(选填)", + "type": "string" + }, + "status": { + "description": "处理状态:待处理、已处理、拒绝", + "type": "string" + }, + "updatedAt": { + "description": "更新时间(自动更新)", + "type": "string" + }, + "userId": { + "description": "App端登录用户ID,0表示匿名", + "type": "integer" + } + } + }, + "richtextcontentmod.RichTextContentModel": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "id": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序值", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题(内部)", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "srv_acc.OpLogInfo": { + "type": "object", + "properties": { + "actId": { + "description": "操作人Id", + "type": "integer" + }, + "actName": { + "description": "操作人", + "type": "string" + }, + "action": { + "description": "操作目的", + "type": "string" + }, + "createdAt": { + "description": "操作时间", + "type": "string" + }, + "param": { + "description": "操作内容", + "type": "string" + }, + "path": { + "description": "请求接口", + "type": "string" + } + } + }, + "srv_actress.ActressAddReq": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "office": { + "description": "事务所", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或别名自动生成", + "type": "string" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + } + } + }, + "srv_actress.ActressDelReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_actress.ActressListReq": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "sortType": { + "description": "排序方式 0=创建时间 1=评分 2=排序值 3=作品数 4=总观看次数 5=热度值", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + } + } + }, + "srv_actress.ActressListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/actress.Actress" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_actress.ActressUpdateParam": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "office": { + "description": "事务所", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "srv_actress.ActressUpdateReq": { + "type": "object", + "required": [ + "id", + "param" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/srv_actress.ActressUpdateParam" + } + } + }, + "srv_commend.CommendReplay": { + "type": "object", + "properties": { + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "parentsId": { + "description": "评论ID", + "type": "integer" + }, + "score": { + "description": "评分", + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "srv_commend.CommendReviewReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "reviewStatus": { + "type": "integer" + } + } + }, + "srv_commend.CommendSearch": { + "type": "object", + "properties": { + "level": { + "description": "评论级别", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 0=视频 4=短剧", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CommentObjectType" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "reviewStatus": { + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "srv_commend.CommendSearchResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_commend.PostCommentInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_commend.PostCommentInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "评论ID", + "type": "integer" + }, + "ip": { + "description": "评论IP", + "type": "string" + }, + "level": { + "description": "评论级别", + "type": "integer" + }, + "likes": { + "description": "点赞数", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectName": { + "description": "对象类型名称 视频/短剧", + "type": "string" + }, + "objectType": { + "description": "对象类型 0=视频 4=短剧", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CommentObjectType" + } + ] + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "replyUserName": { + "description": "回复名称", + "type": "string" + }, + "reviewStatus": { + "description": "审核状态 0 未审核 1审核成功 2 审核失败", + "type": "integer" + }, + "status": { + "description": "评论用户的状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "topRank": { + "description": "置顶排序", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "srv_contactus.DelOneContactusReq": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "srv_contactus.GetContactusListReq": { + "type": "object", + "properties": { + "categoryName": { + "description": "分类", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "ip": { + "description": "ip", + "type": "string" + }, + "name": { + "description": "姓名", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_contactus.GetContactusListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/contactusmod.ContactUsModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_contactus.UpdateContactusReq": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, + "srv_detailnav.AddDetailNavReq": { + "type": "object", + "required": [ + "name", + "url" + ], + "properties": { + "name": { + "description": "导航名称", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "url": { + "description": "导航链接", + "type": "string" + } + } + }, + "srv_detailnav.DelDetailNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "srv_detailnav.GetDetailNavListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_detailnav.GetDetailNavListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/detailnavmod.DetailNavModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_detailnav.UpdateDetailNavParam": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "boolean" + }, + "url": { + "type": "string" + } + } + }, + "srv_detailnav.UpdateDetailNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "param": { + "$ref": "#/definitions/srv_detailnav.UpdateDetailNavParam" + } + } + }, + "srv_hotnav.AddHotNavReq": { + "type": "object", + "required": [ + "title", + "url" + ], + "properties": { + "icon": { + "description": "图标", + "type": "string" + }, + "intro": { + "description": "介绍", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + } + } + }, + "srv_hotnav.DelHotNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_hotnav.GetHotNavListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_hotnav.GetHotNavListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/hotnavmod.HotNav" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_hotnav.UpdateHotNavParam": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "intro": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "srv_hotnav.UpdateHotNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/srv_hotnav.UpdateHotNavParam" + } + } + }, + "srv_ipblack.AddIPBlackListReq": { + "type": "object", + "properties": { + "desc": { + "description": "备注", + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "srv_ipblack.DelIpBlackListReq": { + "type": "object", + "properties": { + "ip": { + "type": "string" + } + } + }, + "srv_ipblack.GetIPBlackListReq": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_ipblack.GetIPBlackListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/ipblackmod.IPBlackModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_questionnaire.HandleReq": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "description": "问卷ID(ObjectID 字符串)", + "type": "string" + }, + "remark": { + "description": "备注(选填)", + "type": "string" + }, + "status": { + "description": "目标状态:processed 已处理 / reject 拒绝", + "type": "string" + } + } + }, + "srv_questionnaire.ListReq": { + "type": "object", + "properties": { + "endTime": { + "description": "创建时间范围-结束,Unix 时间戳(秒,选填)", + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "questionType": { + "description": "模糊匹配:问题类型(选填)", + "type": "string" + }, + "startTime": { + "description": "创建时间范围-开始,Unix 时间戳(秒,选填)", + "type": "integer" + }, + "status": { + "description": "精确匹配:处理状态(选填)", + "type": "string" + } + } + }, + "srv_questionnaire.ListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据", + "type": "array", + "items": { + "$ref": "#/definitions/questionnairemod.QuestionnaireModel" + } + }, + "total": { + "description": "满足条件的总条数", + "type": "integer" + } + } + }, + "srv_richtextcontent.DelRichTextContentReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_richtextcontent.GetRichTextContentListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "srv_richtextcontent.GetRichTextContentResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/richtextcontentmod.RichTextContentModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_richtextcontent.UpdateRichTextContentData": { + "type": "object", + "properties": { + "_id": { + "type": "integer" + }, + "content": { + "description": "内容", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "location": { + "description": "位置", + "type": "array", + "items": { + "type": "string" + } + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序值", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_richtextcontent.UpdateRichTextContentReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_richtextcontent.UpdateRichTextContentData" + } + ] + } + } + }, + "srv_translation.TranslationInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_translation.TranslationInfoReq": { + "type": "object", + "properties": { + "id": { + "description": "导航ID", + "type": "integer" + }, + "name": { + "description": "导航名称", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "transCategoryId": { + "description": "导航分类ID", + "type": "integer" + } + } + }, + "srv_translation.TranslationInfoResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/translationmod.TranslationInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_translation.TranslationInfoUpdate": { + "type": "object", + "properties": { + "htmlId": { + "description": "富文本ID", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "link": { + "description": "外部跳转", + "type": "string" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "transCategoryId": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_translation.TranslationInfoUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_translation.TranslationInfoUpdate" + } + ] + } + } + }, + "srv_translationcategory.TranslationCategoryDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_translationcategory.TranslationCategoryReq": { + "type": "object", + "properties": { + "id": { + "description": "导航分类ID", + "type": "integer" + }, + "name": { + "description": "导航分类名称", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_translationcategory.TranslationCategoryResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/translationcategorymod.TranslationCategory" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_translationcategory.TranslationCategoryUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "categoryType": { + "description": "导航类型 1=url跳转 2=二级导航 3=底部导航", + "type": "integer" + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "type": "string" + }, + "urlValue": { + "description": "跳转URL", + "type": "string" + } + } + }, + "srv_translationcategory.TranslationCategoryUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryUpdate" + } + ] + } + } + }, + "srv_user.UserListReq": { + "type": "object", + "properties": { + "id": { + "description": "用户ID", + "type": "integer" + }, + "nickName": { + "description": "昵称(模糊)", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + } + } + }, + "srv_user.UserListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/usermod.User" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_user.UserUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/usermod.UserUpdate" + } + } + }, + "srv_videocategory.VideoCategoryDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryReq": { + "type": "object", + "properties": { + "displayPosition": { + "description": "显示位置 0=仅TAB 1=仅首页 2=两者都显示", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategoryDisplayPosition" + } + ] + }, + "id": { + "description": "分类 ID", + "type": "integer" + }, + "name": { + "description": "分类名称(模糊匹配)", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "sortType": { + "description": "排序方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategorySortType" + } + ] + } + } + }, + "srv_videocategory.VideoCategoryResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videocategorymod.VideoCategory" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryUpdate": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "displayMode": { + "description": "展示方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.DisplayMode" + } + ] + }, + "displayPosition": { + "description": "显示位置 0=仅TAB 1=仅首页 2=两者都显示", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategoryDisplayPosition" + } + ] + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sortType": { + "description": "排序方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategorySortType" + } + ] + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "tags": { + "description": "关联视频标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "translationCategoryId": { + "description": "导航分类", + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_videocollection.CollectionMediaInfo": { + "type": "object", + "properties": { + "coverImg": { + "description": "封面", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "nodes": { + "description": "节点", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.Node" + } + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + } + } + }, + "srv_videocollection.GetCollectionMediaListReq": { + "type": "object", + "properties": { + "collectionID": { + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_videocollection.GetCollectionMediaListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_videocollection.CollectionMediaInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocollection.UpdateCollectionMediaRankReq": { + "type": "object", + "properties": { + "collectionID": { + "description": "合集ID", + "type": "integer" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "videoId": { + "description": "视频ID", + "type": "integer" + } + } + }, + "srv_videocollection.VideoCollectionOptionMediaReq": { + "type": "object", + "properties": { + "collectionID": { + "description": "合集ID", + "type": "integer" + }, + "isAdd": { + "description": "增加还是移除", + "type": "boolean" + }, + "videoIds": { + "description": "视频ID", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "srv_videocreater.VideoCreaterDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videocreatermod.VideoCreaterInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "background": { + "description": "背景", + "type": "string" + }, + "createType": { + "description": "创作者类型 0=普通 1=官方", + "type": "integer" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "image": { + "description": "头像", + "type": "string" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "srv_videoinfo.AddVideoCollectionReq": { + "type": "object", + "properties": { + "coverImg": { + "description": "封面", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "firstTag": { + "description": "一级标签", + "type": "string" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "remark": { + "description": "备注", + "type": "string" + }, + "secondTags": { + "description": "二级标签标签", + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + } + } + }, + "srv_videoinfo.AddVideoReq": { + "type": "object", + "properties": { + "fileId": { + "description": "资源id", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "srv_videoinfo.SetFakeCountResp": { + "type": "object", + "properties": { + "matchedCount": { + "description": "匹配到的视频数量", + "type": "integer" + }, + "modifiedCount": { + "description": "实际修改的视频数量", + "type": "integer" + } + } + }, + "srv_videoinfo.SetFakeLikesReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,支持单个或批量", + "type": "array", + "items": { + "type": "integer" + } + }, + "likesMax": { + "description": "假点赞数随机最大值,包含该值", + "type": "integer" + }, + "likesMin": { + "description": "假点赞数随机最小值,包含该值", + "type": "integer" + } + } + }, + "srv_videoinfo.SetFakeWatchTimesReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,支持单个或批量", + "type": "array", + "items": { + "type": "integer" + } + }, + "watchTimesMax": { + "description": "假观看次数随机最大值,包含该值", + "type": "integer" + }, + "watchTimesMin": { + "description": "假观看次数随机最小值,包含该值", + "type": "integer" + } + } + }, + "srv_videoinfo.SetRecommendReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + } + } + }, + "srv_videoinfo.SyncMediaFileItem": { + "type": "object", + "properties": { + "bango": { + "type": "string" + }, + "coverImg": { + "type": "string" + }, + "fieldNameFs": { + "type": "string" + }, + "firstTag": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "keyworld": { + "type": "array", + "items": { + "type": "string" + } + }, + "publish": { + "type": "string" + }, + "secondTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "taskId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "videoTime": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } + }, + "srv_videoinfo.SyncMediaFileReq": { + "type": "object", + "properties": { + "fileId": { + "description": "媒资库影片 ID", + "type": "array", + "items": { + "type": "string" + } + }, + "firstTagMap": { + "description": "覆盖一级标签", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "minVideoSeconds": { + "description": "最短时长,0 表示不限制", + "type": "integer" + }, + "sourceVersion": { + "description": "v1 / v2", + "type": "string" + }, + "videoList": { + "description": "媒资库搜索结果,优先使用", + "type": "array", + "items": { + "$ref": "#/definitions/srv_videoinfo.SyncMediaFileItem" + } + } + } + }, + "srv_videoinfo.VideoAddTagsReq": { + "type": "object", + "required": [ + "tags", + "videoIds" + ], + "properties": { + "tags": { + "description": "统一标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "videoIds": { + "description": "视频ID集合", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "srv_videoinfo.VideoInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoReq": { + "type": "object", + "properties": { + "actressIds": { + "description": "女优ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "bango": { + "description": "番号,", + "type": "string" + }, + "categoryId": { + "description": "分类ID", + "type": "integer" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "endTime": { + "description": "创建时间-截止", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "keyworld": { + "description": "关键字", + "type": "string" + }, + "mediaType": { + "description": "资源类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "optionAccount": { + "description": "最后操作人", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "sort": { + "description": "排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数。按分类查询且排序方式为排序值时,使用该分类的 categoryRanks 排序", + "type": "integer" + }, + "startTime": { + "description": "创建时间-开始", + "type": "string" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "tag": { + "description": "标签", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoFileId": { + "description": "切片id(切片列表的ID)", + "type": "string" + }, + "videoTaskId": { + "description": "视频任务id", + "type": "string" + } + } + }, + "srv_videoinfo.VideoInfoResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.VideoInformation" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoUpdate": { + "type": "object", + "properties": { + "actressIds": { + "description": "女优ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "addedTime": { + "type": "string" + }, + "bango": { + "description": "番号,唯一", + "type": "string" + }, + "categoryId": { + "description": "分类ID", + "type": "integer" + }, + "cover": { + "description": "视频封面", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "firstTag": { + "description": "一级标签", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "indexNow": { + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "issuedAt": { + "description": "发行时间", + "type": "string" + }, + "issuedBrand": { + "description": "发行商", + "type": "string" + }, + "keyworld": { + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "description": "资源类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "nodes": { + "description": "节点", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.Node" + } + }, + "remark": { + "description": "备注", + "type": "string" + }, + "secondTags": { + "description": "二级标签标签", + "type": "array", + "items": { + "type": "string" + } + }, + "secondUpdatedAt": { + "description": "二次修改时间", + "type": "string" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "unencryptedImage": { + "description": "不加密封面", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videoinfo.VideoInfoUpdate" + } + ] + } + } + }, + "srv_videoinfo.VideoSetTagsReq": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "description": "视频ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagIds": { + "description": "标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "srv_videotag.HotTagItem": { + "type": "object", + "properties": { + "tagId": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "watchTimes": { + "type": "integer" + } + } + }, + "srv_videotag.HotTagResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_videotag.HotTagItem" + } + } + } + }, + "srv_videotag.IpStatReq": { + "type": "object", + "properties": { + "todayIP": { + "type": "integer" + }, + "yesterdayIP": { + "type": "integer" + } + } + }, + "srv_videotag.VideoTagInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videotag.VideoTagInfoReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "srv_videotag.VideoTagInfoResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videotagmod.VideoTagInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videotag.VideoTagInfoUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "images": { + "description": "背景", + "type": "string" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "translationcategorymod.TranslationCategory": { + "type": "object", + "properties": { + "categoryType": { + "description": "导航类型 1:url 2:二级导航 3:底部导航", + "allOf": [ + { + "$ref": "#/definitions/pubdef.TranslationType" + } + ] + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "urlValue": { + "description": "跳转URL", + "type": "string" + } + } + }, + "translationmod.TranslationInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "htmlId": { + "description": "富文本ID", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "link": { + "description": "外部跳转", + "type": "string" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "transCategoryId": { + "description": "导航分类", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "usermod.User": { + "type": "object", + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "age": { + "description": "年龄", + "type": "integer" + }, + "agentInviteCode": { + "description": "被邀请的inviteCode", + "type": "string" + }, + "appVer": { + "description": "app版本", + "type": "string" + }, + "auth": { + "description": "是否认证", + "type": "boolean" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "backgroud": { + "description": "背景图", + "type": "string" + }, + "consecutiveLogin": { + "description": "连续登陆天数", + "type": "integer" + }, + "consumptionTotal": { + "description": "金币总消费数量", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "currentVipExpire": { + "description": "当前vip过期时间", + "type": "string" + }, + "devID": { + "description": "设备唯一标示", + "type": "string" + }, + "devV2": { + "description": "设备唯一设备ID", + "type": "string" + }, + "device": { + "description": "设备", + "type": "string" + }, + "districtCode": { + "description": "商区码", + "type": "string" + }, + "gender": { + "description": "性别 0-未知,1-男,2-女", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "introduction": { + "description": "简介", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "invites": { + "description": "我邀请的人的个数(我邀请他人)", + "type": "integer" + }, + "isRegister": { + "description": "是否已注册", + "type": "boolean" + }, + "lastIp": { + "description": "上次登陆IP", + "type": "string" + }, + "lastLoginAt": { + "description": "上次登陆时间", + "type": "string" + }, + "loginType": { + "description": "登陆方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "mac": { + "description": "设备唯一mac地址", + "type": "string" + }, + "modifyUserNameAt": { + "description": "上一次修改用户时间", + "type": "string" + }, + "nickName": { + "description": "昵称", + "type": "string" + }, + "oriVer": { + "description": "注册时的app版本", + "type": "string" + }, + "password": { + "description": "登陆密码", + "type": "string" + }, + "recharge": { + "description": "当前充值余额,金币个数", + "type": "integer" + }, + "rechargeTotal": { + "description": "累计充值额,金币个数", + "type": "integer" + }, + "regIp": { + "description": "注册IP", + "type": "string" + }, + "region": { + "description": "地区", + "type": "string" + }, + "registerTime": { + "type": "string" + }, + "score": { + "description": "积分", + "type": "integer" + }, + "scoreInvites": { + "description": "邀请人获得的积分累计", + "type": "integer" + }, + "scoreLvName": { + "description": "积分等级名称", + "type": "string" + }, + "scoreTotal": { + "description": "累计积分", + "type": "integer" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "sysMsgAt": { + "description": "获取系统消息时间", + "type": "string" + }, + "sysType": { + "description": "系统类型 ios android", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "usermod.UserUpdate": { + "type": "object", + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "age": { + "description": "年龄", + "type": "integer" + }, + "agentInviteCode": { + "description": "被邀请的inviteCode", + "type": "string" + }, + "appVer": { + "description": "app版本", + "type": "string" + }, + "auth": { + "description": "是否认证", + "type": "boolean" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "backgroud": { + "description": "背景图", + "type": "string" + }, + "consecutiveLogin": { + "description": "连续登陆天数", + "type": "integer" + }, + "consumptionTotal": { + "description": "金币总消费数量", + "type": "integer" + }, + "currentVipExpire": { + "description": "当前vip过期时间", + "type": "string" + }, + "devID": { + "description": "设备唯一标示", + "type": "string" + }, + "devV2": { + "description": "设备唯一设备ID", + "type": "string" + }, + "device": { + "description": "设备", + "type": "string" + }, + "districtCode": { + "description": "商区码", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "gender": { + "description": "性别 0-未知,1-男,2-女", + "type": "integer" + }, + "introduction": { + "description": "简介", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "invites": { + "description": "我邀请的人的个数(我邀请他人)", + "type": "integer" + }, + "isRegister": { + "description": "是否已注册", + "type": "boolean" + }, + "lastIp": { + "description": "上次登陆IP", + "type": "string" + }, + "lastLoginAt": { + "description": "上次登陆时间", + "type": "string" + }, + "loginType": { + "description": "登陆方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "mac": { + "description": "设备唯一mac地址", + "type": "string" + }, + "merchantExpiredAt": { + "description": "商家/兼职有效期", + "type": "string" + }, + "modifyUserNameAt": { + "description": "上一次修改用户时间", + "type": "string" + }, + "nickName": { + "description": "昵称", + "type": "string" + }, + "oriVer": { + "description": "注册时的app版本", + "type": "string" + }, + "password": { + "description": "登陆密码", + "type": "string" + }, + "recharge": { + "description": "当前充值余额,金币个数", + "type": "integer" + }, + "rechargeTotal": { + "description": "累计充值额,金币个数", + "type": "integer" + }, + "regIp": { + "description": "注册IP", + "type": "string" + }, + "region": { + "description": "地区", + "type": "string" + }, + "registerTime": { + "type": "string" + }, + "score": { + "description": "积分", + "type": "integer" + }, + "scoreInvites": { + "description": "邀请人获得的积分累计", + "type": "integer" + }, + "scoreLvName": { + "description": "积分等级名称", + "type": "string" + }, + "scoreTotal": { + "description": "累计积分", + "type": "integer" + }, + "showCardExpiredAt": { + "description": "最新会员卡过期时间", + "type": "string" + }, + "showCardId": { + "description": "最新会员卡id", + "type": "integer" + }, + "showCardName": { + "description": "最新会员卡名称", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "sysMsgAt": { + "description": "获取系统消息时间", + "type": "string" + }, + "sysType": { + "description": "系统类型 ios android", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "videocategorymod.VideoCategory": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "displayMode": { + "description": "展示方式 0:默认 1:卡片层叠轮播 2:头像列表 3:图文列表 4:大封面列表", + "allOf": [ + { + "$ref": "#/definitions/pubdef.DisplayMode" + } + ] + }, + "displayPosition": { + "description": "显示位置 0=仅TAB 1=仅首页 2=两者都显示", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategoryDisplayPosition" + } + ] + }, + "id": { + "description": "Id", + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sortType": { + "description": "排序方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategorySortType" + } + ] + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "tags": { + "description": "关联视频标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "translationCategoryId": { + "description": "导航分类", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "videocreatermod.VideoCreaterInfo": { + "type": "object", + "properties": { + "background": { + "description": "背景", + "type": "string" + }, + "createType": { + "description": "创作者类型 0=普通 1=官方", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "description": "头像", + "type": "string" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "description": "帖子标题", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "videoinformationmod.HevcStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + -1 + ], + "x-enum-comments": { + "HevcStatusFailed": "转码失败", + "HevcStatusNone": "未提交转码", + "HevcStatusPending": "转码中", + "HevcStatusSuccess": "转码成功" + }, + "x-enum-varnames": [ + "HevcStatusNone", + "HevcStatusPending", + "HevcStatusSuccess", + "HevcStatusFailed" + ] + }, + "videoinformationmod.MediaType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "MediaType_AV": "AV视频", + "MediaType_Advitise": "广告", + "MediaType_Collection": "合集", + "MediaType_Video": "视频" + }, + "x-enum-varnames": [ + "MediaType_Video", + "MediaType_Collection", + "MediaType_Advitise", + "MediaType_AV" + ] + }, + "videoinformationmod.Node": { + "type": "object", + "properties": { + "images": { + "description": "图片", + "type": "array", + "items": { + "type": "string" + } + }, + "nodeId": { + "description": "节点ID", + "type": "string" + }, + "text": { + "description": "文本", + "type": "string" + }, + "title": { + "description": "节点标题", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + }, + "videos": { + "description": "视频信息列表", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + } + } + }, + "videoinformationmod.VideoInfo": { + "type": "object", + "properties": { + "cover": { + "description": "视频封面", + "type": "string" + }, + "fileId": { + "description": "媒资库ID", + "type": "string" + }, + "h265Url": { + "description": "H.265 m3u8 播放地址,空字符串表示暂无该编码源", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "H.264 m3u8 播放地址(始终存在)", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "videoinformationmod.VideoInformation": { + "type": "object", + "properties": { + "actressIds": { + "description": "女优ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "audioFile": { + "description": "音频文件路径", + "type": "string" + }, + "bango": { + "description": "番号,唯一", + "type": "string" + }, + "categoryId": { + "description": "分类ID", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "fake": { + "description": "伪造流量", + "allOf": [ + { + "$ref": "#/definitions/pubdef.FakeFlow" + } + ] + }, + "firstTag": { + "description": "一级标签", + "type": "string" + }, + "hevcFailCount": { + "description": "主视频转码累计失败次数", + "type": "integer" + }, + "hevcMetaInfo": { + "description": "H265 视频元信息", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoMetaInfo" + } + ] + }, + "hevcStatus": { + "description": "主视频 H265 转码状态", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.HevcStatus" + } + ] + }, + "id": { + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "issuedAt": { + "description": "发行时间", + "type": "string" + }, + "issuedBrand": { + "description": "发行商", + "type": "string" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "description": "资源类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "nodes": { + "description": "节点", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.Node" + } + }, + "optionAccount": { + "description": "最后操作人", + "type": "string" + }, + "pageUpdatedAt": { + "description": "页面展示更新时间", + "type": "string" + }, + "previewVtt": { + "description": "雪碧图 vtt", + "type": "string" + }, + "real": { + "description": "真实流量", + "allOf": [ + { + "$ref": "#/definitions/pubdef.Flow" + } + ] + }, + "realUpdateAt": { + "description": "播放相关数据更新时间", + "type": "string" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "secondTags": { + "description": "二级标签标签", + "type": "array", + "items": { + "type": "string" + } + }, + "secondUpdatedAt": { + "description": "二次修改时间", + "type": "string" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "smartCover": { + "description": "智能封面", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "syncId": { + "description": "同步ID", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "unencryptedImage": { + "description": "不加密封面", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "uploadUserName": { + "description": "上传用户名", + "type": "string" + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "videoCount": { + "description": "合集视频数量", + "type": "integer" + } + } + }, + "videoinformationmod.VideoMetaInfo": { + "type": "object", + "properties": { + "audioBitrate": { + "type": "integer" + }, + "audioChannels": { + "type": "integer" + }, + "audioCodec": { + "type": "string" + }, + "audioSampleRate": { + "type": "integer" + }, + "bitrate": { + "type": "integer" + }, + "codec": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "fps": { + "type": "number" + }, + "height": { + "type": "integer" + }, + "m3u8Url": { + "type": "string" + }, + "pixFmt": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "videoBitrate": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } + }, + "videotagmod.VideoTagInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "integer" + }, + "images": { + "description": "背景", + "type": "string" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "isShowHome": { + "description": "是否显示在首页", + "type": "boolean" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "watchTimes": { + "description": "预计算:该标签下所有视频的累计播放量", + "type": "integer" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "web_acc.AccOpLogReq": { + "type": "object", + "properties": { + "actId": { + "description": "操作人Id", + "type": "integer" + }, + "action": { + "description": "操作目的", + "type": "string" + }, + "endTime": { + "description": "开始时间", + "type": "string" + }, + "pageNum": { + "description": "页码", + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "path": { + "description": "请求接口", + "type": "string" + }, + "startTime": { + "description": "开始时间", + "type": "string" + } + } + }, + "web_acc.AccOpLogResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_acc.OpLogInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "web_acc.BindQurlReq": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + } + } + }, + "web_acc.BindQurlResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MainWebLoginReq": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + }, + "code": { + "description": "验证码", + "type": "string" + }, + "pwd": { + "description": "密码", + "type": "string" + } + } + }, + "web_acc.MainWebLoginResp": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + }, + "accId": { + "description": "账户id", + "type": "integer" + }, + "qurl": { + "description": "二维码", + "type": "array", + "items": { + "type": "integer" + } + }, + "roleId": { + "description": "角色id", + "type": "integer" + }, + "token": { + "description": "token", + "type": "string" + } + } + }, + "web_acc.MwAccAddReq": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色ID", + "type": "integer" + } + } + }, + "web_acc.MwAccAddResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwAccDelReq": { + "type": "object", + "properties": { + "id": { + "description": "账号Id", + "type": "integer" + } + } + }, + "web_acc.MwAccDelResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwAccGetInfoReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwAccGetInfoResp": { + "type": "object", + "properties": { + "acc": { + "description": "账号", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色Id", + "type": "integer" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccGetStatusReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwAccGetStatusResp": { + "type": "object", + "properties": { + "feedBack": { + "description": "禁用原因", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccListReq": { + "type": "object", + "properties": { + "keyWords": { + "description": "搜索条件", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态 1正常 2 禁用", + "type": "integer" + } + } + }, + "web_acc.MwAccListResp": { + "type": "object", + "properties": { + "accRec": { + "description": "当前页配置记录", + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.MwAccRec" + } + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "web_acc.MwAccRec": { + "type": "object", + "properties": { + "acc": { + "description": "账号", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "integer" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "feedBack": { + "description": "禁用原因", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isBase32": { + "description": "// 二步签名\nBase32 string ` + "`" + `json:\"base32\"` + "`" + `\n是否已签名", + "type": "boolean" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色Id", + "type": "integer" + }, + "roleName": { + "description": "角色名", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "integer" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccSaveStatusReq": { + "type": "object", + "properties": { + "feedBack": { + "description": "禁用原因", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccSaveStatusResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwAccUpPwdReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "账号Id", + "type": "integer" + }, + "pwd": { + "description": "密码", + "type": "string" + } + } + }, + "web_acc.MwAccUpReq": { + "type": "object", + "properties": { + "acc": { + "description": "账号", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色Id", + "type": "integer" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccUpResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleAddReq": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "isEnb": { + "description": "状态", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + } + } + }, + "web_acc.MwRoleAddResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleDelReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwRoleDelResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleDrop": { + "type": "object", + "properties": { + "id": { + "description": "角色Id", + "type": "integer" + }, + "name": { + "description": "角色名称", + "type": "string" + } + } + }, + "web_acc.MwRoleDropResp": { + "type": "object", + "properties": { + "roleDrop": { + "description": "是否成功 true:成功 false:未成功", + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.MwRoleDrop" + } + } + } + }, + "web_acc.MwRoleEnableReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + }, + "isEnb": { + "description": "状态", + "type": "boolean" + } + } + }, + "web_acc.MwRoleEnableResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleGetAuthReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwRoleGetAuthResp": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + }, + "router": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router" + } + } + } + }, + "web_acc.MwRoleGetInfoReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwRoleGetInfoResp": { + "type": "object", + "properties": { + "roleRec": { + "$ref": "#/definitions/web_acc.MwRoleRec" + } + } + }, + "web_acc.MwRoleListResp": { + "type": "object", + "properties": { + "roleRecs": { + "description": "当前页配置记录", + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.MwRoleRec" + } + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "web_acc.MwRoleRec": { + "type": "object", + "properties": { + "apiMenus": { + "description": "已有权限的api", + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "角色Id", + "type": "integer" + }, + "isEnb": { + "description": "是否启用 false:禁用 true:启用", + "type": "boolean" + }, + "name": { + "description": "角色名称", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "web_acc.MwRoleUpAuthReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + }, + "router": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router" + } + } + } + }, + "web_acc.MwRoleUpAuthResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleUpReq": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "角色id", + "type": "integer" + }, + "isEnb": { + "description": "状态", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + } + } + }, + "web_acc.MwRoleUpResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.Router": { + "type": "object", + "properties": { + "apiUrl": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router_ApiUrl" + } + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router" + } + }, + "component": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "isShow": { + "type": "boolean" + }, + "meta": { + "$ref": "#/definitions/web_acc.Router_Meta" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "redirect": { + "type": "string" + } + } + }, + "web_acc.Router_ApiUrl": { + "type": "object", + "properties": { + "desc": { + "type": "string" + }, + "method": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "boolean" + }, + "url": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "web_acc.Router_Meta": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "noCache": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "web_cfg.GetSysConfigReq": { + "type": "object" + }, + "web_cfg.GetSysConfigResp": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "dhDescription": { + "description": "导航SEO描述", + "type": "string" + }, + "dhKeyworld": { + "description": "导航SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "dhNewUrlAddr1": { + "description": "导航页-线路1", + "type": "string" + }, + "dhNewUrlAddr2": { + "description": "导航页-线路2", + "type": "string" + }, + "dhNewUrlAddr3": { + "description": "导航页-线路3", + "type": "string" + }, + "dhSiteName": { + "description": "导航SEO网站名称", + "type": "string" + }, + "dhTitle": { + "description": "导航SEO标题", + "type": "string" + }, + "downloadVideoUrl": { + "description": "下载视频URL", + "type": "string" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "freeVPN": { + "description": "免费vpn链接", + "type": "string" + }, + "gitAddr": { + "description": "git地址", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "id": { + "type": "integer" + }, + "imgAppCdn": { + "description": "图片CDN(app使用)", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgWebCdn": { + "description": "图片CDN(web使用)", + "type": "string" + }, + "m3u8DayRateLimitMax": { + "description": "每 IP 每天最多不同正片数,\u003c=0 使用默认 80", + "type": "integer" + }, + "mediaAppCdn": { + "description": "视频CDN(app使用)", + "type": "string" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "mediaWebCdn": { + "description": "视频CDN(web使用)", + "type": "string" + }, + "mp4Addr": { + "description": "mp4地址(web使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "网站管理", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "potaoGroup": { + "description": "土豆群", + "type": "string" + }, + "qq": { + "description": "qq群", + "type": "string" + }, + "sitemapDefaultImages": { + "description": "Sitemap里面默认封面图", + "type": "array", + "items": { + "type": "string" + } + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "telegramAdd": { + "description": "加入我们tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + }, + "unencryptedCdn": { + "description": "不加密资源使用的cdn", + "type": "string" + }, + "useAddress": { + "description": "所有使用中的域名", + "type": "array", + "items": { + "type": "string" + } + }, + "version": { + "description": "版本号,用于更新乐观锁", + "type": "integer" + }, + "watermarkImage": { + "description": "图片水印文件", + "type": "string" + }, + "watermarkMedia": { + "description": "视频水印文件(底部右边)", + "type": "string" + }, + "watermarkMediaBottomLeft": { + "description": "视频水印文件(底部左边)", + "type": "string" + }, + "watermarkMediaTop": { + "description": "视频水印文件(顶部左边)", + "type": "string" + }, + "watermarkMediaTopRight": { + "description": "视频水印文件(顶部右边)", + "type": "string" + }, + "webAddr": { + "description": "网站SEO网站地址", + "type": "string" + }, + "webDescription": { + "description": "网站SEO描述", + "type": "string" + }, + "webKeyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "webSiteName": { + "description": "网站SEO网站名称", + "type": "string" + }, + "webTitle": { + "description": "SEO", + "type": "string" + }, + "weiChart": { + "description": "微信群", + "type": "string" + } + } + }, + "web_cfg.SysConfigUpdateReq": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "dhDescription": { + "description": "导航SEO描述", + "type": "string" + }, + "dhKeyworld": { + "description": "导航SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "dhNewUrlAddr1": { + "description": "导航页-线路一", + "type": "string" + }, + "dhNewUrlAddr2": { + "description": "导航页-线路2", + "type": "string" + }, + "dhNewUrlAddr3": { + "description": "导航页-线路3", + "type": "string" + }, + "dhSiteName": { + "description": "导航SEO网站名称", + "type": "string" + }, + "dhTitle": { + "description": "导航SEO标题", + "type": "string" + }, + "downloadVideoUrl": { + "description": "下载视频URL", + "type": "string" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "gitAddr": { + "description": "git地址", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "imgAppCdn": { + "description": "图片CDN(app使用)", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgWebCdn": { + "description": "图片CDN(web使用)", + "type": "string" + }, + "m3u8DayRateLimitMax": { + "description": "每 IP 每天最多不同正片数,\u003c=0 使用默认 80", + "type": "integer" + }, + "mediaAppCdn": { + "description": "视频CDN(app使用)", + "type": "string" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "mediaWebCdn": { + "description": "视频CDN(web使用)", + "type": "string" + }, + "mp4Addr": { + "description": "mp4地址(web使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "最新地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "potaoGroup": { + "description": "土豆群", + "type": "string" + }, + "qq": { + "description": "qq群", + "type": "string" + }, + "sitemapDefaultImages": { + "description": "Sitemap里面默认封面图", + "type": "array", + "items": { + "type": "string" + } + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "telegramAdd": { + "description": "加入我们tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + }, + "unencryptedCdn": { + "description": "不加密资源使用的cdn", + "type": "string" + }, + "watermarkImage": { + "description": "图片水印文件", + "type": "string" + }, + "watermarkMedia": { + "description": "视频水印文件(底部右边)", + "type": "string" + }, + "watermarkMediaBottomLeft": { + "description": "视频水印文件(底部左边)", + "type": "string" + }, + "watermarkMediaTop": { + "description": "视频水印文件(顶部左边)", + "type": "string" + }, + "watermarkMediaTopRight": { + "description": "视频水印文件(顶部右边)", + "type": "string" + }, + "webAddr": { + "description": "网站SEO网站地址", + "type": "string" + }, + "webDescription": { + "description": "网站SEO描述", + "type": "string" + }, + "webKeyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "webSiteName": { + "description": "网站SEO网站名称", + "type": "string" + }, + "webTitle": { + "description": "SEO", + "type": "string" + }, + "weiChart": { + "description": "微信群", + "type": "string" + } + } + }, + "web_cfg.SysConfigUpdateResp": { + "type": "object" + }, + "web_commend.CommendDelIpReq": { + "type": "object", + "properties": { + "ip": { + "type": "string" + } + } + }, + "web_commend.CommendDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "web_commend.CommendReviewBatchReq": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "reviewStatus": { + "type": "integer" + } + } + }, + "web_file.UploadImageResp": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + }, + "web_file.UploadImagesResp": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "BasicAuth": { + "type": "basic" + }, + "OAuth2AccessCode": { + "type": "oauth2", + "flow": "accessCode", + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information" + } + }, + "OAuth2Application": { + "type": "oauth2", + "flow": "application", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Implicit": { + "type": "oauth2", + "flow": "implicit", + "authorizationUrl": "https://example.com/oauth/authorize", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Password": { + "type": "oauth2", + "flow": "password", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "read": "Grants read access", + "write": "Grants write access" + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:12667", + BasePath: "/", + Schemes: []string{"http", "https"}, + Title: "站群后台接口", + Description: "这是站群后台接口服务.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/web/docs/swagger.json b/web/docs/swagger.json new file mode 100644 index 0000000..cc334bf --- /dev/null +++ b/web/docs/swagger.json @@ -0,0 +1,13376 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "description": "这是站群后台接口服务.", + "title": "站群后台接口", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "host": "localhost:12667", + "basePath": "/", + "paths": { + "/api/web/acc/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-添加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-添加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccAddResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccDelResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/getinfo": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-获取信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-获取信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/getstatus": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-获取状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-获取状态", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetStatusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccGetStatusResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/ops": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-获取操作记录", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-获取操作记录", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.AccOpLogReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.AccOpLogResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/savestatus": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-保存状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-保存状态", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccSaveStatusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccSaveStatusResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/up": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/acc/uppwd": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "账号-更新密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "账号" + ], + "summary": "账号-更新密码", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpPwdReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwAccUpPwdReq" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "添加一个女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "删除一个女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "分组分页获取女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_actress.ActressListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/actress/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个女优信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "女优管理" + ], + "summary": "更新一个女优信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_actress.ActressUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/del": { + "post": { + "description": "删除一个联系我们", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们" + ], + "summary": "删除一个联系我们", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_contactus.DelOneContactusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/list": { + "post": { + "description": "联系我们列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们" + ], + "summary": "联系我们列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_contactus.GetContactusListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_contactus.GetContactusListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改联系我们状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们" + ], + "summary": "修改联系我们状态", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_contactus.UpdateContactusReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "新增统一标签 -- by 王鸣", + "parameters": [ + { + "description": "新增统一标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContentTagAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContentTagAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "删除统一标签 -- by 王鸣", + "parameters": [ + { + "description": "删除统一标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContentTagDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询统一标签配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "统一标签列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "标签ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "标签名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "状态 true-启用 false-停用", + "name": "status", + "in": "query" + }, + { + "enum": [ + 1, + 5 + ], + "type": "integer", + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ], + "description": "内容类型 1-视频 5-短剧", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContentTagListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/option": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "查询启用的统一标签下拉列表,传type时按支持类型过滤", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "统一标签下拉列表 -- by 王鸣", + "parameters": [ + { + "enum": [ + 1, + 5 + ], + "type": "integer", + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ], + "description": "内容类型 1-视频 5-短剧,不传返回全部启用标签", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContentTagOptionResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/content/tag/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web统一标签相关接口" + ], + "summary": "更新统一标签 -- by 王鸣", + "parameters": [ + { + "description": "更新统一标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContentTagUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-增加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-增加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.AddDetailNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.DelDetailNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.GetDetailNavListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_detailnav.GetDetailNavListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/detailnav/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "详情页导航配置-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "详情页导航配置" + ], + "summary": "详情页导航配置-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_detailnav.UpdateDetailNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/file/uploadImg": { + "post": { + "description": "图片上传", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "文件上传" + ], + "summary": "图片上传", + "parameters": [ + { + "type": "file", + "description": "文件", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_file.UploadImageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/file/uploadImgs": { + "post": { + "description": "图片批量上传", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "文件上传" + ], + "summary": "图片批量上传", + "parameters": [ + { + "type": "file", + "description": "文件", + "name": "file[]", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_file.UploadImagesResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "新增首页banner -- by 王鸣", + "parameters": [ + { + "description": "新增首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.HomeBannerAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeBannerAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "删除首页banner -- by 王鸣", + "parameters": [ + { + "description": "删除首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.HomeBannerDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询首页banner配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "首页banner列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "首页 banner ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认10,最大100", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "状态 true-启用 false-停用", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeBannerListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/banner/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web首页banner相关接口" + ], + "summary": "更新首页banner -- by 王鸣", + "parameters": [ + { + "description": "更新首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.HomeBannerUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-增加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-增加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.AddHotNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.DelHotNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.GetHotNavListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_hotnav.GetHotNavListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/hotnav/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "热门导航配置-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "热门导航配置" + ], + "summary": "热门导航配置-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_hotnav.UpdateHotNavReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/ipblack/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "IP黑名单-增加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "IP黑名单" + ], + "summary": "IP黑名单-增加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_ipblack.AddIPBlackListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/ipblack/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "IP黑名单-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "IP黑名单" + ], + "summary": "IP黑名单-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_ipblack.DelIpBlackListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/ipblack/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "IP黑名单-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "IP黑名单" + ], + "summary": "IP黑名单-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_ipblack.GetIPBlackListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_ipblack.GetIPBlackListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/login/acc": { + "post": { + "description": "登录-登录", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "登录" + ], + "summary": "登录-登录", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MainWebLoginReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MainWebLoginResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/login/bind": { + "post": { + "description": "登录-绑定二维码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "登录" + ], + "summary": "登录-绑定二维码", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.BindQurlReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.BindQurlResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/login/slogin": { + "post": { + "description": "管理员sso登陆", + "consumes": [ + "multipart/form-data", + "application/json" + ], + "produces": [ + "application/json", + "text/html" + ], + "tags": [ + "web-单点登陆" + ], + "summary": "管理员sso登陆", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "name", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "密码", + "name": "password", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"msg\": \"管理员信息\"\"}", + "schema": { + "type": "string" + } + }, + "400": { + "description": "{\"msg\": \"操作失败\"}", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "新增 playlet 集合短剧数据,支持分类、题材、标签、支付方式、推荐和 banner 配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧 -- by 王鸣", + "parameters": [ + { + "description": "新增短剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/banner/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 showOnBanner 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧是否显示在首页banner -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧是否显示在首页banner请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletBannerSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "新增短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端删除短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "删除短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧分类列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "分类ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "分类名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "启用状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 categoryIds 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategorySetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/category/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新短剧分类 -- by 王鸣", + "parameters": [ + { + "description": "更新短剧分类请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCategoryUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "新增 playlet_chapter 集合短剧剧集数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧剧集 -- by 王鸣", + "parameters": [ + { + "description": "新增短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据剧集ID集合删除 playlet_chapter 记录,仅原创短剧支持删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量删除短剧剧集", + "parameters": [ + { + "description": "单/批量删除短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询 playlet_chapter 集合短剧剧集数据,默认按集数升序返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧剧集列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "剧集ID", + "name": "id", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "短剧ID", + "name": "playletId", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "x-enum-comments": { + "WatermarkStatusDone": "已完成", + "WatermarkStatusFailed": "处理失败", + "WatermarkStatusPending": "未处理(待 mediafile/sync)", + "WatermarkStatusProcessing": "处理中(已提交加水印任务,待回写)", + "WatermarkStatusRegistered": "已登记(原片已登记,待 task/add)" + }, + "x-enum-varnames": [ + "WatermarkStatusPending", + "WatermarkStatusRegistered", + "WatermarkStatusProcessing", + "WatermarkStatusDone", + "WatermarkStatusFailed" + ], + "description": "水印状态 0-未处理 1-已登记 2-处理中 3-已完成 4-处理失败", + "name": "watermarkStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/pay-type/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据剧集ID集合批量更新 playlet_chapter 集合 payType 和 singleCoins 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧剧集支付方式 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧剧集支付方式请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterPayTypeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合从媒资中心同步新增剧集,playletIds传1个表示单条同步,传多个表示批量同步,允许已完结短剧补集或修正状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端手动单/批量同步短剧剧集", + "parameters": [ + { + "description": "手动单/批量同步短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterSyncResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/chapter/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按剧集ID编辑 playlet_chapter 集合短剧剧集数据,只更新请求中传入的字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端编辑短剧剧集 -- by 王鸣", + "parameters": [ + { + "description": "编辑短剧剧集请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/featured/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 isFeatured 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧是否爆剧 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧是否爆剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletFeaturedSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/like/virtual/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 virtualCountLike 字段,每条短剧从最小值和最大值之间随机取不重复数值入库", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧虚拟点赞数 -- by 王鸣", + "parameters": [ + { + "description": "单/批量随机设置短剧虚拟点赞数请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletVirtualLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "分类ID", + "name": "categoryId", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "x-enum-comments": { + "ContentTypeAlbum": "普通短剧/影集(对应 media_type=6)", + "ContentTypeAnime": "动漫(对应 media_type=1)", + "ContentTypeComic": "漫画短剧(对应 media_type=4)", + "ContentTypeManga": "漫画(对应 media_type=2)", + "ContentTypeTheater": "短剧(对应 media_type=5)" + }, + "x-enum-varnames": [ + "ContentTypeTheater", + "ContentTypeAlbum", + "ContentTypeComic", + "ContentTypeAnime", + "ContentTypeManga" + ], + "description": "内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画", + "name": "contentType", + "in": "query" + }, + { + "type": "integer", + "description": "短剧ID", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "description": "是否爆剧,true-是、false-否", + "name": "isFeatured", + "in": "query" + }, + { + "type": "boolean", + "description": "是否原创:true只查原创,false只查非原创,不传查全部", + "name": "isOriginal", + "in": "query" + }, + { + "type": "boolean", + "description": "是否推荐,true-推荐、false-不推荐", + "name": "isRecommend", + "in": "query" + }, + { + "type": "string", + "description": "最后操作人名称,支持模糊查询", + "name": "operatorName", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ], + "description": "支付方式,0-免费、1-金币、2-VIP", + "name": "payType", + "in": "query" + }, + { + "type": "boolean", + "description": "是否显示在首页banner,true-显示、false-不显示", + "name": "showOnBanner", + "in": "query" + }, + { + "enum": [ + 0, + 1 + ], + "type": "integer", + "x-enum-comments": { + "StatusDown": "下架", + "StatusUp": "上架" + }, + "x-enum-varnames": [ + "StatusDown", + "StatusUp" + ], + "description": "上架状态,0-下架、1-上架", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID", + "name": "tagId", + "in": "query" + }, + { + "type": "integer", + "description": "题材ID", + "name": "themeId", + "in": "query" + }, + { + "type": "string", + "description": "短剧标题,支持模糊查询", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/pay-type/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 payType/singleCoins 字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧默认支付方式 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧默认支付方式请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletPayTypeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/recommend/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧是否推荐 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧是否推荐请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletRecommendSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/status/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 status 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧上下架 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧上下架请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletStatusSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增标签 -- by 王鸣", + "parameters": [ + { + "description": "新增标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletTagAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端删除标签 -- by 王鸣", + "parameters": [ + { + "description": "删除标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧标签", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端标签列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "string", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletTagListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 tagIds 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧标签 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/tag/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新标签 -- by 王鸣", + "parameters": [ + { + "description": "更新标签请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletTagUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增题材 -- by 王鸣", + "parameters": [ + { + "description": "新增题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端删除题材 -- by 王鸣", + "parameters": [ + { + "description": "删除题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧题材", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端题材列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "题材ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "题材名称,支持模糊查询", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "boolean", + "description": "上架状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧题材 -- by 王鸣", + "parameters": [ + { + "description": "单/批量设置短剧题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新题材 -- by 王鸣", + "parameters": [ + { + "description": "更新题材请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按短剧ID编辑 playlet 集合短剧数据,只更新请求中传入的字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端编辑短剧 -- by 王鸣", + "parameters": [ + { + "description": "编辑短剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/watermark/reset": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合重置剧集水印状态,保留已有水印地址以便换水印期间继续观看", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端按短剧ID批量重置剧集水印 -- by 王鸣", + "parameters": [ + { + "description": "批量重置短剧水印请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletWatermarkResetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/del": { + "post": { + "description": "删除一个评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "删除一个评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_commend.CommendDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/list": { + "post": { + "description": "评论列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "评论列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_commend.CommendSearch" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_commend.CommendSearchResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/replay": { + "post": { + "description": "回复评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "回复评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_commend.CommendReplay" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/review": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "审核评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "审核评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_commend.CommendReviewReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/reviewBatch": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "批量审核评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "批量审核评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_commend.CommendReviewBatchReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/post/comment/user/del": { + "post": { + "description": "删除一个用户所有评论", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论" + ], + "summary": "删除一个用户所有评论", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_commend.CommendDelIpReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/questionnaire/handle": { + "post": { + "description": "只有状态为\"待处理\"的问卷才允许更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "问卷调查" + ], + "summary": "处理问卷", + "parameters": [ + { + "description": "处理参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_questionnaire.HandleReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/questionnaire/list": { + "post": { + "description": "支持分页及条件筛选(question_type、status、时间范围)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "问卷调查" + ], + "summary": "查询问卷列表", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_questionnaire.ListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_questionnaire.ListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/resource/chapters": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按媒资ID查询剧集信息,复用入库时的 mediaSync 接口", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端短剧媒资剧集列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "媒资中心资源ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletResourceChapterListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/resource/import": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "提交媒资入库任务,后台从媒资中心同步短剧详情并写入短剧和章节集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端短剧媒资入库 -- by 王鸣", + "parameters": [ + { + "description": "短剧媒资入库请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletResourceUseReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/resource/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询短剧媒资列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端短剧媒资列表 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "媒体类型 1-动漫 2-漫画 4-漫画短剧 5-短剧 6-普通短剧", + "name": "media_type", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNumber", + "in": "query" + }, + { + "type": "integer", + "description": "个数", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "同步类型 1-全量同步 2-增量同步", + "name": "sync_type", + "in": "query" + }, + { + "type": "string", + "description": "标签", + "name": "tag", + "in": "query" + }, + { + "type": "string", + "description": "标题", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletResourceListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "添加一个富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/richtextcontentmod.RichTextContentModel" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "删除一个富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_richtextcontent.DelRichTextContentReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "分组分页获取富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_richtextcontent.GetRichTextContentListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_richtextcontent.GetRichTextContentResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/richtextcontent/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个富文本内容", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "富文本内容" + ], + "summary": "更新一个富文本内容", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_richtextcontent.UpdateRichTextContentReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-添加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-添加", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleAddResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-删除", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-删除", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleDelResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/drop": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-下拉列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-下拉列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleDropResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/enable": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-使能", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-使能", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleEnableReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleEnableResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/getauth": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-获取权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-获取权限", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetAuthReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetAuthResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/getinfo": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-获取信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-获取信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleGetInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pubdef.Page" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/up": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-更新", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/role/upauth": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "角色-更新权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "角色" + ], + "summary": "角色-更新权限", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpAuthReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_acc.MwRoleUpAuthResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/slug/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "为视频、分类、女优、作者、短剧、统一标签等空 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。type 为空处理全部,也可指定 video/videoCategory/actress/videoCreater/playlet/playletCategory/playletTheme/contentTag。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web slug相关接口" + ], + "summary": "同步历史空 slug", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/proto.SlugSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.SlugSyncResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/stat/analytics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "统计数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "获取统计数据" + ], + "summary": "统计数据", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/stat/daily/analytics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "统计数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "获取统计数据" + ], + "summary": "统计数据", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/syscfg/get": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "获取系统配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "系统配置" + ], + "summary": "获取系统配置", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_cfg.GetSysConfigReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_cfg.GetSysConfigResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/syscfg/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改系统配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "系统配置" + ], + "summary": "修改系统配置", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/web_cfg.SysConfigUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/web_cfg.SysConfigUpdateResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "添加一个导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/translationmod.TranslationInfo" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "删除一个导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "分组分页获取导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translation/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个导航跳转信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航跳转信息" + ], + "summary": "更新一个导航跳转信息", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translation.TranslationInfoUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "添加一个导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/translationcategorymod.TranslationCategory" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "删除一个导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "分组分页获取导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/translationcategory/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "导航分类" + ], + "summary": "更新一个导航分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/user/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "查询用户列表", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.UserListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_user.UserListResp" + } + } + } + } + }, + "/api/web/user/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "更新用户信息", + "parameters": [ + { + "description": "更新参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.UserUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "添加一个视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocategorymod.VideoCategory" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "删除一个视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "分组分页获取视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocategory/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个视频分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频分类" + ], + "summary": "更新一个视频分类", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocategory.VideoCategoryUpdate" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocollection/media/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "获取合集视频列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合集管理" + ], + "summary": "获取合集视频列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocollection.GetCollectionMediaListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videocollection.GetCollectionMediaListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocollection/media/option": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改合集里的视频列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合集管理" + ], + "summary": "修改合集里的视频列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocollection.VideoCollectionOptionMediaReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocollection/media/rank": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "修改合集里的视频排序", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "合集管理" + ], + "summary": "修改合集里的视频排序", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocollection.UpdateCollectionMediaRankReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "添加一个视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocreatermod.VideoCreaterInfo" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "删除一个视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "分组分页获取视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videocreater/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个视频作者", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频作者" + ], + "summary": "更新一个视频作者", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videocreater.VideoCreaterUpdate" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/addTags": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据视频ID集合批量追加统一标签,已存在的标签不会重复追加", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "批量追加视频标签 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoAddTagsReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/addcoll": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "新增一个视频合集", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "新增一个视频合集", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.AddVideoCollectionReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个视频管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "删除一个视频管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取视频管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "分组分页获取视频管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/recommend/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据视频ID集合批量更新 video_information 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频是否推荐 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetRecommendReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/setFakeLikes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据ids批量随机设置视频fake.likes,随机范围包含最小值和最大值", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频假点赞数 --by 旺鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeLikesReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeCountResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/setFakeWatchTimes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据ids批量随机设置视频fake.watchTimes,随机范围包含最小值和最大值", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频假观看次数 --by 旺鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeWatchTimesReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videoinfo.SetFakeCountResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "从切片列表同步多个视频", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "从切片列表同步多个视频", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.AddVideoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/syncMediaFile": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "从媒资库直同步到视频列表,后续切片同步时按 mediaSourceId 覆盖", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "从媒资库直同步到视频列表", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.SyncMediaFileReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/tag/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据视频ID集合批量覆盖 video_information 集合 tags 字段,传1个ID表示单条设置,传多个ID表示批量设置,tagIds传空数组表示清空标签", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "单/批量设置视频标签 -- by 王鸣", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoSetTagsReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videoinfo/transcode": { + "post": { + "tags": [ + "视频管理" + ], + "summary": "提交视频 H265 转码", + "responses": {} + } + }, + "/api/web/videoinfo/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个视频管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "更新一个视频管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videoinfo.VideoInfoUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "添加一个标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videotagmod.VideoTagInfo" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除一个标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "删除一个标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分组分页获取标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "分组分页获取标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotag/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "更新一个标签管理", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "更新一个标签管理", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_videotag.VideoTagInfoUpdate" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/wbapi/hjsq/hottag": { + "get": { + "description": "根据视频累计播放量聚合返回热门标签列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "查询播放量最多的标签", + "parameters": [ + { + "type": "integer", + "description": "返回条数,默认10,最大1000", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.HotTagResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/wbapi/hjsq/ipstat": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "IP统计" + ], + "summary": "查询今日和昨日去重访客IP数", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.IpStatReq" + } + } + } + } + } + }, + "definitions": { + "actress.Actress": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "office": { + "description": "事务所", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "real": { + "description": "真实流量", + "allOf": [ + { + "$ref": "#/definitions/pubdef.Flow" + } + ] + }, + "realUpdateAt": { + "description": "播放相关数据更新时间", + "type": "string" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "actress.Meas": { + "type": "object", + "properties": { + "blood": { + "description": "血型", + "type": "string" + }, + "bust": { + "description": "胸围 英寸", + "type": "integer" + }, + "cup": { + "description": "罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I", + "type": "integer" + }, + "dia": { + "description": "直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45)", + "type": "integer" + }, + "gender": { + "description": "性别 0:女 1:男 2:其他", + "type": "integer" + }, + "height": { + "description": "身高 厘米", + "type": "integer" + }, + "hip": { + "description": "臀围 英寸", + "type": "integer" + }, + "len": { + "description": "长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145)", + "type": "integer" + }, + "waist": { + "description": "腰围 英寸", + "type": "integer" + }, + "weight": { + "description": "体重 公斤", + "type": "integer" + } + } + }, + "contactusmod.ContactUsModel": { + "type": "object", + "properties": { + "categoryName": { + "description": "主旨分类", + "type": "string" + }, + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "email": { + "description": "邮件", + "type": "string" + }, + "id": { + "description": "ID", + "type": "string" + }, + "ip": { + "description": "评论IP", + "type": "string" + }, + "name": { + "description": "名字", + "type": "string" + }, + "status": { + "description": "状态 0=初始 1=已回复 2=不予处理 3=已完结", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "contenttagmod.ContentTagType": { + "type": "integer", + "enum": [ + 1, + 5 + ], + "x-enum-comments": { + "ContentTagTypePlaylet": "短剧", + "ContentTagTypeVideo": "视频" + }, + "x-enum-varnames": [ + "ContentTagTypeVideo", + "ContentTagTypePlaylet" + ] + }, + "detailnavmod.DetailNavModel": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "description": "导航名称", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "url": { + "description": "导航链接", + "type": "string" + } + } + }, + "hotnavmod.HotNav": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "intro": { + "description": "介绍", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + } + } + }, + "ipblackmod.IPBlackModel": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "备注", + "type": "string" + }, + "id": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "playletchaptermod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletchaptermod.WatermarkStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "WatermarkStatusDone": "已完成", + "WatermarkStatusFailed": "处理失败", + "WatermarkStatusPending": "未处理(待 mediafile/sync)", + "WatermarkStatusProcessing": "处理中(已提交加水印任务,待回写)", + "WatermarkStatusRegistered": "已登记(原片已登记,待 task/add)" + }, + "x-enum-varnames": [ + "WatermarkStatusPending", + "WatermarkStatusRegistered", + "WatermarkStatusProcessing", + "WatermarkStatusDone", + "WatermarkStatusFailed" + ] + }, + "playletmod.ContentType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "ContentTypeAlbum": "普通短剧/影集(对应 media_type=6)", + "ContentTypeAnime": "动漫(对应 media_type=1)", + "ContentTypeComic": "漫画短剧(对应 media_type=4)", + "ContentTypeManga": "漫画(对应 media_type=2)", + "ContentTypeTheater": "短剧(对应 media_type=5)" + }, + "x-enum-varnames": [ + "ContentTypeTheater", + "ContentTypeAlbum", + "ContentTypeComic", + "ContentTypeAnime", + "ContentTypeManga" + ] + }, + "playletmod.PayType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PayTypeCoin": "金币", + "PayTypeFree": "免费", + "PayTypeVip": "VIP" + }, + "x-enum-varnames": [ + "PayTypeFree", + "PayTypeCoin", + "PayTypeVip" + ] + }, + "playletmod.Status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "x-enum-comments": { + "StatusDown": "下架", + "StatusUp": "上架" + }, + "x-enum-varnames": [ + "StatusDown", + "StatusUp" + ] + }, + "proto.ContentTagAddReq": { + "type": "object", + "required": [ + "name", + "status", + "types" + ], + "properties": { + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + } + } + }, + "proto.ContentTagAddResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.ContentTagDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.ContentTagListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.ContentTagOptionItem": { + "type": "object", + "properties": { + "bindCount": { + "description": "当前查询类型的绑定数量,不传type时返回0", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "typeNames": { + "description": "支持类型名称", + "type": "array", + "items": { + "type": "string" + } + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + } + } + }, + "proto.ContentTagOptionResp": { + "type": "object", + "properties": { + "list": { + "description": "标签下拉列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContentTagOptionItem" + } + } + } + }, + "proto.ContentTagUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "types": { + "description": "支持类型 1-视频 5-短剧", + "type": "array", + "items": { + "$ref": "#/definitions/contenttagmod.ContentTagType" + } + } + } + }, + "proto.HomeBannerAddReq": { + "type": "object", + "required": [ + "image", + "name", + "status" + ], + "properties": { + "image": { + "description": "图片", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "url": { + "description": "URL", + "type": "string" + }, + "verticalImage": { + "description": "竖版图片", + "type": "string" + } + } + }, + "proto.HomeBannerAddResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeBannerDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeBannerListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.HomeBannerUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "image": { + "description": "图片", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态 true-启用 false-停用", + "type": "boolean" + }, + "url": { + "description": "URL", + "type": "string" + }, + "verticalImage": { + "description": "竖版图片", + "type": "string" + } + } + }, + "proto.PlayletAddReq": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "contentType": { + "description": "内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "cover": { + "description": "封面原图", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "isFeatured": { + "description": "是否爆剧,true-是、false-否", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐,true-推荐、false-不推荐", + "type": "boolean" + }, + "payType": { + "description": "支付方式,0-免费、1-金币、2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告", + "type": "string" + }, + "recommendRank": { + "description": "推荐排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或标题自动生成", + "type": "string" + }, + "status": { + "description": "上架状态,0-下架、1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topRank": { + "description": "置顶排序", + "type": "integer" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + } + } + }, + "proto.PlayletAddResp": { + "type": "object", + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletBannerSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "showOnBanner": { + "description": "是否显示在首页banner,true-显示、false-不显示", + "type": "boolean" + } + } + }, + "proto.PlayletCategoryAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "description": "分类图标", + "type": "string" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "description": "启用状态", + "type": "boolean" + } + } + }, + "proto.PlayletCategoryAddResp": { + "type": "object", + "properties": { + "id": { + "description": "分类ID", + "type": "integer" + } + } + }, + "proto.PlayletCategoryDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "分类ID", + "type": "integer" + } + } + }, + "proto.PlayletCategoryListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletCategorySetReq": { + "type": "object", + "properties": { + "categoryIds": { + "description": "分类ID集合,可设置多个分类,传空数组表示清空分类,不传表示参数错误", + "type": "array", + "items": { + "type": "integer" + } + }, + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletCategoryUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "icon": { + "description": "分类图标", + "type": "string" + }, + "id": { + "description": "分类ID", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "启用状态", + "type": "boolean" + } + } + }, + "proto.PlayletChapterAddReq": { + "type": "object", + "required": [ + "currentEpisode", + "playletId" + ], + "properties": { + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "视频高度", + "type": "integer" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaSourceId": { + "description": "media切片服原片ID,上传正片已创建原片时直接沿用", + "type": "string" + }, + "mediaTime": { + "description": "影片长度,单位秒", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "title": { + "description": "剧集标题", + "type": "string" + }, + "videoUrl": { + "description": "原片视频地址", + "type": "string" + }, + "width": { + "description": "视频宽度", + "type": "integer" + } + } + }, + "proto.PlayletChapterAddResp": { + "type": "object", + "properties": { + "id": { + "description": "剧集ID", + "type": "integer" + } + } + }, + "proto.PlayletChapterDelReq": { + "type": "object", + "properties": { + "ids": { + "description": "剧集ID集合,传1个表示单条删除,传多个表示批量删除", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletChapterListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletChapterPayTypeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "剧集ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时必填且需大于0", + "type": "integer" + } + } + }, + "proto.PlayletChapterSyncReq": { + "type": "object", + "required": [ + "playletIds" + ], + "properties": { + "playletIds": { + "description": "短剧ID集合,传1个表示单条同步,传多个表示批量同步", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletChapterSyncResp": { + "type": "object", + "properties": { + "failedCount": { + "description": "失败数量", + "type": "integer" + }, + "list": { + "description": "同步结果集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterSyncResult" + } + }, + "successCount": { + "description": "成功数量", + "type": "integer" + }, + "total": { + "description": "同步总数", + "type": "integer" + } + } + }, + "proto.PlayletChapterSyncResult": { + "type": "object", + "properties": { + "newChapterCount": { + "description": "本次新增剧集数量", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "reason": { + "description": "失败原因", + "type": "string" + }, + "success": { + "description": "是否同步成功", + "type": "boolean" + } + } + }, + "proto.PlayletChapterUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "视频高度", + "type": "integer" + }, + "id": { + "description": "剧集ID", + "type": "integer" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度,单位秒", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletchaptermod.PayType" + } + ] + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "title": { + "description": "剧集标题", + "type": "string" + }, + "videoUrl": { + "description": "原片视频地址", + "type": "string" + }, + "width": { + "description": "视频宽度", + "type": "integer" + } + } + }, + "proto.PlayletFeaturedSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isFeatured": { + "description": "是否爆剧,true-是、false-否", + "type": "boolean" + } + } + }, + "proto.PlayletListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletPayTypeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "payType": { + "description": "支付方式,0-免费、1-金币、2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时必填且需大于0", + "type": "integer" + } + } + }, + "proto.PlayletRecommendSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isRecommend": { + "description": "是否推荐,true-推荐、false-不推荐", + "type": "boolean" + } + } + }, + "proto.PlayletResourceChapterItem": { + "type": "object", + "properties": { + "cover": { + "description": "封面", + "type": "string" + }, + "createTime": { + "description": "创建时间", + "type": "integer" + }, + "episodeNumber": { + "description": "第几集", + "type": "integer" + }, + "hashId": { + "description": "资源hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "媒资内容ID", + "type": "integer" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "playTime": { + "description": "播放时长(秒)", + "type": "integer" + }, + "saleCoin": { + "description": "单集售价", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "updateTime": { + "description": "更新时间", + "type": "integer" + }, + "videoUrl": { + "description": "播放地址", + "type": "string" + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletResourceChapterListResp": { + "type": "object", + "properties": { + "list": { + "description": "剧集列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceChapterItem" + } + }, + "mediaId": { + "description": "媒资中心资源ID", + "type": "integer" + }, + "title": { + "description": "短剧标题", + "type": "string" + }, + "total": { + "description": "剧集数量", + "type": "integer" + } + } + }, + "proto.PlayletResourceListItem": { + "type": "object", + "properties": { + "anchor": { + "description": "作者", + "type": "string" + }, + "create_time": { + "description": "创建时间", + "type": "integer" + }, + "data_version": { + "description": "数据版本", + "type": "integer" + }, + "horizontal_cover": { + "description": "横版封面", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isAdd": { + "description": "是否已加入后台数据", + "type": "boolean" + }, + "media_type": { + "description": "媒体类型", + "type": "integer" + }, + "sale_coin": { + "description": "整部售价", + "type": "integer" + }, + "summary": { + "description": "简介", + "type": "string" + }, + "tags_text": { + "description": "标签文字", + "type": "string" + }, + "terminal_cdo": { + "description": "上传者", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "total_episode": { + "description": "总集数", + "type": "integer" + }, + "update_status": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "update_time": { + "description": "修改时间", + "type": "integer" + }, + "vertical_cover": { + "description": "竖版封面", + "type": "string" + }, + "vlogger_id": { + "type": "integer" + } + } + }, + "proto.PlayletResourceListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceListItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "proto.PlayletResourceUseReq": { + "type": "object", + "properties": { + "ids": { + "description": "媒资中心资源ID集合", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletStatusSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "description": "上架状态,0-下架、1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + } + } + }, + "proto.PlayletTagAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletTagAddResp": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletTagDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletTagListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletTagSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagIds": { + "description": "标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletTagUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "description": "题材图标", + "type": "string" + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddResp": { + "type": "object", + "properties": { + "id": { + "description": "题材ID", + "type": "integer" + } + } + }, + "proto.PlayletThemeDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "题材ID", + "type": "integer" + } + } + }, + "proto.PlayletThemeListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据" + }, + "pageNum": { + "description": "当前页码", + "type": "integer" + }, + "pageSize": { + "description": "每页数量", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.PlayletThemeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "description": "题材ID集合,可设置多个题材", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletThemeUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "icon": { + "description": "题材图标", + "type": "string" + }, + "id": { + "description": "题材ID", + "type": "integer" + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合,传空数组表示清空分类", + "type": "array", + "items": { + "type": "integer" + } + }, + "contentType": { + "description": "内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画", + "allOf": [ + { + "$ref": "#/definitions/playletmod.ContentType" + } + ] + }, + "cover": { + "description": "封面原图", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧,true-是、false-否", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐,true-推荐、false-不推荐", + "type": "boolean" + }, + "payType": { + "description": "支付方式,0-免费、1-金币、2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "preview": { + "description": "预告", + "type": "string" + }, + "recommendRank": { + "description": "推荐排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币,支付方式为1-金币时生效", + "type": "integer" + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "description": "上架状态,0-下架、1-上架", + "allOf": [ + { + "$ref": "#/definitions/playletmod.Status" + } + ] + }, + "tagIds": { + "description": "标签ID集合,传空数组表示清空标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topRank": { + "description": "置顶排序", + "type": "integer" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + } + } + }, + "proto.PlayletVirtualLikeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "maxVirtualCountLike": { + "description": "虚拟点赞数最大值,必须大于等于最小值", + "type": "integer" + }, + "minVirtualCountLike": { + "description": "虚拟点赞数最小值,必须大于等于0", + "type": "integer" + } + } + }, + "proto.PlayletWatermarkResetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条,传多个表示批量", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.SlugSyncReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多处理的空 slug 记录数,默认500,最大2000", + "type": "integer" + }, + "type": { + "description": "可选,指定类型;空则按顺序处理全部", + "type": "string" + } + } + }, + "proto.SlugSyncResp": { + "type": "object", + "properties": { + "actressUpdated": { + "description": "本次补齐的女优数", + "type": "integer" + }, + "contentTagUpdated": { + "description": "本次补齐的统一标签数", + "type": "integer" + }, + "hasMore": { + "description": "是否还有未处理的历史数据", + "type": "boolean" + }, + "playletCategoryUpdated": { + "description": "本次补齐的短剧分类数", + "type": "integer" + }, + "playletThemeUpdated": { + "description": "本次补齐的短剧主题数", + "type": "integer" + }, + "playletUpdated": { + "description": "本次补齐的短剧数", + "type": "integer" + }, + "totalUpdated": { + "description": "本次合计补齐数", + "type": "integer" + }, + "videoCategoryUpdated": { + "description": "本次补齐的视频分类数", + "type": "integer" + }, + "videoCreaterUpdated": { + "description": "本次补齐的作者数", + "type": "integer" + }, + "videoUpdated": { + "description": "本次补齐的视频数", + "type": "integer" + } + } + }, + "pubdef.CategoryDisplayPosition": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "CategoryDisplayPositionBoth": "TAB和首页都显示", + "CategoryDisplayPositionHomeOnly": "仅首页", + "CategoryDisplayPositionTabOnly": "仅TAB" + }, + "x-enum-varnames": [ + "CategoryDisplayPositionTabOnly", + "CategoryDisplayPositionHomeOnly", + "CategoryDisplayPositionBoth" + ] + }, + "pubdef.CategorySortType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "x-enum-comments": { + "CategorySortTypeAddedTimeAsc": "上架时间升序", + "CategorySortTypeAddedTimeDesc": "上架时间降序", + "CategorySortTypeLikeAsc": "点赞(收藏)次数升序", + "CategorySortTypeLikeDesc": "点赞(收藏)次数降序", + "CategorySortTypeSortType": "自定义排序", + "CategorySortTypeUpdateTimeAsc": "更新时间升序", + "CategorySortTypeUpdateTimeDesc": "更新时间降序", + "CategorySortTypeWatchAsc": "观看次数升序", + "CategorySortTypeWatchDesc": "观看次数降序" + }, + "x-enum-varnames": [ + "CategorySortTypeAddedTimeDesc", + "CategorySortTypeWatchDesc", + "CategorySortTypeLikeDesc", + "CategorySortTypeAddedTimeAsc", + "CategorySortTypeWatchAsc", + "CategorySortTypeLikeAsc", + "CategorySortTypeUpdateTimeDesc", + "CategorySortTypeUpdateTimeAsc", + "CategorySortTypeSortType" + ] + }, + "pubdef.CommentObjectType": { + "type": "integer", + "enum": [ + 0, + 4 + ], + "x-enum-comments": { + "CommentObjectTypePlaylet": "短剧", + "CommentObjectTypeVideo": "视频" + }, + "x-enum-varnames": [ + "CommentObjectTypeVideo", + "CommentObjectTypePlaylet" + ] + }, + "pubdef.DisplayMode": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-comments": { + "DisplayModeAvatarList": "头像列表", + "DisplayModeBigCover": "大封面列表(一大图两列)", + "DisplayModeCardCarousel": "卡片层叠轮播", + "DisplayModeDefault": "默认(上图下标题多列)", + "DisplayModeImageText": "图文列表(左图右标题多列)" + }, + "x-enum-varnames": [ + "DisplayModeDefault", + "DisplayModeCardCarousel", + "DisplayModeAvatarList", + "DisplayModeImageText", + "DisplayModeBigCover" + ] + }, + "pubdef.FakeFlow": { + "type": "object", + "properties": { + "likes": { + "description": "假点赞数", + "type": "integer" + }, + "watchTimes": { + "description": "假观看次数", + "type": "integer" + } + } + }, + "pubdef.Flow": { + "type": "object", + "properties": { + "comments": { + "description": "评论条数", + "type": "integer" + }, + "likes": { + "description": "真实点赞数(收藏总量)", + "type": "integer" + }, + "monthLikes": { + "description": "近30日收藏量", + "type": "integer" + }, + "monthWatchs": { + "description": "本月观看次数", + "type": "integer" + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "todayWatchs": { + "description": "当日观看次数", + "type": "integer" + }, + "watchTimes": { + "description": "真实观看次数", + "type": "integer" + }, + "weekLikes": { + "description": "近7日收藏量", + "type": "integer" + }, + "weekWatchs": { + "description": "本周观看次数", + "type": "integer" + }, + "yearLikes": { + "description": "近365日收藏量", + "type": "integer" + }, + "yearWatchs": { + "description": "本年观看次数", + "type": "integer" + } + } + }, + "pubdef.InformationStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "InformationStatusVerifyCancel": "下架", + "InformationStatusVerifyOK": "上架", + "InformationStatusWaiting": "初始化" + }, + "x-enum-varnames": [ + "InformationStatusWaiting", + "InformationStatusVerifyOK", + "InformationStatusVerifyCancel" + ] + }, + "pubdef.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeMobile": "手机登陆", + "LoginTypeQrCode": "二维码登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeMobile", + "LoginTypeQrCode", + "LoginTypeAccount" + ] + }, + "pubdef.Page": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "pubdef.SeoValue": { + "type": "object", + "properties": { + "description": { + "description": "网站SEO描述", + "type": "string" + }, + "keyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "网站SEO标题", + "type": "string" + } + } + }, + "pubdef.TranslationType": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "TranslationTypeBottom": "底部导航", + "TranslationTypeSub": "二级导航", + "TranslationTypeUrl": "URL模式" + }, + "x-enum-varnames": [ + "TranslationTypeUrl", + "TranslationTypeSub", + "TranslationTypeBottom" + ] + }, + "pubdef.UserStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "UserStatusDisable": "封禁", + "UserStatusNormal": "正常", + "UserStatusSlient": "禁言" + }, + "x-enum-varnames": [ + "UserStatusNormal", + "UserStatusDisable", + "UserStatusSlient" + ] + }, + "questionnairemod.Attachment": { + "type": "object", + "properties": { + "fileType": { + "description": "附件类型(img / video)", + "type": "string" + }, + "url": { + "description": "附件URL地址", + "type": "string" + } + } + }, + "questionnairemod.QuestionnaireModel": { + "type": "object", + "properties": { + "attachments": { + "description": "附件列表,最多3个", + "type": "array", + "items": { + "$ref": "#/definitions/questionnairemod.Attachment" + } + }, + "city": { + "description": "所在城市(选填)", + "type": "string" + }, + "contact": { + "description": "联系方式(选填)", + "type": "string" + }, + "createdAt": { + "description": "创建时间(自动生成)", + "type": "string" + }, + "description": { + "description": "问题描述(必填)", + "type": "string" + }, + "deviceModel": { + "description": "设备型号(选填)", + "type": "string" + }, + "deviceType": { + "description": "设备类型:iPhone、Android、iPad、其他", + "type": "string" + }, + "frequency": { + "description": "出现频率:每次都会、经常出现、偶尔出现、仅现一次", + "type": "string" + }, + "id": { + "description": "唯一标识(MongoDB自动生成)", + "type": "string" + }, + "networkType": { + "description": "网络类型:WI-FI、5G、4G、其他", + "type": "string" + }, + "operator": { + "description": "运营商:电信、联通、移动、广电", + "type": "string" + }, + "province": { + "description": "所在省份(必填)", + "type": "string" + }, + "questionType": { + "description": "问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题", + "type": "string" + }, + "remark": { + "description": "备注,处理问卷时填写(选填)", + "type": "string" + }, + "status": { + "description": "处理状态:待处理、已处理、拒绝", + "type": "string" + }, + "updatedAt": { + "description": "更新时间(自动更新)", + "type": "string" + }, + "userId": { + "description": "App端登录用户ID,0表示匿名", + "type": "integer" + } + } + }, + "richtextcontentmod.RichTextContentModel": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "id": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序值", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题(内部)", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "srv_acc.OpLogInfo": { + "type": "object", + "properties": { + "actId": { + "description": "操作人Id", + "type": "integer" + }, + "actName": { + "description": "操作人", + "type": "string" + }, + "action": { + "description": "操作目的", + "type": "string" + }, + "createdAt": { + "description": "操作时间", + "type": "string" + }, + "param": { + "description": "操作内容", + "type": "string" + }, + "path": { + "description": "请求接口", + "type": "string" + } + } + }, + "srv_actress.ActressAddReq": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "office": { + "description": "事务所", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或别名自动生成", + "type": "string" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + } + } + }, + "srv_actress.ActressDelReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_actress.ActressListReq": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "sortType": { + "description": "排序方式 0=创建时间 1=评分 2=排序值 3=作品数 4=总观看次数 5=热度值", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + } + } + }, + "srv_actress.ActressListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/actress.Actress" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_actress.ActressUpdateParam": { + "type": "object", + "properties": { + "alias": { + "description": "别名", + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "bornDate": { + "description": "出生日期", + "type": "string" + }, + "cover": { + "description": "封面大图", + "type": "string" + }, + "debutDate": { + "description": "出道日期", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "meas": { + "description": "三围", + "allOf": [ + { + "$ref": "#/definitions/actress.Meas" + } + ] + }, + "office": { + "description": "事务所", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "region": { + "description": "地区/国家", + "type": "string" + }, + "score": { + "description": "评分", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "translationCategoryId": { + "description": "关联导航分类id", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "srv_actress.ActressUpdateReq": { + "type": "object", + "required": [ + "id", + "param" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/srv_actress.ActressUpdateParam" + } + } + }, + "srv_commend.CommendReplay": { + "type": "object", + "properties": { + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "parentsId": { + "description": "评论ID", + "type": "integer" + }, + "score": { + "description": "评分", + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "srv_commend.CommendReviewReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "reviewStatus": { + "type": "integer" + } + } + }, + "srv_commend.CommendSearch": { + "type": "object", + "properties": { + "level": { + "description": "评论级别", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 0=视频 4=短剧", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CommentObjectType" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "reviewStatus": { + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "srv_commend.CommendSearchResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_commend.PostCommentInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_commend.PostCommentInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "评论ID", + "type": "integer" + }, + "ip": { + "description": "评论IP", + "type": "string" + }, + "level": { + "description": "评论级别", + "type": "integer" + }, + "likes": { + "description": "点赞数", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectName": { + "description": "对象类型名称 视频/短剧", + "type": "string" + }, + "objectType": { + "description": "对象类型 0=视频 4=短剧", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CommentObjectType" + } + ] + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "replyUserName": { + "description": "回复名称", + "type": "string" + }, + "reviewStatus": { + "description": "审核状态 0 未审核 1审核成功 2 审核失败", + "type": "integer" + }, + "status": { + "description": "评论用户的状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "topRank": { + "description": "置顶排序", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "srv_contactus.DelOneContactusReq": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "srv_contactus.GetContactusListReq": { + "type": "object", + "properties": { + "categoryName": { + "description": "分类", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "ip": { + "description": "ip", + "type": "string" + }, + "name": { + "description": "姓名", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_contactus.GetContactusListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/contactusmod.ContactUsModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_contactus.UpdateContactusReq": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, + "srv_detailnav.AddDetailNavReq": { + "type": "object", + "required": [ + "name", + "url" + ], + "properties": { + "name": { + "description": "导航名称", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "url": { + "description": "导航链接", + "type": "string" + } + } + }, + "srv_detailnav.DelDetailNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "srv_detailnav.GetDetailNavListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_detailnav.GetDetailNavListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/detailnavmod.DetailNavModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_detailnav.UpdateDetailNavParam": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "boolean" + }, + "url": { + "type": "string" + } + } + }, + "srv_detailnav.UpdateDetailNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "param": { + "$ref": "#/definitions/srv_detailnav.UpdateDetailNavParam" + } + } + }, + "srv_hotnav.AddHotNavReq": { + "type": "object", + "required": [ + "title", + "url" + ], + "properties": { + "icon": { + "description": "图标", + "type": "string" + }, + "intro": { + "description": "介绍", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + } + } + }, + "srv_hotnav.DelHotNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_hotnav.GetHotNavListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_hotnav.GetHotNavListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/hotnavmod.HotNav" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_hotnav.UpdateHotNavParam": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "intro": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "srv_hotnav.UpdateHotNavReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/srv_hotnav.UpdateHotNavParam" + } + } + }, + "srv_ipblack.AddIPBlackListReq": { + "type": "object", + "properties": { + "desc": { + "description": "备注", + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "srv_ipblack.DelIpBlackListReq": { + "type": "object", + "properties": { + "ip": { + "type": "string" + } + } + }, + "srv_ipblack.GetIPBlackListReq": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_ipblack.GetIPBlackListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/ipblackmod.IPBlackModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_questionnaire.HandleReq": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "description": "问卷ID(ObjectID 字符串)", + "type": "string" + }, + "remark": { + "description": "备注(选填)", + "type": "string" + }, + "status": { + "description": "目标状态:processed 已处理 / reject 拒绝", + "type": "string" + } + } + }, + "srv_questionnaire.ListReq": { + "type": "object", + "properties": { + "endTime": { + "description": "创建时间范围-结束,Unix 时间戳(秒,选填)", + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "questionType": { + "description": "模糊匹配:问题类型(选填)", + "type": "string" + }, + "startTime": { + "description": "创建时间范围-开始,Unix 时间戳(秒,选填)", + "type": "integer" + }, + "status": { + "description": "精确匹配:处理状态(选填)", + "type": "string" + } + } + }, + "srv_questionnaire.ListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页数据", + "type": "array", + "items": { + "$ref": "#/definitions/questionnairemod.QuestionnaireModel" + } + }, + "total": { + "description": "满足条件的总条数", + "type": "integer" + } + } + }, + "srv_richtextcontent.DelRichTextContentReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_richtextcontent.GetRichTextContentListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "srv_richtextcontent.GetRichTextContentResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/richtextcontentmod.RichTextContentModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_richtextcontent.UpdateRichTextContentData": { + "type": "object", + "properties": { + "_id": { + "type": "integer" + }, + "content": { + "description": "内容", + "type": "string" + }, + "desc": { + "description": "标题(app)", + "type": "string" + }, + "location": { + "description": "位置", + "type": "array", + "items": { + "type": "string" + } + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序值", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_richtextcontent.UpdateRichTextContentReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_richtextcontent.UpdateRichTextContentData" + } + ] + } + } + }, + "srv_translation.TranslationInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_translation.TranslationInfoReq": { + "type": "object", + "properties": { + "id": { + "description": "导航ID", + "type": "integer" + }, + "name": { + "description": "导航名称", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "transCategoryId": { + "description": "导航分类ID", + "type": "integer" + } + } + }, + "srv_translation.TranslationInfoResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/translationmod.TranslationInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_translation.TranslationInfoUpdate": { + "type": "object", + "properties": { + "htmlId": { + "description": "富文本ID", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "link": { + "description": "外部跳转", + "type": "string" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "transCategoryId": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_translation.TranslationInfoUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_translation.TranslationInfoUpdate" + } + ] + } + } + }, + "srv_translationcategory.TranslationCategoryDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_translationcategory.TranslationCategoryReq": { + "type": "object", + "properties": { + "id": { + "description": "导航分类ID", + "type": "integer" + }, + "name": { + "description": "导航分类名称", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_translationcategory.TranslationCategoryResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/translationcategorymod.TranslationCategory" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_translationcategory.TranslationCategoryUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "categoryType": { + "description": "导航类型 1=url跳转 2=二级导航 3=底部导航", + "type": "integer" + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "type": "string" + }, + "urlValue": { + "description": "跳转URL", + "type": "string" + } + } + }, + "srv_translationcategory.TranslationCategoryUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_translationcategory.TranslationCategoryUpdate" + } + ] + } + } + }, + "srv_user.UserListReq": { + "type": "object", + "properties": { + "id": { + "description": "用户ID", + "type": "integer" + }, + "nickName": { + "description": "昵称(模糊)", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + } + } + }, + "srv_user.UserListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/usermod.User" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_user.UserUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/usermod.UserUpdate" + } + } + }, + "srv_videocategory.VideoCategoryDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryReq": { + "type": "object", + "properties": { + "displayPosition": { + "description": "显示位置 0=仅TAB 1=仅首页 2=两者都显示", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategoryDisplayPosition" + } + ] + }, + "id": { + "description": "分类 ID", + "type": "integer" + }, + "name": { + "description": "分类名称(模糊匹配)", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "sortType": { + "description": "排序方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategorySortType" + } + ] + } + } + }, + "srv_videocategory.VideoCategoryResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videocategorymod.VideoCategory" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryUpdate": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "displayMode": { + "description": "展示方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.DisplayMode" + } + ] + }, + "displayPosition": { + "description": "显示位置 0=仅TAB 1=仅首页 2=两者都显示", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategoryDisplayPosition" + } + ] + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sortType": { + "description": "排序方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategorySortType" + } + ] + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "tags": { + "description": "关联视频标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "translationCategoryId": { + "description": "导航分类", + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_videocollection.CollectionMediaInfo": { + "type": "object", + "properties": { + "coverImg": { + "description": "封面", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "nodes": { + "description": "节点", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.Node" + } + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + } + } + }, + "srv_videocollection.GetCollectionMediaListReq": { + "type": "object", + "properties": { + "collectionID": { + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_videocollection.GetCollectionMediaListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_videocollection.CollectionMediaInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocollection.UpdateCollectionMediaRankReq": { + "type": "object", + "properties": { + "collectionID": { + "description": "合集ID", + "type": "integer" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "videoId": { + "description": "视频ID", + "type": "integer" + } + } + }, + "srv_videocollection.VideoCollectionOptionMediaReq": { + "type": "object", + "properties": { + "collectionID": { + "description": "合集ID", + "type": "integer" + }, + "isAdd": { + "description": "增加还是移除", + "type": "boolean" + }, + "videoIds": { + "description": "视频ID", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "srv_videocreater.VideoCreaterDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videocreatermod.VideoCreaterInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "background": { + "description": "背景", + "type": "string" + }, + "createType": { + "description": "创作者类型 0=普通 1=官方", + "type": "integer" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "image": { + "description": "头像", + "type": "string" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "srv_videoinfo.AddVideoCollectionReq": { + "type": "object", + "properties": { + "coverImg": { + "description": "封面", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "firstTag": { + "description": "一级标签", + "type": "string" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "remark": { + "description": "备注", + "type": "string" + }, + "secondTags": { + "description": "二级标签标签", + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + } + } + }, + "srv_videoinfo.AddVideoReq": { + "type": "object", + "properties": { + "fileId": { + "description": "资源id", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "srv_videoinfo.SetFakeCountResp": { + "type": "object", + "properties": { + "matchedCount": { + "description": "匹配到的视频数量", + "type": "integer" + }, + "modifiedCount": { + "description": "实际修改的视频数量", + "type": "integer" + } + } + }, + "srv_videoinfo.SetFakeLikesReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,支持单个或批量", + "type": "array", + "items": { + "type": "integer" + } + }, + "likesMax": { + "description": "假点赞数随机最大值,包含该值", + "type": "integer" + }, + "likesMin": { + "description": "假点赞数随机最小值,包含该值", + "type": "integer" + } + } + }, + "srv_videoinfo.SetFakeWatchTimesReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,支持单个或批量", + "type": "array", + "items": { + "type": "integer" + } + }, + "watchTimesMax": { + "description": "假观看次数随机最大值,包含该值", + "type": "integer" + }, + "watchTimesMin": { + "description": "假观看次数随机最小值,包含该值", + "type": "integer" + } + } + }, + "srv_videoinfo.SetRecommendReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + } + } + }, + "srv_videoinfo.SyncMediaFileItem": { + "type": "object", + "properties": { + "bango": { + "type": "string" + }, + "coverImg": { + "type": "string" + }, + "fieldNameFs": { + "type": "string" + }, + "firstTag": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "keyworld": { + "type": "array", + "items": { + "type": "string" + } + }, + "publish": { + "type": "string" + }, + "secondTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "taskId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "videoTime": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } + }, + "srv_videoinfo.SyncMediaFileReq": { + "type": "object", + "properties": { + "fileId": { + "description": "媒资库影片 ID", + "type": "array", + "items": { + "type": "string" + } + }, + "firstTagMap": { + "description": "覆盖一级标签", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "minVideoSeconds": { + "description": "最短时长,0 表示不限制", + "type": "integer" + }, + "sourceVersion": { + "description": "v1 / v2", + "type": "string" + }, + "videoList": { + "description": "媒资库搜索结果,优先使用", + "type": "array", + "items": { + "$ref": "#/definitions/srv_videoinfo.SyncMediaFileItem" + } + } + } + }, + "srv_videoinfo.VideoAddTagsReq": { + "type": "object", + "required": [ + "tags", + "videoIds" + ], + "properties": { + "tags": { + "description": "统一标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "videoIds": { + "description": "视频ID集合", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "srv_videoinfo.VideoInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoReq": { + "type": "object", + "properties": { + "actressIds": { + "description": "女优ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "bango": { + "description": "番号,", + "type": "string" + }, + "categoryId": { + "description": "分类ID", + "type": "integer" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "endTime": { + "description": "创建时间-截止", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "keyworld": { + "description": "关键字", + "type": "string" + }, + "mediaType": { + "description": "资源类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "optionAccount": { + "description": "最后操作人", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "sort": { + "description": "排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数。按分类查询且排序方式为排序值时,使用该分类的 categoryRanks 排序", + "type": "integer" + }, + "startTime": { + "description": "创建时间-开始", + "type": "string" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "tag": { + "description": "标签", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoFileId": { + "description": "切片id(切片列表的ID)", + "type": "string" + }, + "videoTaskId": { + "description": "视频任务id", + "type": "string" + } + } + }, + "srv_videoinfo.VideoInfoResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.VideoInformation" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoUpdate": { + "type": "object", + "properties": { + "actressIds": { + "description": "女优ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "addedTime": { + "type": "string" + }, + "bango": { + "description": "番号,唯一", + "type": "string" + }, + "categoryId": { + "description": "分类ID", + "type": "integer" + }, + "cover": { + "description": "视频封面", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "firstTag": { + "description": "一级标签", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "indexNow": { + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "issuedAt": { + "description": "发行时间", + "type": "string" + }, + "issuedBrand": { + "description": "发行商", + "type": "string" + }, + "keyworld": { + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "description": "资源类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "nodes": { + "description": "节点", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.Node" + } + }, + "remark": { + "description": "备注", + "type": "string" + }, + "secondTags": { + "description": "二级标签标签", + "type": "array", + "items": { + "type": "string" + } + }, + "secondUpdatedAt": { + "description": "二次修改时间", + "type": "string" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "unencryptedImage": { + "description": "不加密封面", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videoinfo.VideoInfoUpdate" + } + ] + } + } + }, + "srv_videoinfo.VideoSetTagsReq": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "description": "视频ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagIds": { + "description": "标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "srv_videotag.HotTagItem": { + "type": "object", + "properties": { + "tagId": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "watchTimes": { + "type": "integer" + } + } + }, + "srv_videotag.HotTagResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_videotag.HotTagItem" + } + } + } + }, + "srv_videotag.IpStatReq": { + "type": "object", + "properties": { + "todayIP": { + "type": "integer" + }, + "yesterdayIP": { + "type": "integer" + } + } + }, + "srv_videotag.VideoTagInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videotag.VideoTagInfoReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "srv_videotag.VideoTagInfoResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videotagmod.VideoTagInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videotag.VideoTagInfoUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "images": { + "description": "背景", + "type": "string" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "translationcategorymod.TranslationCategory": { + "type": "object", + "properties": { + "categoryType": { + "description": "导航类型 1:url 2:二级导航 3:底部导航", + "allOf": [ + { + "$ref": "#/definitions/pubdef.TranslationType" + } + ] + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "urlValue": { + "description": "跳转URL", + "type": "string" + } + } + }, + "translationmod.TranslationInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "htmlId": { + "description": "富文本ID", + "type": "integer" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "link": { + "description": "外部跳转", + "type": "string" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "transCategoryId": { + "description": "导航分类", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "usermod.User": { + "type": "object", + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "age": { + "description": "年龄", + "type": "integer" + }, + "agentInviteCode": { + "description": "被邀请的inviteCode", + "type": "string" + }, + "appVer": { + "description": "app版本", + "type": "string" + }, + "auth": { + "description": "是否认证", + "type": "boolean" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "backgroud": { + "description": "背景图", + "type": "string" + }, + "consecutiveLogin": { + "description": "连续登陆天数", + "type": "integer" + }, + "consumptionTotal": { + "description": "金币总消费数量", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "currentVipExpire": { + "description": "当前vip过期时间", + "type": "string" + }, + "devID": { + "description": "设备唯一标示", + "type": "string" + }, + "devV2": { + "description": "设备唯一设备ID", + "type": "string" + }, + "device": { + "description": "设备", + "type": "string" + }, + "districtCode": { + "description": "商区码", + "type": "string" + }, + "gender": { + "description": "性别 0-未知,1-男,2-女", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "introduction": { + "description": "简介", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "invites": { + "description": "我邀请的人的个数(我邀请他人)", + "type": "integer" + }, + "isRegister": { + "description": "是否已注册", + "type": "boolean" + }, + "lastIp": { + "description": "上次登陆IP", + "type": "string" + }, + "lastLoginAt": { + "description": "上次登陆时间", + "type": "string" + }, + "loginType": { + "description": "登陆方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "mac": { + "description": "设备唯一mac地址", + "type": "string" + }, + "modifyUserNameAt": { + "description": "上一次修改用户时间", + "type": "string" + }, + "nickName": { + "description": "昵称", + "type": "string" + }, + "oriVer": { + "description": "注册时的app版本", + "type": "string" + }, + "password": { + "description": "登陆密码", + "type": "string" + }, + "recharge": { + "description": "当前充值余额,金币个数", + "type": "integer" + }, + "rechargeTotal": { + "description": "累计充值额,金币个数", + "type": "integer" + }, + "regIp": { + "description": "注册IP", + "type": "string" + }, + "region": { + "description": "地区", + "type": "string" + }, + "registerTime": { + "type": "string" + }, + "score": { + "description": "积分", + "type": "integer" + }, + "scoreInvites": { + "description": "邀请人获得的积分累计", + "type": "integer" + }, + "scoreLvName": { + "description": "积分等级名称", + "type": "string" + }, + "scoreTotal": { + "description": "累计积分", + "type": "integer" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "sysMsgAt": { + "description": "获取系统消息时间", + "type": "string" + }, + "sysType": { + "description": "系统类型 ios android", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "usermod.UserUpdate": { + "type": "object", + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "age": { + "description": "年龄", + "type": "integer" + }, + "agentInviteCode": { + "description": "被邀请的inviteCode", + "type": "string" + }, + "appVer": { + "description": "app版本", + "type": "string" + }, + "auth": { + "description": "是否认证", + "type": "boolean" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "backgroud": { + "description": "背景图", + "type": "string" + }, + "consecutiveLogin": { + "description": "连续登陆天数", + "type": "integer" + }, + "consumptionTotal": { + "description": "金币总消费数量", + "type": "integer" + }, + "currentVipExpire": { + "description": "当前vip过期时间", + "type": "string" + }, + "devID": { + "description": "设备唯一标示", + "type": "string" + }, + "devV2": { + "description": "设备唯一设备ID", + "type": "string" + }, + "device": { + "description": "设备", + "type": "string" + }, + "districtCode": { + "description": "商区码", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "gender": { + "description": "性别 0-未知,1-男,2-女", + "type": "integer" + }, + "introduction": { + "description": "简介", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "invites": { + "description": "我邀请的人的个数(我邀请他人)", + "type": "integer" + }, + "isRegister": { + "description": "是否已注册", + "type": "boolean" + }, + "lastIp": { + "description": "上次登陆IP", + "type": "string" + }, + "lastLoginAt": { + "description": "上次登陆时间", + "type": "string" + }, + "loginType": { + "description": "登陆方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "mac": { + "description": "设备唯一mac地址", + "type": "string" + }, + "merchantExpiredAt": { + "description": "商家/兼职有效期", + "type": "string" + }, + "modifyUserNameAt": { + "description": "上一次修改用户时间", + "type": "string" + }, + "nickName": { + "description": "昵称", + "type": "string" + }, + "oriVer": { + "description": "注册时的app版本", + "type": "string" + }, + "password": { + "description": "登陆密码", + "type": "string" + }, + "recharge": { + "description": "当前充值余额,金币个数", + "type": "integer" + }, + "rechargeTotal": { + "description": "累计充值额,金币个数", + "type": "integer" + }, + "regIp": { + "description": "注册IP", + "type": "string" + }, + "region": { + "description": "地区", + "type": "string" + }, + "registerTime": { + "type": "string" + }, + "score": { + "description": "积分", + "type": "integer" + }, + "scoreInvites": { + "description": "邀请人获得的积分累计", + "type": "integer" + }, + "scoreLvName": { + "description": "积分等级名称", + "type": "string" + }, + "scoreTotal": { + "description": "累计积分", + "type": "integer" + }, + "showCardExpiredAt": { + "description": "最新会员卡过期时间", + "type": "string" + }, + "showCardId": { + "description": "最新会员卡id", + "type": "integer" + }, + "showCardName": { + "description": "最新会员卡名称", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "sysMsgAt": { + "description": "获取系统消息时间", + "type": "string" + }, + "sysType": { + "description": "系统类型 ios android", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "videocategorymod.VideoCategory": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "displayMode": { + "description": "展示方式 0:默认 1:卡片层叠轮播 2:头像列表 3:图文列表 4:大封面列表", + "allOf": [ + { + "$ref": "#/definitions/pubdef.DisplayMode" + } + ] + }, + "displayPosition": { + "description": "显示位置 0=仅TAB 1=仅首页 2=两者都显示", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategoryDisplayPosition" + } + ] + }, + "id": { + "description": "Id", + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sortType": { + "description": "排序方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.CategorySortType" + } + ] + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "tags": { + "description": "关联视频标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "translationCategoryId": { + "description": "导航分类", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "videocreatermod.VideoCreaterInfo": { + "type": "object", + "properties": { + "background": { + "description": "背景", + "type": "string" + }, + "createType": { + "description": "创作者类型 0=普通 1=官方", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "description": "头像", + "type": "string" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "description": "帖子标题", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "videoinformationmod.HevcStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + -1 + ], + "x-enum-comments": { + "HevcStatusFailed": "转码失败", + "HevcStatusNone": "未提交转码", + "HevcStatusPending": "转码中", + "HevcStatusSuccess": "转码成功" + }, + "x-enum-varnames": [ + "HevcStatusNone", + "HevcStatusPending", + "HevcStatusSuccess", + "HevcStatusFailed" + ] + }, + "videoinformationmod.MediaType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "MediaType_AV": "AV视频", + "MediaType_Advitise": "广告", + "MediaType_Collection": "合集", + "MediaType_Video": "视频" + }, + "x-enum-varnames": [ + "MediaType_Video", + "MediaType_Collection", + "MediaType_Advitise", + "MediaType_AV" + ] + }, + "videoinformationmod.Node": { + "type": "object", + "properties": { + "images": { + "description": "图片", + "type": "array", + "items": { + "type": "string" + } + }, + "nodeId": { + "description": "节点ID", + "type": "string" + }, + "text": { + "description": "文本", + "type": "string" + }, + "title": { + "description": "节点标题", + "type": "string" + }, + "url": { + "description": "链接", + "type": "string" + }, + "videos": { + "description": "视频信息列表", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + } + } + }, + "videoinformationmod.VideoInfo": { + "type": "object", + "properties": { + "cover": { + "description": "视频封面", + "type": "string" + }, + "fileId": { + "description": "媒资库ID", + "type": "string" + }, + "h265Url": { + "description": "H.265 m3u8 播放地址,空字符串表示暂无该编码源", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "H.264 m3u8 播放地址(始终存在)", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "videoinformationmod.VideoInformation": { + "type": "object", + "properties": { + "actressIds": { + "description": "女优ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "audioFile": { + "description": "音频文件路径", + "type": "string" + }, + "bango": { + "description": "番号,唯一", + "type": "string" + }, + "categoryId": { + "description": "分类ID", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "desc": { + "description": "详细描述", + "type": "string" + }, + "fake": { + "description": "伪造流量", + "allOf": [ + { + "$ref": "#/definitions/pubdef.FakeFlow" + } + ] + }, + "firstTag": { + "description": "一级标签", + "type": "string" + }, + "hevcFailCount": { + "description": "主视频转码累计失败次数", + "type": "integer" + }, + "hevcMetaInfo": { + "description": "H265 视频元信息", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoMetaInfo" + } + ] + }, + "hevcStatus": { + "description": "主视频 H265 转码状态", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.HevcStatus" + } + ] + }, + "id": { + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "isHot": { + "description": "是否热门", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐 false-不推荐", + "type": "boolean" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "issuedAt": { + "description": "发行时间", + "type": "string" + }, + "issuedBrand": { + "description": "发行商", + "type": "string" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "description": "资源类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "nodes": { + "description": "节点", + "type": "array", + "items": { + "$ref": "#/definitions/videoinformationmod.Node" + } + }, + "optionAccount": { + "description": "最后操作人", + "type": "string" + }, + "pageUpdatedAt": { + "description": "页面展示更新时间", + "type": "string" + }, + "previewVtt": { + "description": "雪碧图 vtt", + "type": "string" + }, + "real": { + "description": "真实流量", + "allOf": [ + { + "$ref": "#/definitions/pubdef.Flow" + } + ] + }, + "realUpdateAt": { + "description": "播放相关数据更新时间", + "type": "string" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "secondTags": { + "description": "二级标签标签", + "type": "array", + "items": { + "type": "string" + } + }, + "secondUpdatedAt": { + "description": "二次修改时间", + "type": "string" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "smartCover": { + "description": "智能封面", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.InformationStatus" + } + ] + }, + "syncId": { + "description": "同步ID", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "unencryptedImage": { + "description": "不加密封面", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "uploadUserName": { + "description": "上传用户名", + "type": "string" + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.VideoInfo" + } + ] + }, + "videoCount": { + "description": "合集视频数量", + "type": "integer" + } + } + }, + "videoinformationmod.VideoMetaInfo": { + "type": "object", + "properties": { + "audioBitrate": { + "type": "integer" + }, + "audioChannels": { + "type": "integer" + }, + "audioCodec": { + "type": "string" + }, + "audioSampleRate": { + "type": "integer" + }, + "bitrate": { + "type": "integer" + }, + "codec": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "fps": { + "type": "number" + }, + "height": { + "type": "integer" + }, + "m3u8Url": { + "type": "string" + }, + "pixFmt": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "videoBitrate": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } + }, + "videotagmod.VideoTagInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "integer" + }, + "images": { + "description": "背景", + "type": "string" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "isShowHome": { + "description": "是否显示在首页", + "type": "boolean" + }, + "mediaTypes": { + "description": "作者关联的资源类型 1视频", + "type": "array", + "items": { + "type": "integer" + } + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "watchTimes": { + "description": "预计算:该标签下所有视频的累计播放量", + "type": "integer" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "web_acc.AccOpLogReq": { + "type": "object", + "properties": { + "actId": { + "description": "操作人Id", + "type": "integer" + }, + "action": { + "description": "操作目的", + "type": "string" + }, + "endTime": { + "description": "开始时间", + "type": "string" + }, + "pageNum": { + "description": "页码", + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "path": { + "description": "请求接口", + "type": "string" + }, + "startTime": { + "description": "开始时间", + "type": "string" + } + } + }, + "web_acc.AccOpLogResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_acc.OpLogInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "web_acc.BindQurlReq": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + } + } + }, + "web_acc.BindQurlResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MainWebLoginReq": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + }, + "code": { + "description": "验证码", + "type": "string" + }, + "pwd": { + "description": "密码", + "type": "string" + } + } + }, + "web_acc.MainWebLoginResp": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + }, + "accId": { + "description": "账户id", + "type": "integer" + }, + "qurl": { + "description": "二维码", + "type": "array", + "items": { + "type": "integer" + } + }, + "roleId": { + "description": "角色id", + "type": "integer" + }, + "token": { + "description": "token", + "type": "string" + } + } + }, + "web_acc.MwAccAddReq": { + "type": "object", + "properties": { + "acc": { + "description": "账户", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色ID", + "type": "integer" + } + } + }, + "web_acc.MwAccAddResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwAccDelReq": { + "type": "object", + "properties": { + "id": { + "description": "账号Id", + "type": "integer" + } + } + }, + "web_acc.MwAccDelResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwAccGetInfoReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwAccGetInfoResp": { + "type": "object", + "properties": { + "acc": { + "description": "账号", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色Id", + "type": "integer" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccGetStatusReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwAccGetStatusResp": { + "type": "object", + "properties": { + "feedBack": { + "description": "禁用原因", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccListReq": { + "type": "object", + "properties": { + "keyWords": { + "description": "搜索条件", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态 1正常 2 禁用", + "type": "integer" + } + } + }, + "web_acc.MwAccListResp": { + "type": "object", + "properties": { + "accRec": { + "description": "当前页配置记录", + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.MwAccRec" + } + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "web_acc.MwAccRec": { + "type": "object", + "properties": { + "acc": { + "description": "账号", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "integer" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "feedBack": { + "description": "禁用原因", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isBase32": { + "description": "// 二步签名\nBase32 string `json:\"base32\"`\n是否已签名", + "type": "boolean" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色Id", + "type": "integer" + }, + "roleName": { + "description": "角色名", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "integer" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccSaveStatusReq": { + "type": "object", + "properties": { + "feedBack": { + "description": "禁用原因", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccSaveStatusResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwAccUpPwdReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "账号Id", + "type": "integer" + }, + "pwd": { + "description": "密码", + "type": "string" + } + } + }, + "web_acc.MwAccUpReq": { + "type": "object", + "properties": { + "acc": { + "description": "账号", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "id": { + "description": "账号Id", + "type": "integer" + }, + "isEnb": { + "description": "使能", + "type": "boolean" + }, + "limitIP": { + "description": "允许访问的IP", + "type": "array", + "items": { + "type": "string" + } + }, + "pwd": { + "description": "密码", + "type": "string" + }, + "roleId": { + "description": "角色Id", + "type": "integer" + }, + "userName": { + "description": "用户名", + "type": "string" + } + } + }, + "web_acc.MwAccUpResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleAddReq": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "isEnb": { + "description": "状态", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + } + } + }, + "web_acc.MwRoleAddResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleDelReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwRoleDelResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleDrop": { + "type": "object", + "properties": { + "id": { + "description": "角色Id", + "type": "integer" + }, + "name": { + "description": "角色名称", + "type": "string" + } + } + }, + "web_acc.MwRoleDropResp": { + "type": "object", + "properties": { + "roleDrop": { + "description": "是否成功 true:成功 false:未成功", + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.MwRoleDrop" + } + } + } + }, + "web_acc.MwRoleEnableReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + }, + "isEnb": { + "description": "状态", + "type": "boolean" + } + } + }, + "web_acc.MwRoleEnableResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleGetAuthReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwRoleGetAuthResp": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + }, + "router": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router" + } + } + } + }, + "web_acc.MwRoleGetInfoReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + } + } + }, + "web_acc.MwRoleGetInfoResp": { + "type": "object", + "properties": { + "roleRec": { + "$ref": "#/definitions/web_acc.MwRoleRec" + } + } + }, + "web_acc.MwRoleListResp": { + "type": "object", + "properties": { + "roleRecs": { + "description": "当前页配置记录", + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.MwRoleRec" + } + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "web_acc.MwRoleRec": { + "type": "object", + "properties": { + "apiMenus": { + "description": "已有权限的api", + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "角色Id", + "type": "integer" + }, + "isEnb": { + "description": "是否启用 false:禁用 true:启用", + "type": "boolean" + }, + "name": { + "description": "角色名称", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "web_acc.MwRoleUpAuthReq": { + "type": "object", + "properties": { + "id": { + "description": "角色id", + "type": "integer" + }, + "router": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router" + } + } + } + }, + "web_acc.MwRoleUpAuthResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.MwRoleUpReq": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "角色id", + "type": "integer" + }, + "isEnb": { + "description": "状态", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + } + } + }, + "web_acc.MwRoleUpResp": { + "type": "object", + "properties": { + "result": { + "description": "是否成功 true:成功 false:未成功", + "type": "boolean" + } + } + }, + "web_acc.Router": { + "type": "object", + "properties": { + "apiUrl": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router_ApiUrl" + } + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/web_acc.Router" + } + }, + "component": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "isShow": { + "type": "boolean" + }, + "meta": { + "$ref": "#/definitions/web_acc.Router_Meta" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "redirect": { + "type": "string" + } + } + }, + "web_acc.Router_ApiUrl": { + "type": "object", + "properties": { + "desc": { + "type": "string" + }, + "method": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "boolean" + }, + "url": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "web_acc.Router_Meta": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "noCache": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "web_cfg.GetSysConfigReq": { + "type": "object" + }, + "web_cfg.GetSysConfigResp": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "dhDescription": { + "description": "导航SEO描述", + "type": "string" + }, + "dhKeyworld": { + "description": "导航SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "dhNewUrlAddr1": { + "description": "导航页-线路1", + "type": "string" + }, + "dhNewUrlAddr2": { + "description": "导航页-线路2", + "type": "string" + }, + "dhNewUrlAddr3": { + "description": "导航页-线路3", + "type": "string" + }, + "dhSiteName": { + "description": "导航SEO网站名称", + "type": "string" + }, + "dhTitle": { + "description": "导航SEO标题", + "type": "string" + }, + "downloadVideoUrl": { + "description": "下载视频URL", + "type": "string" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "freeVPN": { + "description": "免费vpn链接", + "type": "string" + }, + "gitAddr": { + "description": "git地址", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "id": { + "type": "integer" + }, + "imgAppCdn": { + "description": "图片CDN(app使用)", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgWebCdn": { + "description": "图片CDN(web使用)", + "type": "string" + }, + "m3u8DayRateLimitMax": { + "description": "每 IP 每天最多不同正片数,\u003c=0 使用默认 80", + "type": "integer" + }, + "mediaAppCdn": { + "description": "视频CDN(app使用)", + "type": "string" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "mediaWebCdn": { + "description": "视频CDN(web使用)", + "type": "string" + }, + "mp4Addr": { + "description": "mp4地址(web使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "网站管理", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "potaoGroup": { + "description": "土豆群", + "type": "string" + }, + "qq": { + "description": "qq群", + "type": "string" + }, + "sitemapDefaultImages": { + "description": "Sitemap里面默认封面图", + "type": "array", + "items": { + "type": "string" + } + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "telegramAdd": { + "description": "加入我们tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + }, + "unencryptedCdn": { + "description": "不加密资源使用的cdn", + "type": "string" + }, + "useAddress": { + "description": "所有使用中的域名", + "type": "array", + "items": { + "type": "string" + } + }, + "version": { + "description": "版本号,用于更新乐观锁", + "type": "integer" + }, + "watermarkImage": { + "description": "图片水印文件", + "type": "string" + }, + "watermarkMedia": { + "description": "视频水印文件(底部右边)", + "type": "string" + }, + "watermarkMediaBottomLeft": { + "description": "视频水印文件(底部左边)", + "type": "string" + }, + "watermarkMediaTop": { + "description": "视频水印文件(顶部左边)", + "type": "string" + }, + "watermarkMediaTopRight": { + "description": "视频水印文件(顶部右边)", + "type": "string" + }, + "webAddr": { + "description": "网站SEO网站地址", + "type": "string" + }, + "webDescription": { + "description": "网站SEO描述", + "type": "string" + }, + "webKeyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "webSiteName": { + "description": "网站SEO网站名称", + "type": "string" + }, + "webTitle": { + "description": "SEO", + "type": "string" + }, + "weiChart": { + "description": "微信群", + "type": "string" + } + } + }, + "web_cfg.SysConfigUpdateReq": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "dhDescription": { + "description": "导航SEO描述", + "type": "string" + }, + "dhKeyworld": { + "description": "导航SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "dhNewUrlAddr1": { + "description": "导航页-线路一", + "type": "string" + }, + "dhNewUrlAddr2": { + "description": "导航页-线路2", + "type": "string" + }, + "dhNewUrlAddr3": { + "description": "导航页-线路3", + "type": "string" + }, + "dhSiteName": { + "description": "导航SEO网站名称", + "type": "string" + }, + "dhTitle": { + "description": "导航SEO标题", + "type": "string" + }, + "downloadVideoUrl": { + "description": "下载视频URL", + "type": "string" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "gitAddr": { + "description": "git地址", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "imgAppCdn": { + "description": "图片CDN(app使用)", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgWebCdn": { + "description": "图片CDN(web使用)", + "type": "string" + }, + "m3u8DayRateLimitMax": { + "description": "每 IP 每天最多不同正片数,\u003c=0 使用默认 80", + "type": "integer" + }, + "mediaAppCdn": { + "description": "视频CDN(app使用)", + "type": "string" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "mediaWebCdn": { + "description": "视频CDN(web使用)", + "type": "string" + }, + "mp4Addr": { + "description": "mp4地址(web使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "最新地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "potaoGroup": { + "description": "土豆群", + "type": "string" + }, + "qq": { + "description": "qq群", + "type": "string" + }, + "sitemapDefaultImages": { + "description": "Sitemap里面默认封面图", + "type": "array", + "items": { + "type": "string" + } + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "telegramAdd": { + "description": "加入我们tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "translationAddr": { + "description": "应用导航", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + }, + "unencryptedCdn": { + "description": "不加密资源使用的cdn", + "type": "string" + }, + "watermarkImage": { + "description": "图片水印文件", + "type": "string" + }, + "watermarkMedia": { + "description": "视频水印文件(底部右边)", + "type": "string" + }, + "watermarkMediaBottomLeft": { + "description": "视频水印文件(底部左边)", + "type": "string" + }, + "watermarkMediaTop": { + "description": "视频水印文件(顶部左边)", + "type": "string" + }, + "watermarkMediaTopRight": { + "description": "视频水印文件(顶部右边)", + "type": "string" + }, + "webAddr": { + "description": "网站SEO网站地址", + "type": "string" + }, + "webDescription": { + "description": "网站SEO描述", + "type": "string" + }, + "webKeyworld": { + "description": "网站SEO关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "webSiteName": { + "description": "网站SEO网站名称", + "type": "string" + }, + "webTitle": { + "description": "SEO", + "type": "string" + }, + "weiChart": { + "description": "微信群", + "type": "string" + } + } + }, + "web_cfg.SysConfigUpdateResp": { + "type": "object" + }, + "web_commend.CommendDelIpReq": { + "type": "object", + "properties": { + "ip": { + "type": "string" + } + } + }, + "web_commend.CommendDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "web_commend.CommendReviewBatchReq": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "reviewStatus": { + "type": "integer" + } + } + }, + "web_file.UploadImageResp": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + }, + "web_file.UploadImagesResp": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "BasicAuth": { + "type": "basic" + }, + "OAuth2AccessCode": { + "type": "oauth2", + "flow": "accessCode", + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information" + } + }, + "OAuth2Application": { + "type": "oauth2", + "flow": "application", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Implicit": { + "type": "oauth2", + "flow": "implicit", + "authorizationUrl": "https://example.com/oauth/authorize", + "scopes": { + "admin": "Grants read and write access to administrative information", + "write": "Grants write access" + } + }, + "OAuth2Password": { + "type": "oauth2", + "flow": "password", + "tokenUrl": "https://example.com/oauth/token", + "scopes": { + "admin": "Grants read and write access to administrative information", + "read": "Grants read access", + "write": "Grants write access" + } + } + } +} \ No newline at end of file diff --git a/web/docs/swagger.yaml b/web/docs/swagger.yaml new file mode 100644 index 0000000..a0e5109 --- /dev/null +++ b/web/docs/swagger.yaml @@ -0,0 +1,8867 @@ +basePath: / +definitions: + actress.Actress: + properties: + alias: + description: 别名 + type: string + avatar: + description: 头像 + type: string + bornDate: + description: 出生日期 + type: string + cover: + description: 封面大图 + type: string + createdAt: + description: 创建时间 + type: string + debutDate: + description: 出道日期 + type: string + desc: + description: 详细描述 + type: string + hotValue: + description: 热度值 + type: integer + id: + description: Id + type: integer + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + meas: + allOf: + - $ref: '#/definitions/actress.Meas' + description: 三围 + office: + description: 事务所 + type: string + rank: + description: 排序 + type: integer + real: + allOf: + - $ref: '#/definitions/pubdef.Flow' + description: 真实流量 + realUpdateAt: + description: 播放相关数据更新时间 + type: string + region: + description: 地区/国家 + type: string + score: + description: 评分 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + status: + description: 是否启用 + type: boolean + translationCategoryId: + description: 关联导航分类id + type: integer + updatedAt: + description: 更新时间 + type: string + works: + description: 作品数 + type: integer + type: object + actress.Meas: + properties: + blood: + description: 血型 + type: string + bust: + description: 胸围 英寸 + type: integer + cup: + description: 罩杯(A:1-G:9)1表示A,2表示B,3表示C,4表示D,5表示E,6表示F,7表示G,8表示H,9表示I + type: integer + dia: + description: 直径 毫米(前端可以显示为厘米,如45毫米,显示为4.5厘米,返回后端时,乘以10,保留整数,返回45) + type: integer + gender: + description: 性别 0:女 1:男 2:其他 + type: integer + height: + description: 身高 厘米 + type: integer + hip: + description: 臀围 英寸 + type: integer + len: + description: 长度 毫米(前端可以显示为厘米,如145毫米,显示为14.5厘米,返回后端时,乘以10,保留整数,返回145) + type: integer + waist: + description: 腰围 英寸 + type: integer + weight: + description: 体重 公斤 + type: integer + type: object + contactusmod.ContactUsModel: + properties: + categoryName: + description: 主旨分类 + type: string + content: + description: 内容 + type: string + createdAt: + description: 创建时间 + type: string + email: + description: 邮件 + type: string + id: + description: ID + type: string + ip: + description: 评论IP + type: string + name: + description: 名字 + type: string + status: + description: 状态 0=初始 1=已回复 2=不予处理 3=已完结 + type: integer + updatedAt: + description: 更新时间 + type: string + type: object + contenttagmod.ContentTagType: + enum: + - 1 + - 5 + type: integer + x-enum-comments: + ContentTagTypePlaylet: 短剧 + ContentTagTypeVideo: 视频 + x-enum-varnames: + - ContentTagTypeVideo + - ContentTagTypePlaylet + detailnavmod.DetailNavModel: + properties: + createdAt: + description: 创建时间 + type: string + id: + type: string + name: + description: 导航名称 + type: string + sort: + description: 排序 + type: integer + status: + description: 是否启用 + type: boolean + updatedAt: + description: 更新时间 + type: string + url: + description: 导航链接 + type: string + type: object + hotnavmod.HotNav: + properties: + createdAt: + description: 创建时间 + type: string + icon: + description: 图标 + type: string + id: + description: ID + type: integer + intro: + description: 介绍 + type: string + sort: + description: 排序 + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题 + type: string + updatedAt: + description: 更新时间 + type: string + url: + description: 链接 + type: string + type: object + ipblackmod.IPBlackModel: + properties: + createdAt: + description: 创建时间 + type: string + desc: + description: 备注 + type: string + id: + type: string + ip: + type: string + updatedAt: + description: 更新时间 + type: string + type: object + playletchaptermod.PayType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + PayTypeCoin: 金币 + PayTypeFree: 免费 + PayTypeVip: VIP + x-enum-varnames: + - PayTypeFree + - PayTypeCoin + - PayTypeVip + playletchaptermod.WatermarkStatus: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + WatermarkStatusDone: 已完成 + WatermarkStatusFailed: 处理失败 + WatermarkStatusPending: 未处理(待 mediafile/sync) + WatermarkStatusProcessing: 处理中(已提交加水印任务,待回写) + WatermarkStatusRegistered: 已登记(原片已登记,待 task/add) + x-enum-varnames: + - WatermarkStatusPending + - WatermarkStatusRegistered + - WatermarkStatusProcessing + - WatermarkStatusDone + - WatermarkStatusFailed + playletmod.ContentType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + ContentTypeAlbum: 普通短剧/影集(对应 media_type=6) + ContentTypeAnime: 动漫(对应 media_type=1) + ContentTypeComic: 漫画短剧(对应 media_type=4) + ContentTypeManga: 漫画(对应 media_type=2) + ContentTypeTheater: 短剧(对应 media_type=5) + x-enum-varnames: + - ContentTypeTheater + - ContentTypeAlbum + - ContentTypeComic + - ContentTypeAnime + - ContentTypeManga + playletmod.PayType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + PayTypeCoin: 金币 + PayTypeFree: 免费 + PayTypeVip: VIP + x-enum-varnames: + - PayTypeFree + - PayTypeCoin + - PayTypeVip + playletmod.Status: + enum: + - 0 + - 1 + type: integer + x-enum-comments: + StatusDown: 下架 + StatusUp: 上架 + x-enum-varnames: + - StatusDown + - StatusUp + proto.ContentTagAddReq: + properties: + name: + description: 标签名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,不传则按 SEO 关键词或名称自动生成 + type: string + status: + description: 状态 true-启用 false-停用 + type: boolean + types: + description: 支持类型 1-视频 5-短剧 + items: + $ref: '#/definitions/contenttagmod.ContentTagType' + type: array + required: + - name + - status + - types + type: object + proto.ContentTagAddResp: + properties: + id: + description: ID + type: integer + type: object + proto.ContentTagDelReq: + properties: + id: + description: ID + type: integer + required: + - id + type: object + proto.ContentTagListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.ContentTagOptionItem: + properties: + bindCount: + description: 当前查询类型的绑定数量,不传type时返回0 + type: integer + id: + description: ID + type: integer + name: + description: 标签名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + typeNames: + description: 支持类型名称 + items: + type: string + type: array + types: + description: 支持类型 1-视频 5-短剧 + items: + $ref: '#/definitions/contenttagmod.ContentTagType' + type: array + type: object + proto.ContentTagOptionResp: + properties: + list: + description: 标签下拉列表 + items: + $ref: '#/definitions/proto.ContentTagOptionItem' + type: array + type: object + proto.ContentTagUpdateReq: + properties: + id: + description: ID + type: integer + name: + description: 标签名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + description: 状态 true-启用 false-停用 + type: boolean + types: + description: 支持类型 1-视频 5-短剧 + items: + $ref: '#/definitions/contenttagmod.ContentTagType' + type: array + required: + - id + type: object + proto.HomeBannerAddReq: + properties: + image: + description: 图片 + type: string + name: + description: 名称 + type: string + rank: + description: 排序 + type: integer + status: + description: 状态 true-启用 false-停用 + type: boolean + url: + description: URL + type: string + verticalImage: + description: 竖版图片 + type: string + required: + - image + - name + - status + type: object + proto.HomeBannerAddResp: + properties: + id: + description: ID + type: integer + type: object + proto.HomeBannerDelReq: + properties: + id: + description: ID + type: integer + required: + - id + type: object + proto.HomeBannerListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.HomeBannerUpdateReq: + properties: + id: + description: ID + type: integer + image: + description: 图片 + type: string + name: + description: 名称 + type: string + rank: + description: 排序 + type: integer + status: + description: 状态 true-启用 false-停用 + type: boolean + url: + description: URL + type: string + verticalImage: + description: 竖版图片 + type: string + required: + - id + type: object + proto.PlayletAddReq: + properties: + anchor: + description: 作者/演员 + type: string + banner: + description: banner图 + type: string + categoryIds: + description: 分类ID集合 + items: + type: integer + type: array + contentType: + allOf: + - $ref: '#/definitions/playletmod.ContentType' + description: 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + cover: + description: 封面原图 + type: string + desc: + description: 简介 + type: string + isFeatured: + description: 是否爆剧,true-是、false-否 + type: boolean + isRecommend: + description: 是否推荐,true-推荐、false-不推荐 + type: boolean + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式,0-免费、1-金币、2-VIP + preview: + description: 预告 + type: string + recommendRank: + description: 推荐排序 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + description: 是否显示在首页banner + type: boolean + singleCoins: + description: 单集金币,支付方式为1-金币时生效 + type: integer + slug: + description: slug,不传则按 SEO 关键词或标题自动生成 + type: string + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 上架状态,0-下架、1-上架 + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + title: + description: 标题 + type: string + topRank: + description: 置顶排序 + type: integer + totalEpisode: + description: 总集数 + type: integer + required: + - title + type: object + proto.PlayletAddResp: + properties: + id: + description: 短剧ID + type: integer + type: object + proto.PlayletBannerSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + showOnBanner: + description: 是否显示在首页banner,true-显示、false-不显示 + type: boolean + type: object + proto.PlayletCategoryAddReq: + properties: + icon: + description: 分类图标 + type: string + name: + description: 分类名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + remark: + description: 备注 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,不传则按 SEO 关键词或名称自动生成 + type: string + status: + description: 启用状态 + type: boolean + required: + - name + type: object + proto.PlayletCategoryAddResp: + properties: + id: + description: 分类ID + type: integer + type: object + proto.PlayletCategoryDelReq: + properties: + id: + description: 分类ID + type: integer + required: + - id + type: object + proto.PlayletCategoryListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.PlayletCategorySetReq: + properties: + categoryIds: + description: 分类ID集合,可设置多个分类,传空数组表示清空分类,不传表示参数错误 + items: + type: integer + type: array + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + type: object + proto.PlayletCategoryUpdateReq: + properties: + icon: + description: 分类图标 + type: string + id: + description: 分类ID + type: integer + name: + description: 分类名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + remark: + description: 备注 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + description: 启用状态 + type: boolean + required: + - id + type: object + proto.PlayletChapterAddReq: + properties: + currentEpisode: + description: 第几集 + type: integer + hash: + description: 媒体hash + type: string + height: + description: 视频高度 + type: integer + md5: + description: 媒体md5 + type: string + mediaSize: + description: 资源大小 + type: integer + mediaSourceId: + description: media切片服原片ID,上传正片已创建原片时直接沿用 + type: string + mediaTime: + description: 影片长度,单位秒 + type: integer + payType: + allOf: + - $ref: '#/definitions/playletchaptermod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + playletId: + description: 短剧ID + type: integer + ratio: + description: 宽高比 + type: number + singleCoins: + description: 单集金币,支付方式为1-金币时生效 + type: integer + title: + description: 剧集标题 + type: string + videoUrl: + description: 原片视频地址 + type: string + width: + description: 视频宽度 + type: integer + required: + - currentEpisode + - playletId + type: object + proto.PlayletChapterAddResp: + properties: + id: + description: 剧集ID + type: integer + type: object + proto.PlayletChapterDelReq: + properties: + ids: + description: 剧集ID集合,传1个表示单条删除,传多个表示批量删除 + items: + type: integer + type: array + type: object + proto.PlayletChapterListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.PlayletChapterPayTypeSetReq: + properties: + ids: + description: 剧集ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + payType: + allOf: + - $ref: '#/definitions/playletchaptermod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + singleCoins: + description: 单集金币,支付方式为1-金币时必填且需大于0 + type: integer + type: object + proto.PlayletChapterSyncReq: + properties: + playletIds: + description: 短剧ID集合,传1个表示单条同步,传多个表示批量同步 + items: + type: integer + type: array + required: + - playletIds + type: object + proto.PlayletChapterSyncResp: + properties: + failedCount: + description: 失败数量 + type: integer + list: + description: 同步结果集合 + items: + $ref: '#/definitions/proto.PlayletChapterSyncResult' + type: array + successCount: + description: 成功数量 + type: integer + total: + description: 同步总数 + type: integer + type: object + proto.PlayletChapterSyncResult: + properties: + newChapterCount: + description: 本次新增剧集数量 + type: integer + playletId: + description: 短剧ID + type: integer + reason: + description: 失败原因 + type: string + success: + description: 是否同步成功 + type: boolean + type: object + proto.PlayletChapterUpdateReq: + properties: + currentEpisode: + description: 第几集 + type: integer + hash: + description: 媒体hash + type: string + height: + description: 视频高度 + type: integer + id: + description: 剧集ID + type: integer + md5: + description: 媒体md5 + type: string + mediaSize: + description: 资源大小 + type: integer + mediaTime: + description: 影片长度,单位秒 + type: integer + payType: + allOf: + - $ref: '#/definitions/playletchaptermod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + ratio: + description: 宽高比 + type: number + singleCoins: + description: 单集金币,支付方式为1-金币时生效 + type: integer + title: + description: 剧集标题 + type: string + videoUrl: + description: 原片视频地址 + type: string + width: + description: 视频宽度 + type: integer + required: + - id + type: object + proto.PlayletFeaturedSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + isFeatured: + description: 是否爆剧,true-是、false-否 + type: boolean + type: object + proto.PlayletListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.PlayletPayTypeSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式,0-免费、1-金币、2-VIP + singleCoins: + description: 单集金币,支付方式为1-金币时必填且需大于0 + type: integer + type: object + proto.PlayletRecommendSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + isRecommend: + description: 是否推荐,true-推荐、false-不推荐 + type: boolean + type: object + proto.PlayletResourceChapterItem: + properties: + cover: + description: 封面 + type: string + createTime: + description: 创建时间 + type: integer + episodeNumber: + description: 第几集 + type: integer + hashId: + description: 资源hash + type: string + height: + description: 高 + type: integer + id: + description: 媒资内容ID + type: integer + mediaSize: + description: 资源大小 + type: integer + playTime: + description: 播放时长(秒) + type: integer + saleCoin: + description: 单集售价 + type: integer + title: + description: 章节名 + type: string + updateTime: + description: 更新时间 + type: integer + videoUrl: + description: 播放地址 + type: string + width: + description: 宽 + type: integer + type: object + proto.PlayletResourceChapterListResp: + properties: + list: + description: 剧集列表 + items: + $ref: '#/definitions/proto.PlayletResourceChapterItem' + type: array + mediaId: + description: 媒资中心资源ID + type: integer + title: + description: 短剧标题 + type: string + total: + description: 剧集数量 + type: integer + type: object + proto.PlayletResourceListItem: + properties: + anchor: + description: 作者 + type: string + create_time: + description: 创建时间 + type: integer + data_version: + description: 数据版本 + type: integer + horizontal_cover: + description: 横版封面 + type: string + id: + type: integer + isAdd: + description: 是否已加入后台数据 + type: boolean + media_type: + description: 媒体类型 + type: integer + sale_coin: + description: 整部售价 + type: integer + summary: + description: 简介 + type: string + tags_text: + description: 标签文字 + type: string + terminal_cdo: + description: 上传者 + type: string + title: + description: 标题 + type: string + total_episode: + description: 总集数 + type: integer + update_status: + description: 更新状态 1-连载中 2-已完结 + type: integer + update_time: + description: 修改时间 + type: integer + vertical_cover: + description: 竖版封面 + type: string + vlogger_id: + type: integer + type: object + proto.PlayletResourceListResp: + properties: + list: + items: + $ref: '#/definitions/proto.PlayletResourceListItem' + type: array + total: + type: integer + type: object + proto.PlayletResourceUseReq: + properties: + ids: + description: 媒资中心资源ID集合 + items: + type: integer + type: array + type: object + proto.PlayletStatusSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 上架状态,0-下架、1-上架 + type: object + proto.PlayletTagAddReq: + properties: + name: + type: string + rank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + status: + type: boolean + required: + - name + type: object + proto.PlayletTagAddResp: + properties: + id: + type: integer + type: object + proto.PlayletTagDelReq: + properties: + id: + type: integer + required: + - id + type: object + proto.PlayletTagListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.PlayletTagSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + tagIds: + description: 标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误 + items: + type: integer + type: array + type: object + proto.PlayletTagUpdateReq: + properties: + id: + type: integer + name: + type: string + rank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + status: + type: boolean + required: + - id + type: object + proto.PlayletThemeAddReq: + properties: + icon: + description: 题材图标 + type: string + name: + description: 题材名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,不传则按 SEO 关键词或名称自动生成 + type: string + status: + description: 上架状态 + type: boolean + required: + - name + type: object + proto.PlayletThemeAddResp: + properties: + id: + description: 题材ID + type: integer + type: object + proto.PlayletThemeDelReq: + properties: + id: + description: 题材ID + type: integer + required: + - id + type: object + proto.PlayletThemeListPageResp: + properties: + list: + description: 当前页数据 + pageNum: + description: 当前页码 + type: integer + pageSize: + description: 每页数量 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.PlayletThemeSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + themeIds: + description: 题材ID集合,可设置多个题材 + items: + type: integer + type: array + type: object + proto.PlayletThemeUpdateReq: + properties: + icon: + description: 题材图标 + type: string + id: + description: 题材ID + type: integer + name: + description: 题材名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + description: 上架状态 + type: boolean + required: + - id + type: object + proto.PlayletUpdateReq: + properties: + anchor: + description: 作者/演员 + type: string + banner: + description: banner图 + type: string + categoryIds: + description: 分类ID集合,传空数组表示清空分类 + items: + type: integer + type: array + contentType: + allOf: + - $ref: '#/definitions/playletmod.ContentType' + description: 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + cover: + description: 封面原图 + type: string + desc: + description: 简介 + type: string + id: + description: 短剧ID + type: integer + isFeatured: + description: 是否爆剧,true-是、false-否 + type: boolean + isRecommend: + description: 是否推荐,true-推荐、false-不推荐 + type: boolean + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式,0-免费、1-金币、2-VIP + preview: + description: 预告 + type: string + recommendRank: + description: 推荐排序 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + description: 是否显示在首页banner + type: boolean + singleCoins: + description: 单集金币,支付方式为1-金币时生效 + type: integer + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + allOf: + - $ref: '#/definitions/playletmod.Status' + description: 上架状态,0-下架、1-上架 + tagIds: + description: 标签ID集合,传空数组表示清空标签 + items: + type: integer + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + title: + description: 标题 + type: string + topRank: + description: 置顶排序 + type: integer + totalEpisode: + description: 总集数 + type: integer + required: + - id + type: object + proto.PlayletVirtualLikeSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + maxVirtualCountLike: + description: 虚拟点赞数最大值,必须大于等于最小值 + type: integer + minVirtualCountLike: + description: 虚拟点赞数最小值,必须大于等于0 + type: integer + type: object + proto.PlayletWatermarkResetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条,传多个表示批量 + items: + type: integer + type: array + type: object + proto.SlugSyncReq: + properties: + maxSize: + description: 本次最多处理的空 slug 记录数,默认500,最大2000 + type: integer + type: + description: 可选,指定类型;空则按顺序处理全部 + type: string + type: object + proto.SlugSyncResp: + properties: + actressUpdated: + description: 本次补齐的女优数 + type: integer + contentTagUpdated: + description: 本次补齐的统一标签数 + type: integer + hasMore: + description: 是否还有未处理的历史数据 + type: boolean + playletCategoryUpdated: + description: 本次补齐的短剧分类数 + type: integer + playletThemeUpdated: + description: 本次补齐的短剧主题数 + type: integer + playletUpdated: + description: 本次补齐的短剧数 + type: integer + totalUpdated: + description: 本次合计补齐数 + type: integer + videoCategoryUpdated: + description: 本次补齐的视频分类数 + type: integer + videoCreaterUpdated: + description: 本次补齐的作者数 + type: integer + videoUpdated: + description: 本次补齐的视频数 + type: integer + type: object + pubdef.CategoryDisplayPosition: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + CategoryDisplayPositionBoth: TAB和首页都显示 + CategoryDisplayPositionHomeOnly: 仅首页 + CategoryDisplayPositionTabOnly: 仅TAB + x-enum-varnames: + - CategoryDisplayPositionTabOnly + - CategoryDisplayPositionHomeOnly + - CategoryDisplayPositionBoth + pubdef.CategorySortType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + type: integer + x-enum-comments: + CategorySortTypeAddedTimeAsc: 上架时间升序 + CategorySortTypeAddedTimeDesc: 上架时间降序 + CategorySortTypeLikeAsc: 点赞(收藏)次数升序 + CategorySortTypeLikeDesc: 点赞(收藏)次数降序 + CategorySortTypeSortType: 自定义排序 + CategorySortTypeUpdateTimeAsc: 更新时间升序 + CategorySortTypeUpdateTimeDesc: 更新时间降序 + CategorySortTypeWatchAsc: 观看次数升序 + CategorySortTypeWatchDesc: 观看次数降序 + x-enum-varnames: + - CategorySortTypeAddedTimeDesc + - CategorySortTypeWatchDesc + - CategorySortTypeLikeDesc + - CategorySortTypeAddedTimeAsc + - CategorySortTypeWatchAsc + - CategorySortTypeLikeAsc + - CategorySortTypeUpdateTimeDesc + - CategorySortTypeUpdateTimeAsc + - CategorySortTypeSortType + pubdef.CommentObjectType: + enum: + - 0 + - 4 + type: integer + x-enum-comments: + CommentObjectTypePlaylet: 短剧 + CommentObjectTypeVideo: 视频 + x-enum-varnames: + - CommentObjectTypeVideo + - CommentObjectTypePlaylet + pubdef.DisplayMode: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + DisplayModeAvatarList: 头像列表 + DisplayModeBigCover: 大封面列表(一大图两列) + DisplayModeCardCarousel: 卡片层叠轮播 + DisplayModeDefault: 默认(上图下标题多列) + DisplayModeImageText: 图文列表(左图右标题多列) + x-enum-varnames: + - DisplayModeDefault + - DisplayModeCardCarousel + - DisplayModeAvatarList + - DisplayModeImageText + - DisplayModeBigCover + pubdef.FakeFlow: + properties: + likes: + description: 假点赞数 + type: integer + watchTimes: + description: 假观看次数 + type: integer + type: object + pubdef.Flow: + properties: + comments: + description: 评论条数 + type: integer + likes: + description: 真实点赞数(收藏总量) + type: integer + monthLikes: + description: 近30日收藏量 + type: integer + monthWatchs: + description: 本月观看次数 + type: integer + showTimes: + description: 展示次数 + type: integer + todayWatchs: + description: 当日观看次数 + type: integer + watchTimes: + description: 真实观看次数 + type: integer + weekLikes: + description: 近7日收藏量 + type: integer + weekWatchs: + description: 本周观看次数 + type: integer + yearLikes: + description: 近365日收藏量 + type: integer + yearWatchs: + description: 本年观看次数 + type: integer + type: object + pubdef.InformationStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + InformationStatusVerifyCancel: 下架 + InformationStatusVerifyOK: 上架 + InformationStatusWaiting: 初始化 + x-enum-varnames: + - InformationStatusWaiting + - InformationStatusVerifyOK + - InformationStatusVerifyCancel + pubdef.LoginType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + LoginTypeAccount: 账号登陆 + LoginTypeDevice: 设备登陆 + LoginTypeMobile: 手机登陆 + LoginTypeQrCode: 二维码登陆 + x-enum-varnames: + - LoginTypeDevice + - LoginTypeMobile + - LoginTypeQrCode + - LoginTypeAccount + pubdef.Page: + properties: + pageNum: + type: integer + pageSize: + type: integer + type: object + pubdef.SeoValue: + properties: + description: + description: 网站SEO描述 + type: string + keyworld: + description: 网站SEO关键字 + items: + type: string + type: array + title: + description: 网站SEO标题 + type: string + type: object + pubdef.TranslationType: + enum: + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + TranslationTypeBottom: 底部导航 + TranslationTypeSub: 二级导航 + TranslationTypeUrl: URL模式 + x-enum-varnames: + - TranslationTypeUrl + - TranslationTypeSub + - TranslationTypeBottom + pubdef.UserStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + UserStatusDisable: 封禁 + UserStatusNormal: 正常 + UserStatusSlient: 禁言 + x-enum-varnames: + - UserStatusNormal + - UserStatusDisable + - UserStatusSlient + questionnairemod.Attachment: + properties: + fileType: + description: 附件类型(img / video) + type: string + url: + description: 附件URL地址 + type: string + type: object + questionnairemod.QuestionnaireModel: + properties: + attachments: + description: 附件列表,最多3个 + items: + $ref: '#/definitions/questionnairemod.Attachment' + type: array + city: + description: 所在城市(选填) + type: string + contact: + description: 联系方式(选填) + type: string + createdAt: + description: 创建时间(自动生成) + type: string + description: + description: 问题描述(必填) + type: string + deviceModel: + description: 设备型号(选填) + type: string + deviceType: + description: 设备类型:iPhone、Android、iPad、其他 + type: string + frequency: + description: 出现频率:每次都会、经常出现、偶尔出现、仅现一次 + type: string + id: + description: 唯一标识(MongoDB自动生成) + type: string + networkType: + description: 网络类型:WI-FI、5G、4G、其他 + type: string + operator: + description: 运营商:电信、联通、移动、广电 + type: string + province: + description: 所在省份(必填) + type: string + questionType: + description: 问题类型:播放卡顿/缓冲、无法播放、画质模糊、音画不同步、加载失败、闪退/崩溃、登录异常、其他问题 + type: string + remark: + description: 备注,处理问卷时填写(选填) + type: string + status: + description: 处理状态:待处理、已处理、拒绝 + type: string + updatedAt: + description: 更新时间(自动更新) + type: string + userId: + description: App端登录用户ID,0表示匿名 + type: integer + type: object + richtextcontentmod.RichTextContentModel: + properties: + content: + description: 内容 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 标题(app) + type: string + id: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + sort: + description: 排序值 + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题(内部) + type: string + updatedAt: + description: 更新时间 + type: string + type: object + srv_acc.OpLogInfo: + properties: + actId: + description: 操作人Id + type: integer + actName: + description: 操作人 + type: string + action: + description: 操作目的 + type: string + createdAt: + description: 操作时间 + type: string + param: + description: 操作内容 + type: string + path: + description: 请求接口 + type: string + type: object + srv_actress.ActressAddReq: + properties: + alias: + description: 别名 + type: string + avatar: + description: 头像 + type: string + bornDate: + description: 出生日期 + type: string + cover: + description: 封面大图 + type: string + debutDate: + description: 出道日期 + type: string + desc: + description: 详细描述 + type: string + hotValue: + description: 热度值 + type: integer + meas: + allOf: + - $ref: '#/definitions/actress.Meas' + description: 三围 + office: + description: 事务所 + type: string + rank: + description: 排序 + type: integer + region: + description: 地区/国家 + type: string + score: + description: 评分 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,不传则按 SEO 关键词或别名自动生成 + type: string + status: + description: 是否启用 + type: boolean + translationCategoryId: + description: 关联导航分类id + type: integer + type: object + srv_actress.ActressDelReq: + properties: + id: + type: integer + type: object + srv_actress.ActressListReq: + properties: + alias: + description: 别名 + type: string + id: + description: Id + type: integer + pageNum: + type: integer + pageSize: + type: integer + region: + description: 地区/国家 + type: string + sortType: + description: 排序方式 0=创建时间 1=评分 2=排序值 3=作品数 4=总观看次数 5=热度值 + type: integer + status: + description: 是否启用 + type: boolean + translationCategoryId: + description: 关联导航分类id + type: integer + type: object + srv_actress.ActressListResp: + properties: + list: + items: + $ref: '#/definitions/actress.Actress' + type: array + total: + type: integer + type: object + srv_actress.ActressUpdateParam: + properties: + alias: + description: 别名 + type: string + avatar: + description: 头像 + type: string + bornDate: + description: 出生日期 + type: string + cover: + description: 封面大图 + type: string + debutDate: + description: 出道日期 + type: string + desc: + description: 详细描述 + type: string + hotValue: + description: 热度值 + type: integer + meas: + allOf: + - $ref: '#/definitions/actress.Meas' + description: 三围 + office: + description: 事务所 + type: string + rank: + description: 排序 + type: integer + region: + description: 地区/国家 + type: string + score: + description: 评分 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + status: + description: 是否启用 + type: boolean + translationCategoryId: + description: 关联导航分类id + type: integer + updatedAt: + description: 更新时间 + type: string + type: object + srv_actress.ActressUpdateReq: + properties: + id: + type: integer + param: + $ref: '#/definitions/srv_actress.ActressUpdateParam' + required: + - id + - param + type: object + srv_commend.CommendReplay: + properties: + objectId: + description: 对象ID + type: integer + parentsId: + description: 评论ID + type: integer + score: + description: 评分 + type: integer + text: + description: 评论内容 + type: string + userName: + description: 用户名 + type: string + type: object + srv_commend.CommendReviewReq: + properties: + id: + type: integer + reviewStatus: + type: integer + type: object + srv_commend.CommendSearch: + properties: + level: + description: 评论级别 + type: integer + objectId: + description: 对象ID + type: integer + objectType: + allOf: + - $ref: '#/definitions/pubdef.CommentObjectType' + description: 对象类型 0=视频 4=短剧 + pageNum: + type: integer + pageSize: + type: integer + reviewStatus: + type: integer + text: + description: 评论内容 + type: string + userName: + description: 用户名称 + type: string + type: object + srv_commend.CommendSearchResp: + properties: + list: + items: + $ref: '#/definitions/srv_commend.PostCommentInfo' + type: array + total: + type: integer + type: object + srv_commend.PostCommentInfo: + properties: + createdAt: + description: 创建时间 + type: string + id: + description: 评论ID + type: integer + ip: + description: 评论IP + type: string + level: + description: 评论级别 + type: integer + likes: + description: 点赞数 + type: integer + objectId: + description: 对象ID + type: integer + objectName: + description: 对象类型名称 视频/短剧 + type: string + objectType: + allOf: + - $ref: '#/definitions/pubdef.CommentObjectType' + description: 对象类型 0=视频 4=短剧 + parentsId: + description: 上级评论ID + type: integer + replyUserName: + description: 回复名称 + type: string + reviewStatus: + description: 审核状态 0 未审核 1审核成功 2 审核失败 + type: integer + status: + allOf: + - $ref: '#/definitions/pubdef.UserStatus' + description: 评论用户的状态 + text: + description: 评论内容 + type: string + topRank: + description: 置顶排序 + type: integer + updatedAt: + description: 更新时间 + type: string + userName: + description: 用户名称 + type: string + type: object + srv_contactus.DelOneContactusReq: + properties: + id: + type: string + type: object + srv_contactus.GetContactusListReq: + properties: + categoryName: + description: 分类 + type: string + email: + description: 邮箱 + type: string + ip: + description: ip + type: string + name: + description: 姓名 + type: string + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_contactus.GetContactusListResp: + properties: + list: + items: + $ref: '#/definitions/contactusmod.ContactUsModel' + type: array + total: + type: integer + type: object + srv_contactus.UpdateContactusReq: + properties: + id: + type: string + status: + type: integer + type: object + srv_detailnav.AddDetailNavReq: + properties: + name: + description: 导航名称 + type: string + sort: + description: 排序 + type: integer + status: + description: 是否启用 + type: boolean + url: + description: 导航链接 + type: string + required: + - name + - url + type: object + srv_detailnav.DelDetailNavReq: + properties: + id: + type: string + required: + - id + type: object + srv_detailnav.GetDetailNavListReq: + properties: + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_detailnav.GetDetailNavListResp: + properties: + list: + items: + $ref: '#/definitions/detailnavmod.DetailNavModel' + type: array + total: + type: integer + type: object + srv_detailnav.UpdateDetailNavParam: + properties: + name: + type: string + sort: + type: integer + status: + type: boolean + url: + type: string + type: object + srv_detailnav.UpdateDetailNavReq: + properties: + id: + type: string + param: + $ref: '#/definitions/srv_detailnav.UpdateDetailNavParam' + required: + - id + type: object + srv_hotnav.AddHotNavReq: + properties: + icon: + description: 图标 + type: string + intro: + description: 介绍 + type: string + sort: + description: 排序 + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题 + type: string + url: + description: 链接 + type: string + required: + - title + - url + type: object + srv_hotnav.DelHotNavReq: + properties: + id: + type: integer + required: + - id + type: object + srv_hotnav.GetHotNavListReq: + properties: + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_hotnav.GetHotNavListResp: + properties: + list: + items: + $ref: '#/definitions/hotnavmod.HotNav' + type: array + total: + type: integer + type: object + srv_hotnav.UpdateHotNavParam: + properties: + icon: + type: string + intro: + type: string + sort: + type: integer + status: + type: boolean + title: + type: string + url: + type: string + type: object + srv_hotnav.UpdateHotNavReq: + properties: + id: + type: integer + param: + $ref: '#/definitions/srv_hotnav.UpdateHotNavParam' + required: + - id + type: object + srv_ipblack.AddIPBlackListReq: + properties: + desc: + description: 备注 + type: string + ip: + type: string + type: object + srv_ipblack.DelIpBlackListReq: + properties: + ip: + type: string + type: object + srv_ipblack.GetIPBlackListReq: + properties: + ip: + type: string + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_ipblack.GetIPBlackListResp: + properties: + list: + items: + $ref: '#/definitions/ipblackmod.IPBlackModel' + type: array + total: + type: integer + type: object + srv_questionnaire.HandleReq: + properties: + id: + description: 问卷ID(ObjectID 字符串) + type: string + remark: + description: 备注(选填) + type: string + status: + description: 目标状态:processed 已处理 / reject 拒绝 + type: string + required: + - id + - status + type: object + srv_questionnaire.ListReq: + properties: + endTime: + description: 创建时间范围-结束,Unix 时间戳(秒,选填) + type: integer + pageNum: + type: integer + pageSize: + type: integer + questionType: + description: 模糊匹配:问题类型(选填) + type: string + startTime: + description: 创建时间范围-开始,Unix 时间戳(秒,选填) + type: integer + status: + description: 精确匹配:处理状态(选填) + type: string + type: object + srv_questionnaire.ListResp: + properties: + list: + description: 当前页数据 + items: + $ref: '#/definitions/questionnairemod.QuestionnaireModel' + type: array + total: + description: 满足条件的总条数 + type: integer + type: object + srv_richtextcontent.DelRichTextContentReq: + properties: + id: + type: integer + type: object + srv_richtextcontent.GetRichTextContentListReq: + properties: + pageNum: + type: integer + pageSize: + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题 + type: string + type: object + srv_richtextcontent.GetRichTextContentResp: + properties: + list: + items: + $ref: '#/definitions/richtextcontentmod.RichTextContentModel' + type: array + total: + type: integer + type: object + srv_richtextcontent.UpdateRichTextContentData: + properties: + _id: + type: integer + content: + description: 内容 + type: string + desc: + description: 标题(app) + type: string + location: + description: 位置 + items: + type: string + type: array + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + sort: + description: 排序值 + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题 + type: string + updatedAt: + type: string + type: object + srv_richtextcontent.UpdateRichTextContentReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_richtextcontent.UpdateRichTextContentData' + description: appId,唯一id + required: + - id + type: object + srv_translation.TranslationInfoDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_translation.TranslationInfoReq: + properties: + id: + description: 导航ID + type: integer + name: + description: 导航名称 + type: string + pageNum: + type: integer + pageSize: + type: integer + transCategoryId: + description: 导航分类ID + type: integer + type: object + srv_translation.TranslationInfoResp: + properties: + list: + items: + $ref: '#/definitions/translationmod.TranslationInfo' + type: array + total: + type: integer + type: object + srv_translation.TranslationInfoUpdate: + properties: + htmlId: + description: 富文本ID + type: integer + isFriendAds: + description: 是否是友链广告位 + type: boolean + link: + description: 外部跳转 + type: string + name: + description: 分类名称 + type: string + rank: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + transCategoryId: + type: integer + updatedAt: + type: string + type: object + srv_translation.TranslationInfoUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_translation.TranslationInfoUpdate' + description: appId,唯一id + required: + - id + type: object + srv_translationcategory.TranslationCategoryDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_translationcategory.TranslationCategoryReq: + properties: + id: + description: 导航分类ID + type: integer + name: + description: 导航分类名称 + type: string + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_translationcategory.TranslationCategoryResp: + properties: + list: + items: + $ref: '#/definitions/translationcategorymod.TranslationCategory' + type: array + total: + type: integer + type: object + srv_translationcategory.TranslationCategoryUpdate: + properties: + appId: + description: appId,唯一id + type: integer + categoryType: + description: 导航类型 1=url跳转 2=二级导航 3=底部导航 + type: integer + icon: + description: Icon + type: string + name: + description: 名称 + type: string + rank: + type: integer + status: + description: 上架状态 + type: boolean + updatedAt: + type: string + urlValue: + description: 跳转URL + type: string + type: object + srv_translationcategory.TranslationCategoryUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_translationcategory.TranslationCategoryUpdate' + description: appId,唯一id + required: + - id + type: object + srv_user.UserListReq: + properties: + id: + description: 用户ID + type: integer + nickName: + description: 昵称(模糊) + type: string + pageNum: + type: integer + pageSize: + type: integer + status: + allOf: + - $ref: '#/definitions/pubdef.UserStatus' + description: 状态 + type: object + srv_user.UserListResp: + properties: + list: + items: + $ref: '#/definitions/usermod.User' + type: array + total: + type: integer + type: object + srv_user.UserUpdateReq: + properties: + id: + type: integer + param: + $ref: '#/definitions/usermod.UserUpdate' + required: + - id + type: object + srv_videocategory.VideoCategoryDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videocategory.VideoCategoryReq: + properties: + displayPosition: + allOf: + - $ref: '#/definitions/pubdef.CategoryDisplayPosition' + description: 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + id: + description: 分类 ID + type: integer + name: + description: 分类名称(模糊匹配) + type: string + pageNum: + type: integer + pageSize: + type: integer + sortType: + allOf: + - $ref: '#/definitions/pubdef.CategorySortType' + description: 排序方式 + type: object + srv_videocategory.VideoCategoryResp: + properties: + list: + items: + $ref: '#/definitions/videocategorymod.VideoCategory' + type: array + total: + type: integer + type: object + srv_videocategory.VideoCategoryUpdate: + properties: + desc: + description: 描述 + type: string + displayMode: + allOf: + - $ref: '#/definitions/pubdef.DisplayMode' + description: 展示方式 + displayPosition: + allOf: + - $ref: '#/definitions/pubdef.CategoryDisplayPosition' + description: 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + indexNow: + type: integer + isReIndexNow: + description: 是否重新提交索引 + type: boolean + name: + description: 名称 + type: string + rank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sortType: + allOf: + - $ref: '#/definitions/pubdef.CategorySortType' + description: 排序方式 + status: + description: 上架状态 + type: boolean + tags: + description: 关联视频标签 + items: + type: integer + type: array + title: + description: APP显示 + type: string + translationCategoryId: + description: 导航分类 + type: integer + updatedAt: + type: string + type: object + srv_videocollection.CollectionMediaInfo: + properties: + coverImg: + description: 封面 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 详细描述 + type: string + id: + type: integer + nodes: + description: 节点 + items: + $ref: '#/definitions/videoinformationmod.Node' + type: array + rank: + description: 排序 + type: integer + remark: + description: 备注 + type: string + status: + allOf: + - $ref: '#/definitions/pubdef.InformationStatus' + description: 帖子上架状态 + title: + description: 帖子标题 + type: string + updatedAt: + description: 更新时间 + type: string + video: + allOf: + - $ref: '#/definitions/videoinformationmod.VideoInfo' + description: 视频 + type: object + srv_videocollection.GetCollectionMediaListReq: + properties: + collectionID: + type: integer + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_videocollection.GetCollectionMediaListResp: + properties: + list: + items: + $ref: '#/definitions/srv_videocollection.CollectionMediaInfo' + type: array + total: + type: integer + type: object + srv_videocollection.UpdateCollectionMediaRankReq: + properties: + collectionID: + description: 合集ID + type: integer + rank: + description: 排序 + type: integer + videoId: + description: 视频ID + type: integer + type: object + srv_videocollection.VideoCollectionOptionMediaReq: + properties: + collectionID: + description: 合集ID + type: integer + isAdd: + description: 增加还是移除 + type: boolean + videoIds: + description: 视频ID + items: + type: integer + type: array + type: object + srv_videocreater.VideoCreaterDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videocreater.VideoCreaterReq: + properties: + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_videocreater.VideoCreaterResp: + properties: + list: + items: + $ref: '#/definitions/videocreatermod.VideoCreaterInfo' + type: array + total: + type: integer + type: object + srv_videocreater.VideoCreaterUpdate: + properties: + appId: + description: appId,唯一id + type: integer + background: + description: 背景 + type: string + createType: + description: 创作者类型 0=普通 1=官方 + type: integer + desc: + description: 简介 + type: string + image: + description: 头像 + type: string + isReIndexNow: + description: 是否重新提交索引 + type: boolean + mediaTypes: + description: 作者关联的资源类型 1视频 + items: + type: integer + type: array + name: + description: 名称 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sort: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + updatedAt: + type: string + works: + description: 作品数量 + type: integer + type: object + srv_videoinfo.AddVideoCollectionReq: + properties: + coverImg: + description: 封面 + type: string + desc: + description: 详细描述 + type: string + firstTag: + description: 一级标签 + type: string + keyworld: + description: 关键字 + items: + type: string + type: array + remark: + description: 备注 + type: string + secondTags: + description: 二级标签标签 + items: + type: string + type: array + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 帖子标题 + type: string + type: object + srv_videoinfo.AddVideoReq: + properties: + fileId: + description: 资源id + items: + type: string + type: array + type: object + srv_videoinfo.SetFakeCountResp: + properties: + matchedCount: + description: 匹配到的视频数量 + type: integer + modifiedCount: + description: 实际修改的视频数量 + type: integer + type: object + srv_videoinfo.SetFakeLikesReq: + properties: + ids: + description: 视频ID集合,支持单个或批量 + items: + type: integer + type: array + likesMax: + description: 假点赞数随机最大值,包含该值 + type: integer + likesMin: + description: 假点赞数随机最小值,包含该值 + type: integer + type: object + srv_videoinfo.SetFakeWatchTimesReq: + properties: + ids: + description: 视频ID集合,支持单个或批量 + items: + type: integer + type: array + watchTimesMax: + description: 假观看次数随机最大值,包含该值 + type: integer + watchTimesMin: + description: 假观看次数随机最小值,包含该值 + type: integer + type: object + srv_videoinfo.SetRecommendReq: + properties: + ids: + description: 视频ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + type: object + srv_videoinfo.SyncMediaFileItem: + properties: + bango: + type: string + coverImg: + type: string + fieldNameFs: + type: string + firstTag: + type: string + height: + type: integer + id: + type: string + keyworld: + items: + type: string + type: array + publish: + type: string + secondTags: + items: + type: string + type: array + taskId: + type: string + title: + type: string + videoTime: + type: integer + width: + type: integer + type: object + srv_videoinfo.SyncMediaFileReq: + properties: + fileId: + description: 媒资库影片 ID + items: + type: string + type: array + firstTagMap: + additionalProperties: + type: string + description: 覆盖一级标签 + type: object + minVideoSeconds: + description: 最短时长,0 表示不限制 + type: integer + sourceVersion: + description: v1 / v2 + type: string + videoList: + description: 媒资库搜索结果,优先使用 + items: + $ref: '#/definitions/srv_videoinfo.SyncMediaFileItem' + type: array + type: object + srv_videoinfo.VideoAddTagsReq: + properties: + tags: + description: 统一标签ID集合 + items: + type: integer + type: array + videoIds: + description: 视频ID集合 + items: + type: integer + type: array + required: + - tags + - videoIds + type: object + srv_videoinfo.VideoInfoDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videoinfo.VideoInfoReq: + properties: + actressIds: + description: 女优ID + items: + type: integer + type: array + bango: + description: 番号, + type: string + categoryId: + description: 分类ID + type: integer + creatorId: + description: 作者Id + type: integer + creatorName: + description: 作者名称 + type: string + endTime: + description: 创建时间-截止 + type: string + id: + type: integer + isHot: + description: 是否热门 + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + isTop: + description: 是否置顶 + type: boolean + keyworld: + description: 关键字 + type: string + mediaType: + allOf: + - $ref: '#/definitions/videoinformationmod.MediaType' + description: 资源类型 + optionAccount: + description: 最后操作人 + type: string + pageNum: + type: integer + pageSize: + type: integer + sort: + description: 排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数。按分类查询且排序方式为排序值时,使用该分类的 categoryRanks 排序 + type: integer + startTime: + description: 创建时间-开始 + type: string + status: + allOf: + - $ref: '#/definitions/pubdef.InformationStatus' + description: 帖子上架状态 + tag: + description: 标签 + type: integer + title: + description: 标题 + type: string + videoFileId: + description: 切片id(切片列表的ID) + type: string + videoTaskId: + description: 视频任务id + type: string + type: object + srv_videoinfo.VideoInfoResp: + properties: + list: + items: + $ref: '#/definitions/videoinformationmod.VideoInformation' + type: array + total: + type: integer + type: object + srv_videoinfo.VideoInfoUpdate: + properties: + actressIds: + description: 女优ID + items: + type: integer + type: array + addedTime: + type: string + bango: + description: 番号,唯一 + type: string + categoryId: + description: 分类ID + type: integer + cover: + description: 视频封面 + type: string + coverImg: + description: 封面 + type: string + creatorId: + description: 作者Id + type: integer + creatorName: + description: 作者名 + type: string + desc: + description: 详细描述 + type: string + firstTag: + description: 一级标签 + type: string + height: + description: 分辨率高(视频才会存在) + type: integer + indexNow: + type: integer + isHot: + description: 是否热门 + type: boolean + isReIndexNow: + description: 是否重新提交索引 + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + isTop: + description: 是否置顶 + type: boolean + issuedAt: + description: 发行时间 + type: string + issuedBrand: + description: 发行商 + type: string + keyworld: + items: + type: string + type: array + mediaType: + allOf: + - $ref: '#/definitions/videoinformationmod.MediaType' + description: 资源类型 + nodes: + description: 节点 + items: + $ref: '#/definitions/videoinformationmod.Node' + type: array + remark: + description: 备注 + type: string + secondTags: + description: 二级标签标签 + items: + type: string + type: array + secondUpdatedAt: + description: 二次修改时间 + type: string + slug: + description: slug + type: string + sort: + description: 排序 + type: integer + status: + allOf: + - $ref: '#/definitions/pubdef.InformationStatus' + description: 帖子上架状态 + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 帖子标题 + type: string + unencryptedImage: + description: 不加密封面 + type: string + updatedAt: + description: 更新时间 + type: string + video: + allOf: + - $ref: '#/definitions/videoinformationmod.VideoInfo' + description: 视频 + videoTime: + description: 影片长度 + type: integer + width: + description: 分辨率宽(视频才会存在) + type: integer + type: object + srv_videoinfo.VideoInfoUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_videoinfo.VideoInfoUpdate' + description: appId,唯一id + required: + - id + type: object + srv_videoinfo.VideoSetTagsReq: + properties: + ids: + description: 视频ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + tagIds: + description: 标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误 + items: + type: integer + type: array + required: + - ids + type: object + srv_videotag.HotTagItem: + properties: + tagId: + type: integer + title: + type: string + watchTimes: + type: integer + type: object + srv_videotag.HotTagResp: + properties: + list: + items: + $ref: '#/definitions/srv_videotag.HotTagItem' + type: array + type: object + srv_videotag.IpStatReq: + properties: + todayIP: + type: integer + yesterdayIP: + type: integer + type: object + srv_videotag.VideoTagInfoDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videotag.VideoTagInfoReq: + properties: + id: + type: integer + pageNum: + type: integer + pageSize: + type: integer + title: + type: string + type: object + srv_videotag.VideoTagInfoResp: + properties: + list: + items: + $ref: '#/definitions/videotagmod.VideoTagInfo' + type: array + total: + type: integer + type: object + srv_videotag.VideoTagInfoUpdate: + properties: + appId: + description: appId,唯一id + type: integer + images: + description: 背景 + type: string + indexNow: + type: integer + isReIndexNow: + description: 是否重新提交索引 + type: boolean + mediaTypes: + description: 作者关联的资源类型 1视频 + items: + type: integer + type: array + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + sort: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + title: + description: 帖子标题 + type: string + updatedAt: + type: string + works: + description: 作品数量 + type: integer + type: object + translationcategorymod.TranslationCategory: + properties: + categoryType: + allOf: + - $ref: '#/definitions/pubdef.TranslationType' + description: 导航类型 1:url 2:二级导航 3:底部导航 + createdAt: + description: 创建时间 + type: string + icon: + description: Icon + type: string + id: + description: Id + type: integer + name: + description: 分类名称 + type: string + rank: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + updatedAt: + description: 更新时间 + type: string + urlValue: + description: 跳转URL + type: string + type: object + translationmod.TranslationInfo: + properties: + createdAt: + description: 创建时间 + type: string + htmlId: + description: 富文本ID + type: integer + id: + description: Id + type: integer + isFriendAds: + description: 是否是友链广告位 + type: boolean + link: + description: 外部跳转 + type: string + name: + description: 分类名称 + type: string + rank: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + transCategoryId: + description: 导航分类 + type: integer + updatedAt: + description: 更新时间 + type: string + type: object + usermod.User: + properties: + accountNo: + description: 账号 + type: string + age: + description: 年龄 + type: integer + agentInviteCode: + description: 被邀请的inviteCode + type: string + appVer: + description: app版本 + type: string + auth: + description: 是否认证 + type: boolean + avatar: + description: 头像 + type: string + backgroud: + description: 背景图 + type: string + consecutiveLogin: + description: 连续登陆天数 + type: integer + consumptionTotal: + description: 金币总消费数量 + type: integer + createdAt: + description: 创建时间 + type: string + currentVipExpire: + description: 当前vip过期时间 + type: string + devID: + description: 设备唯一标示 + type: string + devV2: + description: 设备唯一设备ID + type: string + device: + description: 设备 + type: string + districtCode: + description: 商区码 + type: string + gender: + description: 性别 0-未知,1-男,2-女 + type: integer + id: + description: ID + type: integer + introduction: + description: 简介 + type: string + inviteCode: + description: 邀请码 + type: string + invites: + description: 我邀请的人的个数(我邀请他人) + type: integer + isRegister: + description: 是否已注册 + type: boolean + lastIp: + description: 上次登陆IP + type: string + lastLoginAt: + description: 上次登陆时间 + type: string + loginType: + allOf: + - $ref: '#/definitions/pubdef.LoginType' + description: 登陆方式 + mac: + description: 设备唯一mac地址 + type: string + modifyUserNameAt: + description: 上一次修改用户时间 + type: string + nickName: + description: 昵称 + type: string + oriVer: + description: 注册时的app版本 + type: string + password: + description: 登陆密码 + type: string + recharge: + description: 当前充值余额,金币个数 + type: integer + rechargeTotal: + description: 累计充值额,金币个数 + type: integer + regIp: + description: 注册IP + type: string + region: + description: 地区 + type: string + registerTime: + type: string + score: + description: 积分 + type: integer + scoreInvites: + description: 邀请人获得的积分累计 + type: integer + scoreLvName: + description: 积分等级名称 + type: string + scoreTotal: + description: 累计积分 + type: integer + status: + allOf: + - $ref: '#/definitions/pubdef.UserStatus' + description: 状态 + sysMsgAt: + description: 获取系统消息时间 + type: string + sysType: + description: 系统类型 ios android + type: string + updatedAt: + description: 更新时间 + type: string + type: object + usermod.UserUpdate: + properties: + accountNo: + description: 账号 + type: string + age: + description: 年龄 + type: integer + agentInviteCode: + description: 被邀请的inviteCode + type: string + appVer: + description: app版本 + type: string + auth: + description: 是否认证 + type: boolean + avatar: + description: 头像 + type: string + backgroud: + description: 背景图 + type: string + consecutiveLogin: + description: 连续登陆天数 + type: integer + consumptionTotal: + description: 金币总消费数量 + type: integer + currentVipExpire: + description: 当前vip过期时间 + type: string + devID: + description: 设备唯一标示 + type: string + devV2: + description: 设备唯一设备ID + type: string + device: + description: 设备 + type: string + districtCode: + description: 商区码 + type: string + email: + description: 邮箱 + type: string + gender: + description: 性别 0-未知,1-男,2-女 + type: integer + introduction: + description: 简介 + type: string + inviteCode: + description: 邀请码 + type: string + invites: + description: 我邀请的人的个数(我邀请他人) + type: integer + isRegister: + description: 是否已注册 + type: boolean + lastIp: + description: 上次登陆IP + type: string + lastLoginAt: + description: 上次登陆时间 + type: string + loginType: + allOf: + - $ref: '#/definitions/pubdef.LoginType' + description: 登陆方式 + mac: + description: 设备唯一mac地址 + type: string + merchantExpiredAt: + description: 商家/兼职有效期 + type: string + modifyUserNameAt: + description: 上一次修改用户时间 + type: string + nickName: + description: 昵称 + type: string + oriVer: + description: 注册时的app版本 + type: string + password: + description: 登陆密码 + type: string + recharge: + description: 当前充值余额,金币个数 + type: integer + rechargeTotal: + description: 累计充值额,金币个数 + type: integer + regIp: + description: 注册IP + type: string + region: + description: 地区 + type: string + registerTime: + type: string + score: + description: 积分 + type: integer + scoreInvites: + description: 邀请人获得的积分累计 + type: integer + scoreLvName: + description: 积分等级名称 + type: string + scoreTotal: + description: 累计积分 + type: integer + showCardExpiredAt: + description: 最新会员卡过期时间 + type: string + showCardId: + description: 最新会员卡id + type: integer + showCardName: + description: 最新会员卡名称 + type: string + status: + allOf: + - $ref: '#/definitions/pubdef.UserStatus' + description: 状态 + sysMsgAt: + description: 获取系统消息时间 + type: string + sysType: + description: 系统类型 ios android + type: string + updatedAt: + description: 更新时间 + type: string + type: object + videocategorymod.VideoCategory: + properties: + createdAt: + description: 创建时间 + type: string + desc: + description: 描述 + type: string + displayMode: + allOf: + - $ref: '#/definitions/pubdef.DisplayMode' + description: 展示方式 0:默认 1:卡片层叠轮播 2:头像列表 3:图文列表 4:大封面列表 + displayPosition: + allOf: + - $ref: '#/definitions/pubdef.CategoryDisplayPosition' + description: 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + id: + description: Id + type: integer + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + name: + description: 分类名称 + type: string + rank: + description: 排序 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sortType: + allOf: + - $ref: '#/definitions/pubdef.CategorySortType' + description: 排序方式 + status: + description: 上架状态 + type: boolean + tags: + description: 关联视频标签 + items: + type: integer + type: array + title: + description: APP显示 + type: string + translationCategoryId: + description: 导航分类 + type: integer + updatedAt: + description: 更新时间 + type: string + type: object + videocreatermod.VideoCreaterInfo: + properties: + background: + description: 背景 + type: string + createType: + description: 创作者类型 0=普通 1=官方 + type: integer + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + id: + type: integer + image: + description: 头像 + type: string + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + mediaTypes: + description: 作者关联的资源类型 1视频 + items: + type: integer + type: array + name: + description: 帖子标题 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sort: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + updatedAt: + description: 更新时间 + type: string + works: + description: 作品数量 + type: integer + type: object + videoinformationmod.HevcStatus: + enum: + - 0 + - 1 + - 2 + - -1 + type: integer + x-enum-comments: + HevcStatusFailed: 转码失败 + HevcStatusNone: 未提交转码 + HevcStatusPending: 转码中 + HevcStatusSuccess: 转码成功 + x-enum-varnames: + - HevcStatusNone + - HevcStatusPending + - HevcStatusSuccess + - HevcStatusFailed + videoinformationmod.MediaType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + MediaType_AV: AV视频 + MediaType_Advitise: 广告 + MediaType_Collection: 合集 + MediaType_Video: 视频 + x-enum-varnames: + - MediaType_Video + - MediaType_Collection + - MediaType_Advitise + - MediaType_AV + videoinformationmod.Node: + properties: + images: + description: 图片 + items: + type: string + type: array + nodeId: + description: 节点ID + type: string + text: + description: 文本 + type: string + title: + description: 节点标题 + type: string + url: + description: 链接 + type: string + videos: + description: 视频信息列表 + items: + $ref: '#/definitions/videoinformationmod.VideoInfo' + type: array + type: object + videoinformationmod.VideoInfo: + properties: + cover: + description: 视频封面 + type: string + fileId: + description: 媒资库ID + type: string + h265Url: + description: H.265 m3u8 播放地址,空字符串表示暂无该编码源 + type: string + height: + description: 分辨率高(视频才会存在) + type: integer + preMp4FileNameFs: + description: 预览MP4文件 + type: string + taskId: + description: 视频ID(视频才会存在) + type: string + unencryptedPreMp4: + description: 不加密的预览MP4 + type: string + url: + description: H.264 m3u8 播放地址(始终存在) + type: string + videoTime: + description: 影片长度 + type: integer + width: + description: 分辨率宽(视频才会存在) + type: integer + type: object + videoinformationmod.VideoInformation: + properties: + actressIds: + description: 女优ID + items: + type: integer + type: array + addedTime: + description: 上架时间 + type: string + audioFile: + description: 音频文件路径 + type: string + bango: + description: 番号,唯一 + type: string + categoryId: + description: 分类ID + type: integer + coverImg: + description: 封面 + type: string + createdAt: + description: 创建时间 + type: string + creatorId: + description: 作者Id + type: integer + creatorName: + description: 作者名称 + type: string + desc: + description: 详细描述 + type: string + fake: + allOf: + - $ref: '#/definitions/pubdef.FakeFlow' + description: 伪造流量 + firstTag: + description: 一级标签 + type: string + hevcFailCount: + description: 主视频转码累计失败次数 + type: integer + hevcMetaInfo: + allOf: + - $ref: '#/definitions/videoinformationmod.VideoMetaInfo' + description: H265 视频元信息 + hevcStatus: + allOf: + - $ref: '#/definitions/videoinformationmod.HevcStatus' + description: 主视频 H265 转码状态 + id: + type: integer + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + isHot: + description: 是否热门 + type: boolean + isRecommend: + description: 是否推荐 true-推荐 false-不推荐 + type: boolean + isTop: + description: 是否置顶 + type: boolean + issuedAt: + description: 发行时间 + type: string + issuedBrand: + description: 发行商 + type: string + keyworld: + description: 关键字 + items: + type: string + type: array + mediaType: + allOf: + - $ref: '#/definitions/videoinformationmod.MediaType' + description: 资源类型 + nodes: + description: 节点 + items: + $ref: '#/definitions/videoinformationmod.Node' + type: array + optionAccount: + description: 最后操作人 + type: string + pageUpdatedAt: + description: 页面展示更新时间 + type: string + previewVtt: + description: 雪碧图 vtt + type: string + real: + allOf: + - $ref: '#/definitions/pubdef.Flow' + description: 真实流量 + realUpdateAt: + description: 播放相关数据更新时间 + type: string + remark: + description: 备注 + type: string + secondTags: + description: 二级标签标签 + items: + type: string + type: array + secondUpdatedAt: + description: 二次修改时间 + type: string + slug: + description: slug + type: string + smartCover: + description: 智能封面 + type: string + sort: + description: 排序 + type: integer + status: + allOf: + - $ref: '#/definitions/pubdef.InformationStatus' + description: 帖子上架状态 + syncId: + description: 同步ID + type: string + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 标题 + type: string + unencryptedImage: + description: 不加密封面 + type: string + updatedAt: + description: 更新时间 + type: string + uploadUserName: + description: 上传用户名 + type: string + video: + allOf: + - $ref: '#/definitions/videoinformationmod.VideoInfo' + description: 视频 + videoCount: + description: 合集视频数量 + type: integer + type: object + videoinformationmod.VideoMetaInfo: + properties: + audioBitrate: + type: integer + audioChannels: + type: integer + audioCodec: + type: string + audioSampleRate: + type: integer + bitrate: + type: integer + codec: + type: string + duration: + type: number + fps: + type: number + height: + type: integer + m3u8Url: + type: string + pixFmt: + type: string + profile: + type: string + size: + type: integer + videoBitrate: + type: integer + width: + type: integer + type: object + videotagmod.VideoTagInfo: + properties: + createdAt: + description: 创建时间 + type: string + id: + type: integer + images: + description: 背景 + type: string + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + isShowHome: + description: 是否显示在首页 + type: boolean + mediaTypes: + description: 作者关联的资源类型 1视频 + items: + type: integer + type: array + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + sort: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + title: + description: 帖子标题 + type: string + updatedAt: + description: 更新时间 + type: string + watchTimes: + description: 预计算:该标签下所有视频的累计播放量 + type: integer + works: + description: 作品数量 + type: integer + type: object + web_acc.AccOpLogReq: + properties: + actId: + description: 操作人Id + type: integer + action: + description: 操作目的 + type: string + endTime: + description: 开始时间 + type: string + pageNum: + description: 页码 + type: integer + pageSize: + type: integer + path: + description: 请求接口 + type: string + startTime: + description: 开始时间 + type: string + type: object + web_acc.AccOpLogResp: + properties: + list: + items: + $ref: '#/definitions/srv_acc.OpLogInfo' + type: array + total: + type: integer + type: object + web_acc.BindQurlReq: + properties: + acc: + description: 账户 + type: string + type: object + web_acc.BindQurlResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MainWebLoginReq: + properties: + acc: + description: 账户 + type: string + code: + description: 验证码 + type: string + pwd: + description: 密码 + type: string + type: object + web_acc.MainWebLoginResp: + properties: + acc: + description: 账户 + type: string + accId: + description: 账户id + type: integer + qurl: + description: 二维码 + items: + type: integer + type: array + roleId: + description: 角色id + type: integer + token: + description: token + type: string + type: object + web_acc.MwAccAddReq: + properties: + acc: + description: 账户 + type: string + email: + description: 邮箱 + type: string + isEnb: + description: 使能 + type: boolean + limitIP: + description: 允许访问的IP + items: + type: string + type: array + name: + description: 名称 + type: string + pwd: + description: 密码 + type: string + roleId: + description: 角色ID + type: integer + type: object + web_acc.MwAccAddResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwAccDelReq: + properties: + id: + description: 账号Id + type: integer + type: object + web_acc.MwAccDelResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwAccGetInfoReq: + properties: + id: + description: 角色id + type: integer + type: object + web_acc.MwAccGetInfoResp: + properties: + acc: + description: 账号 + type: string + email: + description: 邮箱 + type: string + id: + description: 账号Id + type: integer + isEnb: + description: 使能 + type: boolean + limitIP: + description: 允许访问的IP + items: + type: string + type: array + pwd: + description: 密码 + type: string + roleId: + description: 角色Id + type: integer + userName: + description: 用户名 + type: string + type: object + web_acc.MwAccGetStatusReq: + properties: + id: + description: 角色id + type: integer + type: object + web_acc.MwAccGetStatusResp: + properties: + feedBack: + description: 禁用原因 + type: string + id: + description: 账号Id + type: integer + isEnb: + description: 使能 + type: boolean + userName: + description: 用户名 + type: string + type: object + web_acc.MwAccListReq: + properties: + keyWords: + description: 搜索条件 + type: string + pageNum: + type: integer + pageSize: + type: integer + status: + description: 状态 1正常 2 禁用 + type: integer + type: object + web_acc.MwAccListResp: + properties: + accRec: + description: 当前页配置记录 + items: + $ref: '#/definitions/web_acc.MwAccRec' + type: array + totalCount: + description: 总共数量 + type: integer + type: object + web_acc.MwAccRec: + properties: + acc: + description: 账号 + type: string + createdAt: + description: 创建时间 + type: integer + email: + description: 邮箱 + type: string + feedBack: + description: 禁用原因 + type: string + id: + description: 账号Id + type: integer + isBase32: + description: |- + // 二步签名 + Base32 string `json:"base32"` + 是否已签名 + type: boolean + isEnb: + description: 使能 + type: boolean + limitIP: + description: 允许访问的IP + items: + type: string + type: array + pwd: + description: 密码 + type: string + roleId: + description: 角色Id + type: integer + roleName: + description: 角色名 + type: string + updatedAt: + description: 更新时间 + type: integer + userName: + description: 用户名 + type: string + type: object + web_acc.MwAccSaveStatusReq: + properties: + feedBack: + description: 禁用原因 + type: string + id: + description: 账号Id + type: integer + isEnb: + description: 使能 + type: boolean + userName: + description: 用户名 + type: string + type: object + web_acc.MwAccSaveStatusResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwAccUpPwdReq: + properties: + id: + description: 账号Id + type: integer + pwd: + description: 密码 + type: string + required: + - id + type: object + web_acc.MwAccUpReq: + properties: + acc: + description: 账号 + type: string + email: + description: 邮箱 + type: string + id: + description: 账号Id + type: integer + isEnb: + description: 使能 + type: boolean + limitIP: + description: 允许访问的IP + items: + type: string + type: array + pwd: + description: 密码 + type: string + roleId: + description: 角色Id + type: integer + userName: + description: 用户名 + type: string + type: object + web_acc.MwAccUpResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwRoleAddReq: + properties: + desc: + description: 描述 + type: string + isEnb: + description: 状态 + type: boolean + name: + description: 名称 + type: string + type: object + web_acc.MwRoleAddResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwRoleDelReq: + properties: + id: + description: 角色id + type: integer + type: object + web_acc.MwRoleDelResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwRoleDrop: + properties: + id: + description: 角色Id + type: integer + name: + description: 角色名称 + type: string + type: object + web_acc.MwRoleDropResp: + properties: + roleDrop: + description: 是否成功 true:成功 false:未成功 + items: + $ref: '#/definitions/web_acc.MwRoleDrop' + type: array + type: object + web_acc.MwRoleEnableReq: + properties: + id: + description: 角色id + type: integer + isEnb: + description: 状态 + type: boolean + type: object + web_acc.MwRoleEnableResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwRoleGetAuthReq: + properties: + id: + description: 角色id + type: integer + type: object + web_acc.MwRoleGetAuthResp: + properties: + id: + description: 角色id + type: integer + router: + items: + $ref: '#/definitions/web_acc.Router' + type: array + type: object + web_acc.MwRoleGetInfoReq: + properties: + id: + description: 角色id + type: integer + type: object + web_acc.MwRoleGetInfoResp: + properties: + roleRec: + $ref: '#/definitions/web_acc.MwRoleRec' + type: object + web_acc.MwRoleListResp: + properties: + roleRecs: + description: 当前页配置记录 + items: + $ref: '#/definitions/web_acc.MwRoleRec' + type: array + totalCount: + description: 总共数量 + type: integer + type: object + web_acc.MwRoleRec: + properties: + apiMenus: + description: 已有权限的api + items: + type: string + type: array + createdAt: + description: 创建时间 + type: string + desc: + description: 描述 + type: string + id: + description: 角色Id + type: integer + isEnb: + description: 是否启用 false:禁用 true:启用 + type: boolean + name: + description: 角色名称 + type: string + updatedAt: + description: 更新时间 + type: string + type: object + web_acc.MwRoleUpAuthReq: + properties: + id: + description: 角色id + type: integer + router: + items: + $ref: '#/definitions/web_acc.Router' + type: array + type: object + web_acc.MwRoleUpAuthResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.MwRoleUpReq: + properties: + desc: + description: 描述 + type: string + id: + description: 角色id + type: integer + isEnb: + description: 状态 + type: boolean + name: + description: 名称 + type: string + type: object + web_acc.MwRoleUpResp: + properties: + result: + description: 是否成功 true:成功 false:未成功 + type: boolean + type: object + web_acc.Router: + properties: + apiUrl: + items: + $ref: '#/definitions/web_acc.Router_ApiUrl' + type: array + children: + items: + $ref: '#/definitions/web_acc.Router' + type: array + component: + type: string + hidden: + type: boolean + isShow: + type: boolean + meta: + $ref: '#/definitions/web_acc.Router_Meta' + name: + type: string + path: + type: string + redirect: + type: string + type: object + web_acc.Router_ApiUrl: + properties: + desc: + type: string + method: + items: + type: string + type: array + status: + type: boolean + url: + items: + type: string + type: array + type: object + web_acc.Router_Meta: + properties: + icon: + type: string + noCache: + type: boolean + title: + type: string + type: object + web_cfg.GetSysConfigReq: + type: object + web_cfg.GetSysConfigResp: + properties: + appAddr: + description: APP下载地址(落地页) + type: string + channelCode: + description: 渠道码 + type: string + dhDescription: + description: 导航SEO描述 + type: string + dhKeyworld: + description: 导航SEO关键字 + items: + type: string + type: array + dhNewUrlAddr1: + description: 导航页-线路1 + type: string + dhNewUrlAddr2: + description: 导航页-线路2 + type: string + dhNewUrlAddr3: + description: 导航页-线路3 + type: string + dhSiteName: + description: 导航SEO网站名称 + type: string + dhTitle: + description: 导航SEO标题 + type: string + downloadVideoUrl: + description: 下载视频URL + type: string + enableH265: + description: 是否允许客户端使用 H265 地址播放 + type: boolean + foreverUrlAddr: + description: 永久地址 + type: string + freeVPN: + description: 免费vpn链接 + type: string + gitAddr: + description: git地址 + type: string + gmailAddr: + description: 邮箱地址 + type: string + id: + type: integer + imgAppCdn: + description: 图片CDN(app使用) + type: string + imgAppUsCdn: + description: 图片CDN(海外app使用) + type: string + imgWebCdn: + description: 图片CDN(web使用) + type: string + m3u8DayRateLimitMax: + description: 每 IP 每天最多不同正片数,<=0 使用默认 80 + type: integer + mediaAppCdn: + description: 视频CDN(app使用) + type: string + mediaAppUsCdn: + description: 视频CDN(海外app使用) + type: string + mediaWebCdn: + description: 视频CDN(web使用) + type: string + mp4Addr: + description: mp4地址(web使用) + type: string + newUrlAddr: + description: 网站管理 + type: string + newUrlAddr1: + description: 最新地址(备用地址1) + type: string + newUrlAddr2: + description: 最新地址(备用地址2) + type: string + pdfAddr: + description: 回家的路PDF + type: string + potaoGroup: + description: 土豆群 + type: string + qq: + description: qq群 + type: string + sitemapDefaultImages: + description: Sitemap里面默认封面图 + items: + type: string + type: array + telegram: + description: 商务tg + type: string + telegramAdd: + description: 加入我们tg + type: string + tgGroup: + description: tg群 + type: string + translationAddr: + description: 应用导航 + type: string + twitterAddr: + description: 推特地址 + type: string + unencryptedCdn: + description: 不加密资源使用的cdn + type: string + useAddress: + description: 所有使用中的域名 + items: + type: string + type: array + version: + description: 版本号,用于更新乐观锁 + type: integer + watermarkImage: + description: 图片水印文件 + type: string + watermarkMedia: + description: 视频水印文件(底部右边) + type: string + watermarkMediaBottomLeft: + description: 视频水印文件(底部左边) + type: string + watermarkMediaTop: + description: 视频水印文件(顶部左边) + type: string + watermarkMediaTopRight: + description: 视频水印文件(顶部右边) + type: string + webAddr: + description: 网站SEO网站地址 + type: string + webDescription: + description: 网站SEO描述 + type: string + webKeyworld: + description: 网站SEO关键字 + items: + type: string + type: array + webSiteName: + description: 网站SEO网站名称 + type: string + webTitle: + description: SEO + type: string + weiChart: + description: 微信群 + type: string + type: object + web_cfg.SysConfigUpdateReq: + properties: + appAddr: + description: APP下载地址(落地页) + type: string + channelCode: + description: 渠道码 + type: string + dhDescription: + description: 导航SEO描述 + type: string + dhKeyworld: + description: 导航SEO关键字 + items: + type: string + type: array + dhNewUrlAddr1: + description: 导航页-线路一 + type: string + dhNewUrlAddr2: + description: 导航页-线路2 + type: string + dhNewUrlAddr3: + description: 导航页-线路3 + type: string + dhSiteName: + description: 导航SEO网站名称 + type: string + dhTitle: + description: 导航SEO标题 + type: string + downloadVideoUrl: + description: 下载视频URL + type: string + enableH265: + description: 是否允许客户端使用 H265 地址播放 + type: boolean + foreverUrlAddr: + description: 永久地址 + type: string + gitAddr: + description: git地址 + type: string + gmailAddr: + description: 邮箱地址 + type: string + imgAppCdn: + description: 图片CDN(app使用) + type: string + imgAppUsCdn: + description: 图片CDN(海外app使用) + type: string + imgWebCdn: + description: 图片CDN(web使用) + type: string + m3u8DayRateLimitMax: + description: 每 IP 每天最多不同正片数,<=0 使用默认 80 + type: integer + mediaAppCdn: + description: 视频CDN(app使用) + type: string + mediaAppUsCdn: + description: 视频CDN(海外app使用) + type: string + mediaWebCdn: + description: 视频CDN(web使用) + type: string + mp4Addr: + description: mp4地址(web使用) + type: string + newUrlAddr: + description: 最新地址 + type: string + newUrlAddr1: + description: 最新地址(备用地址1) + type: string + newUrlAddr2: + description: 最新地址(备用地址2) + type: string + pdfAddr: + description: 回家的路PDF + type: string + potaoGroup: + description: 土豆群 + type: string + qq: + description: qq群 + type: string + sitemapDefaultImages: + description: Sitemap里面默认封面图 + items: + type: string + type: array + telegram: + description: 商务tg + type: string + telegramAdd: + description: 加入我们tg + type: string + tgGroup: + description: tg群 + type: string + translationAddr: + description: 应用导航 + type: string + twitterAddr: + description: 推特地址 + type: string + unencryptedCdn: + description: 不加密资源使用的cdn + type: string + watermarkImage: + description: 图片水印文件 + type: string + watermarkMedia: + description: 视频水印文件(底部右边) + type: string + watermarkMediaBottomLeft: + description: 视频水印文件(底部左边) + type: string + watermarkMediaTop: + description: 视频水印文件(顶部左边) + type: string + watermarkMediaTopRight: + description: 视频水印文件(顶部右边) + type: string + webAddr: + description: 网站SEO网站地址 + type: string + webDescription: + description: 网站SEO描述 + type: string + webKeyworld: + description: 网站SEO关键字 + items: + type: string + type: array + webSiteName: + description: 网站SEO网站名称 + type: string + webTitle: + description: SEO + type: string + weiChart: + description: 微信群 + type: string + type: object + web_cfg.SysConfigUpdateResp: + type: object + web_commend.CommendDelIpReq: + properties: + ip: + type: string + type: object + web_commend.CommendDelReq: + properties: + id: + type: integer + required: + - id + type: object + web_commend.CommendReviewBatchReq: + properties: + ids: + items: + type: integer + type: array + reviewStatus: + type: integer + type: object + web_file.UploadImageResp: + properties: + path: + type: string + type: object + web_file.UploadImagesResp: + properties: + paths: + items: + type: string + type: array + type: object +host: localhost:12667 +info: + contact: {} + description: 这是站群后台接口服务. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: 站群后台接口 + version: "1.0" +paths: + /api/web/acc/add: + post: + consumes: + - application/json + description: 账号-添加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccAddResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-添加 + tags: + - 账号 + /api/web/acc/del: + post: + consumes: + - application/json + description: 账号-删除 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccDelResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-删除 + tags: + - 账号 + /api/web/acc/getinfo: + get: + consumes: + - application/json + description: 账号-获取信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccGetInfoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccGetInfoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-获取信息 + tags: + - 账号 + /api/web/acc/getstatus: + get: + consumes: + - application/json + description: 账号-获取状态 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccGetStatusReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccGetStatusResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-获取状态 + tags: + - 账号 + /api/web/acc/list: + get: + consumes: + - application/json + description: 账号-列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-列表 + tags: + - 账号 + /api/web/acc/ops: + post: + consumes: + - application/json + description: 账号-获取操作记录 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.AccOpLogReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.AccOpLogResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-获取操作记录 + tags: + - 账号 + /api/web/acc/savestatus: + post: + consumes: + - application/json + description: 账号-保存状态 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccSaveStatusReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccSaveStatusResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-保存状态 + tags: + - 账号 + /api/web/acc/up: + post: + consumes: + - application/json + description: 账号-更新 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccUpReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccUpResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-更新 + tags: + - 账号 + /api/web/acc/uppwd: + post: + consumes: + - application/json + description: 账号-更新密码 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwAccUpPwdReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwAccUpPwdReq' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 账号-更新密码 + tags: + - 账号 + /api/web/actress/add: + post: + consumes: + - application/json + description: 添加一个女优信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_actress.ActressAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个女优信息 + tags: + - 女优管理 + /api/web/actress/del: + post: + consumes: + - application/json + description: 删除一个女优信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_actress.ActressDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个女优信息 + tags: + - 女优管理 + /api/web/actress/list: + get: + consumes: + - application/json + description: 分组分页获取女优信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_actress.ActressListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_actress.ActressListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取女优信息 + tags: + - 女优管理 + /api/web/actress/update: + post: + consumes: + - application/json + description: 更新一个女优信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_actress.ActressUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个女优信息 + tags: + - 女优管理 + /api/web/contactus/del: + post: + consumes: + - application/json + description: 删除一个联系我们 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_contactus.DelOneContactusReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 删除一个联系我们 + tags: + - 联系我们 + /api/web/contactus/list: + post: + consumes: + - application/json + description: 联系我们列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_contactus.GetContactusListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_contactus.GetContactusListResp' + "400": + description: 失败 + schema: + type: string + summary: 联系我们列表 + tags: + - 联系我们 + /api/web/contactus/update: + post: + consumes: + - application/json + description: 修改联系我们状态 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_contactus.UpdateContactusReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 修改联系我们状态 + tags: + - 联系我们 + /api/web/content/tag/add: + post: + consumes: + - application/json + parameters: + - description: 新增统一标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.ContentTagAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.ContentTagAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 新增统一标签 -- by 王鸣 + tags: + - web统一标签相关接口 + /api/web/content/tag/del: + post: + consumes: + - application/json + parameters: + - description: 删除统一标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.ContentTagDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除统一标签 -- by 王鸣 + tags: + - web统一标签相关接口 + /api/web/content/tag/list: + get: + consumes: + - application/json + description: 分页查询统一标签配置 + parameters: + - description: 标签ID + in: query + name: id + type: integer + - description: 标签名称,支持模糊查询 + in: query + name: name + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 状态 true-启用 false-停用 + in: query + name: status + type: boolean + - description: 内容类型 1-视频 5-短剧 + enum: + - 1 + - 5 + in: query + name: type + type: integer + x-enum-comments: + ContentTagTypePlaylet: 短剧 + ContentTagTypeVideo: 视频 + x-enum-varnames: + - ContentTagTypeVideo + - ContentTagTypePlaylet + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.ContentTagListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 统一标签列表 -- by 王鸣 + tags: + - web统一标签相关接口 + /api/web/content/tag/option: + get: + consumes: + - application/json + description: 查询启用的统一标签下拉列表,传type时按支持类型过滤 + parameters: + - description: 内容类型 1-视频 5-短剧,不传返回全部启用标签 + enum: + - 1 + - 5 + in: query + name: type + type: integer + x-enum-comments: + ContentTagTypePlaylet: 短剧 + ContentTagTypeVideo: 视频 + x-enum-varnames: + - ContentTagTypeVideo + - ContentTagTypePlaylet + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.ContentTagOptionResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 统一标签下拉列表 -- by 王鸣 + tags: + - web统一标签相关接口 + /api/web/content/tag/update: + post: + consumes: + - application/json + parameters: + - description: 更新统一标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.ContentTagUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新统一标签 -- by 王鸣 + tags: + - web统一标签相关接口 + /api/web/detailnav/add: + post: + consumes: + - application/json + description: 详情页导航配置-增加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_detailnav.AddDetailNavReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 详情页导航配置-增加 + tags: + - 详情页导航配置 + /api/web/detailnav/del: + post: + consumes: + - application/json + description: 详情页导航配置-删除 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_detailnav.DelDetailNavReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 详情页导航配置-删除 + tags: + - 详情页导航配置 + /api/web/detailnav/list: + post: + consumes: + - application/json + description: 详情页导航配置-列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_detailnav.GetDetailNavListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_detailnav.GetDetailNavListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 详情页导航配置-列表 + tags: + - 详情页导航配置 + /api/web/detailnav/update: + post: + consumes: + - application/json + description: 详情页导航配置-更新 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_detailnav.UpdateDetailNavReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 详情页导航配置-更新 + tags: + - 详情页导航配置 + /api/web/file/uploadImg: + post: + consumes: + - application/json + description: 图片上传 + parameters: + - description: 文件 + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_file.UploadImageResp' + "400": + description: 失败 + schema: + type: string + summary: 图片上传 + tags: + - 文件上传 + /api/web/file/uploadImgs: + post: + consumes: + - application/json + description: 图片批量上传 + parameters: + - description: 文件 + in: formData + name: file[] + required: true + type: file + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_file.UploadImagesResp' + "400": + description: 失败 + schema: + type: string + summary: 图片批量上传 + tags: + - 文件上传 + /api/web/home/banner/add: + post: + consumes: + - application/json + parameters: + - description: 新增首页banner请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.HomeBannerAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeBannerAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 新增首页banner -- by 王鸣 + tags: + - web首页banner相关接口 + /api/web/home/banner/del: + post: + consumes: + - application/json + parameters: + - description: 删除首页banner请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.HomeBannerDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除首页banner -- by 王鸣 + tags: + - web首页banner相关接口 + /api/web/home/banner/list: + get: + consumes: + - application/json + description: 分页查询首页banner配置 + parameters: + - description: 首页 banner ID + in: query + name: id + type: integer + - description: 名称,支持模糊查询 + in: query + name: name + type: string + - description: 页码,默认1 + in: query + name: pageNum + type: integer + - description: 每页数量,默认10,最大100 + in: query + name: pageSize + type: integer + - description: 状态 true-启用 false-停用 + in: query + name: status + type: boolean + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeBannerListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 首页banner列表 -- by 王鸣 + tags: + - web首页banner相关接口 + /api/web/home/banner/update: + post: + consumes: + - application/json + parameters: + - description: 更新首页banner请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.HomeBannerUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新首页banner -- by 王鸣 + tags: + - web首页banner相关接口 + /api/web/hotnav/add: + post: + consumes: + - application/json + description: 热门导航配置-增加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_hotnav.AddHotNavReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 热门导航配置-增加 + tags: + - 热门导航配置 + /api/web/hotnav/del: + post: + consumes: + - application/json + description: 热门导航配置-删除 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_hotnav.DelHotNavReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 热门导航配置-删除 + tags: + - 热门导航配置 + /api/web/hotnav/list: + post: + consumes: + - application/json + description: 热门导航配置-列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_hotnav.GetHotNavListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_hotnav.GetHotNavListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 热门导航配置-列表 + tags: + - 热门导航配置 + /api/web/hotnav/update: + post: + consumes: + - application/json + description: 热门导航配置-更新 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_hotnav.UpdateHotNavReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 热门导航配置-更新 + tags: + - 热门导航配置 + /api/web/ipblack/add: + post: + consumes: + - application/json + description: IP黑名单-增加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_ipblack.AddIPBlackListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: IP黑名单-增加 + tags: + - IP黑名单 + /api/web/ipblack/del: + post: + consumes: + - application/json + description: IP黑名单-删除 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_ipblack.DelIpBlackListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: IP黑名单-删除 + tags: + - IP黑名单 + /api/web/ipblack/list: + post: + consumes: + - application/json + description: IP黑名单-列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_ipblack.GetIPBlackListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_ipblack.GetIPBlackListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: IP黑名单-列表 + tags: + - IP黑名单 + /api/web/login/acc: + post: + consumes: + - application/json + description: 登录-登录 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MainWebLoginReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MainWebLoginResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 登录-登录 + tags: + - 登录 + /api/web/login/bind: + post: + consumes: + - application/json + description: 登录-绑定二维码 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.BindQurlReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.BindQurlResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 登录-绑定二维码 + tags: + - 登录 + /api/web/login/slogin: + post: + consumes: + - multipart/form-data + - application/json + description: 管理员sso登陆 + parameters: + - description: 账号 + in: formData + name: name + required: true + type: string + - description: 密码 + in: formData + name: password + required: true + type: string + produces: + - application/json + - text/html + responses: + "200": + description: '{"msg": "管理员信息""}' + schema: + type: string + "400": + description: '{"msg": "操作失败"}' + schema: + type: string + summary: 管理员sso登陆 + tags: + - web-单点登陆 + /api/web/playlet/add: + post: + consumes: + - application/json + description: 新增 playlet 集合短剧数据,支持分类、题材、标签、支付方式、推荐和 banner 配置 + parameters: + - description: 新增短剧请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端新增短剧 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/banner/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 showOnBanner 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧是否显示在首页banner请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletBannerSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧是否显示在首页banner -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/category/add: + post: + consumes: + - application/json + parameters: + - description: 新增短剧分类请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletCategoryAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletCategoryAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端新增短剧分类 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/category/del: + post: + consumes: + - application/json + parameters: + - description: 删除短剧分类请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletCategoryDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端删除短剧分类 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/category/list: + get: + consumes: + - application/json + description: 分页查询短剧分类 + parameters: + - description: 分类ID + in: query + name: id + type: integer + - description: 分类名称,支持模糊查询 + in: query + name: name + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 启用状态 + in: query + name: status + type: boolean + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletCategoryListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧分类列表 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/category/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 categoryIds 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧分类请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletCategorySetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧分类 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/category/update: + post: + consumes: + - application/json + parameters: + - description: 更新短剧分类请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletCategoryUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端更新短剧分类 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/add: + post: + consumes: + - application/json + description: 新增 playlet_chapter 集合短剧剧集数据 + parameters: + - description: 新增短剧剧集请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端新增短剧剧集 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/del: + post: + consumes: + - application/json + description: 根据剧集ID集合删除 playlet_chapter 记录,仅原创短剧支持删除 + parameters: + - description: 单/批量删除短剧剧集请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量删除短剧剧集 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/list: + get: + consumes: + - application/json + description: 分页查询 playlet_chapter 集合短剧剧集数据,默认按集数升序返回 + parameters: + - description: 剧集ID + in: query + name: id + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 短剧ID + in: query + name: playletId + type: integer + - description: 水印状态 0-未处理 1-已登记 2-处理中 3-已完成 4-处理失败 + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + in: query + name: watermarkStatus + type: integer + x-enum-comments: + WatermarkStatusDone: 已完成 + WatermarkStatusFailed: 处理失败 + WatermarkStatusPending: 未处理(待 mediafile/sync) + WatermarkStatusProcessing: 处理中(已提交加水印任务,待回写) + WatermarkStatusRegistered: 已登记(原片已登记,待 task/add) + x-enum-varnames: + - WatermarkStatusPending + - WatermarkStatusRegistered + - WatermarkStatusProcessing + - WatermarkStatusDone + - WatermarkStatusFailed + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧剧集列表 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/pay-type/set: + post: + consumes: + - application/json + description: 根据剧集ID集合批量更新 playlet_chapter 集合 payType 和 singleCoins 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧剧集支付方式请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterPayTypeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧剧集支付方式 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/sync: + post: + consumes: + - application/json + description: 根据短剧ID集合从媒资中心同步新增剧集,playletIds传1个表示单条同步,传多个表示批量同步,允许已完结短剧补集或修正状态 + parameters: + - description: 手动单/批量同步短剧剧集请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterSyncReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterSyncResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端手动单/批量同步短剧剧集 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/update: + post: + consumes: + - application/json + description: 按剧集ID编辑 playlet_chapter 集合短剧剧集数据,只更新请求中传入的字段 + parameters: + - description: 编辑短剧剧集请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端编辑短剧剧集 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/featured/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 isFeatured 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧是否爆剧请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletFeaturedSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧是否爆剧 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/like/virtual/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 virtualCountLike 字段,每条短剧从最小值和最大值之间随机取不重复数值入库 + parameters: + - description: 单/批量随机设置短剧虚拟点赞数请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletVirtualLikeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧虚拟点赞数 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/list: + get: + consumes: + - application/json + description: 分页查询短剧列表 + parameters: + - description: 分类ID + in: query + name: categoryId + type: integer + - description: 内容类型,0-短剧、1-普通短剧/影集、2-漫画短剧、3-动漫、4-漫画 + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + in: query + name: contentType + type: integer + x-enum-comments: + ContentTypeAlbum: 普通短剧/影集(对应 media_type=6) + ContentTypeAnime: 动漫(对应 media_type=1) + ContentTypeComic: 漫画短剧(对应 media_type=4) + ContentTypeManga: 漫画(对应 media_type=2) + ContentTypeTheater: 短剧(对应 media_type=5) + x-enum-varnames: + - ContentTypeTheater + - ContentTypeAlbum + - ContentTypeComic + - ContentTypeAnime + - ContentTypeManga + - description: 短剧ID + in: query + name: id + type: integer + - description: 是否爆剧,true-是、false-否 + in: query + name: isFeatured + type: boolean + - description: 是否原创:true只查原创,false只查非原创,不传查全部 + in: query + name: isOriginal + type: boolean + - description: 是否推荐,true-推荐、false-不推荐 + in: query + name: isRecommend + type: boolean + - description: 最后操作人名称,支持模糊查询 + in: query + name: operatorName + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 支付方式,0-免费、1-金币、2-VIP + enum: + - 0 + - 1 + - 2 + in: query + name: payType + type: integer + x-enum-comments: + PayTypeCoin: 金币 + PayTypeFree: 免费 + PayTypeVip: VIP + x-enum-varnames: + - PayTypeFree + - PayTypeCoin + - PayTypeVip + - description: 是否显示在首页banner,true-显示、false-不显示 + in: query + name: showOnBanner + type: boolean + - description: 上架状态,0-下架、1-上架 + enum: + - 0 + - 1 + in: query + name: status + type: integer + x-enum-comments: + StatusDown: 下架 + StatusUp: 上架 + x-enum-varnames: + - StatusDown + - StatusUp + - description: 标签ID + in: query + name: tagId + type: integer + - description: 题材ID + in: query + name: themeId + type: integer + - description: 短剧标题,支持模糊查询 + in: query + name: title + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧列表 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/pay-type/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 payType/singleCoins 字段 + parameters: + - description: 单/批量设置短剧默认支付方式请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletPayTypeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧默认支付方式 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/recommend/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧是否推荐请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletRecommendSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧是否推荐 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/status/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 status 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧上下架请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletStatusSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧上下架 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/tag/add: + post: + consumes: + - application/json + parameters: + - description: 新增标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletTagAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletTagAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端新增标签 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/tag/del: + post: + consumes: + - application/json + parameters: + - description: 删除标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletTagDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端删除标签 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/tag/list: + get: + consumes: + - application/json + description: 分页查询短剧标签 + parameters: + - in: query + name: id + type: integer + - in: query + name: name + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - in: query + name: status + type: boolean + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletTagListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端标签列表 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/tag/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 tagIds 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletTagSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧标签 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/tag/update: + post: + consumes: + - application/json + parameters: + - description: 更新标签请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletTagUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端更新标签 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/theme/add: + post: + consumes: + - application/json + parameters: + - description: 新增题材请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletThemeAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletThemeAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端新增题材 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/theme/del: + post: + consumes: + - application/json + parameters: + - description: 删除题材请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletThemeDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端删除题材 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/theme/list: + get: + consumes: + - application/json + description: 分页查询短剧题材 + parameters: + - description: 题材ID + in: query + name: id + type: integer + - description: 题材名称,支持模糊查询 + in: query + name: name + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 上架状态 + in: query + name: status + type: boolean + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletThemeListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端题材列表 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/theme/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 单/批量设置短剧题材请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletThemeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧题材 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/theme/update: + post: + consumes: + - application/json + parameters: + - description: 更新题材请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletThemeUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端更新题材 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/update: + post: + consumes: + - application/json + description: 按短剧ID编辑 playlet 集合短剧数据,只更新请求中传入的字段 + parameters: + - description: 编辑短剧请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端编辑短剧 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/watermark/reset: + post: + consumes: + - application/json + description: 根据短剧ID集合重置剧集水印状态,保留已有水印地址以便换水印期间继续观看 + parameters: + - description: 批量重置短剧水印请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletWatermarkResetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端按短剧ID批量重置剧集水印 -- by 王鸣 + tags: + - web短剧相关接口 + /api/web/post/comment/del: + post: + consumes: + - application/json + description: 删除一个评论 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_commend.CommendDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 删除一个评论 + tags: + - 评论 + /api/web/post/comment/list: + post: + consumes: + - application/json + description: 评论列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_commend.CommendSearch' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_commend.CommendSearchResp' + "400": + description: 失败 + schema: + type: string + summary: 评论列表 + tags: + - 评论 + /api/web/post/comment/replay: + post: + consumes: + - application/json + description: 回复评论 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_commend.CommendReplay' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 回复评论 + tags: + - 评论 + /api/web/post/comment/review: + post: + consumes: + - application/json + description: 审核评论 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_commend.CommendReviewReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 审核评论 + tags: + - 评论 + /api/web/post/comment/reviewBatch: + post: + consumes: + - application/json + description: 批量审核评论 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_commend.CommendReviewBatchReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 批量审核评论 + tags: + - 评论 + /api/web/post/comment/user/del: + post: + consumes: + - application/json + description: 删除一个用户所有评论 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_commend.CommendDelIpReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 删除一个用户所有评论 + tags: + - 评论 + /api/web/questionnaire/handle: + post: + consumes: + - application/json + description: 只有状态为"待处理"的问卷才允许更新 + parameters: + - description: 处理参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_questionnaire.HandleReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + summary: 处理问卷 + tags: + - 问卷调查 + /api/web/questionnaire/list: + post: + consumes: + - application/json + description: 支持分页及条件筛选(question_type、status、时间范围) + parameters: + - description: 查询参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_questionnaire.ListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_questionnaire.ListResp' + "400": + description: 失败 + schema: + type: string + summary: 查询问卷列表 + tags: + - 问卷调查 + /api/web/resource/chapters: + get: + consumes: + - application/json + description: 按媒资ID查询剧集信息,复用入库时的 mediaSync 接口 + parameters: + - description: 媒资中心资源ID + in: query + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletResourceChapterListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧媒资剧集列表 -- by 王鸣 + tags: + - web媒资库相关接口 + /api/web/resource/import: + post: + consumes: + - application/json + description: 提交媒资入库任务,后台从媒资中心同步短剧详情并写入短剧和章节集合 + parameters: + - description: 短剧媒资入库请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletResourceUseReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧媒资入库 -- by 王鸣 + tags: + - web媒资库相关接口 + /api/web/resource/list: + get: + consumes: + - application/json + description: 分页查询短剧媒资列表 + parameters: + - description: 媒体类型 1-动漫 2-漫画 4-漫画短剧 5-短剧 6-普通短剧 + in: query + name: media_type + type: integer + - description: 页码 + in: query + name: pageNumber + type: integer + - description: 个数 + in: query + name: pageSize + type: integer + - description: 同步类型 1-全量同步 2-增量同步 + in: query + name: sync_type + type: integer + - description: 标签 + in: query + name: tag + type: string + - description: 标题 + in: query + name: title + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletResourceListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧媒资列表 -- by 王鸣 + tags: + - web媒资库相关接口 + /api/web/richtextcontent/add: + post: + consumes: + - application/json + description: 添加一个富文本内容 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/richtextcontentmod.RichTextContentModel' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个富文本内容 + tags: + - 富文本内容 + /api/web/richtextcontent/del: + post: + consumes: + - application/json + description: 删除一个富文本内容 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_richtextcontent.DelRichTextContentReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个富文本内容 + tags: + - 富文本内容 + /api/web/richtextcontent/list: + post: + consumes: + - application/json + description: 分组分页获取富文本内容 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_richtextcontent.GetRichTextContentListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_richtextcontent.GetRichTextContentResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取富文本内容 + tags: + - 富文本内容 + /api/web/richtextcontent/update: + post: + consumes: + - application/json + description: 更新一个富文本内容 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_richtextcontent.UpdateRichTextContentReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个富文本内容 + tags: + - 富文本内容 + /api/web/role/add: + post: + consumes: + - application/json + description: 角色-添加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleAddResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-添加 + tags: + - 角色 + /api/web/role/del: + post: + consumes: + - application/json + description: 角色-删除 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleDelResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-删除 + tags: + - 角色 + /api/web/role/drop: + post: + consumes: + - application/json + description: 角色-下拉列表 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleDropResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-下拉列表 + tags: + - 角色 + /api/web/role/enable: + post: + consumes: + - application/json + description: 角色-使能 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleEnableReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleEnableResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-使能 + tags: + - 角色 + /api/web/role/getauth: + get: + consumes: + - application/json + description: 角色-获取权限 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleGetAuthReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleGetAuthResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-获取权限 + tags: + - 角色 + /api/web/role/getinfo: + get: + consumes: + - application/json + description: 角色-获取信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleGetInfoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleGetInfoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-获取信息 + tags: + - 角色 + /api/web/role/list: + get: + consumes: + - application/json + description: 角色-列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/pubdef.Page' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-列表 + tags: + - 角色 + /api/web/role/up: + post: + consumes: + - application/json + description: 角色-更新 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleUpReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleUpResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-更新 + tags: + - 角色 + /api/web/role/upauth: + get: + consumes: + - application/json + description: 角色-更新权限 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_acc.MwRoleUpAuthReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_acc.MwRoleUpAuthResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 角色-更新权限 + tags: + - 角色 + /api/web/slug/sync: + post: + consumes: + - application/json + description: 为视频、分类、女优、作者、短剧、统一标签等空 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true + 时继续请求。type 为空处理全部,也可指定 video/videoCategory/actress/videoCreater/playlet/playletCategory/playletTheme/contentTag。 + parameters: + - description: 同步参数 + in: body + name: param + schema: + $ref: '#/definitions/proto.SlugSyncReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.SlugSyncResp' + "400": + description: 参数错误 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 同步历史空 slug + tags: + - web slug相关接口 + /api/web/stat/analytics: + post: + consumes: + - application/json + description: 统计数据 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_translationcategory.TranslationCategoryResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 统计数据 + tags: + - 获取统计数据 + /api/web/stat/daily/analytics: + post: + consumes: + - application/json + description: 统计数据 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_translationcategory.TranslationCategoryResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 统计数据 + tags: + - 获取统计数据 + /api/web/syscfg/get: + get: + consumes: + - application/json + description: 获取系统配置 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_cfg.GetSysConfigReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_cfg.GetSysConfigResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 获取系统配置 + tags: + - 系统配置 + /api/web/syscfg/update: + post: + consumes: + - application/json + description: 修改系统配置 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/web_cfg.SysConfigUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/web_cfg.SysConfigUpdateResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 修改系统配置 + tags: + - 系统配置 + /api/web/translation/add: + post: + consumes: + - application/json + description: 添加一个导航跳转信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/translationmod.TranslationInfo' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个导航跳转信息 + tags: + - 导航跳转信息 + /api/web/translation/del: + post: + consumes: + - application/json + description: 删除一个导航跳转信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_translation.TranslationInfoDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个导航跳转信息 + tags: + - 导航跳转信息 + /api/web/translation/list: + post: + consumes: + - application/json + description: 分组分页获取导航跳转信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_translation.TranslationInfoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_translation.TranslationInfoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取导航跳转信息 + tags: + - 导航跳转信息 + /api/web/translation/update: + post: + consumes: + - application/json + description: 更新一个导航跳转信息 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_translation.TranslationInfoUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个导航跳转信息 + tags: + - 导航跳转信息 + /api/web/translationcategory/add: + post: + consumes: + - application/json + description: 添加一个导航分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/translationcategorymod.TranslationCategory' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个导航分类 + tags: + - 导航分类 + /api/web/translationcategory/del: + post: + consumes: + - application/json + description: 删除一个导航分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_translationcategory.TranslationCategoryDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个导航分类 + tags: + - 导航分类 + /api/web/translationcategory/list: + post: + consumes: + - application/json + description: 分组分页获取导航分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_translationcategory.TranslationCategoryReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_translationcategory.TranslationCategoryResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取导航分类 + tags: + - 导航分类 + /api/web/translationcategory/update: + post: + consumes: + - application/json + description: 更新一个导航分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_translationcategory.TranslationCategoryUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个导航分类 + tags: + - 导航分类 + /api/web/user/list: + post: + consumes: + - application/json + parameters: + - description: 查询参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_user.UserListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_user.UserListResp' + security: + - ApiKeyAuth: [] + summary: 查询用户列表 + tags: + - 用户管理 + /api/web/user/update: + post: + consumes: + - application/json + parameters: + - description: 更新参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_user.UserUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新用户信息 + tags: + - 用户管理 + /api/web/videocategory/add: + post: + consumes: + - application/json + description: 添加一个视频分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/videocategorymod.VideoCategory' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个视频分类 + tags: + - 视频分类 + /api/web/videocategory/del: + post: + consumes: + - application/json + description: 删除一个视频分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocategory.VideoCategoryDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个视频分类 + tags: + - 视频分类 + /api/web/videocategory/list: + post: + consumes: + - application/json + description: 分组分页获取视频分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocategory.VideoCategoryReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videocategory.VideoCategoryResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取视频分类 + tags: + - 视频分类 + /api/web/videocategory/update: + post: + consumes: + - application/json + description: 更新一个视频分类 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocategory.VideoCategoryUpdate' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个视频分类 + tags: + - 视频分类 + /api/web/videocollection/media/list: + post: + consumes: + - application/json + description: 获取合集视频列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocollection.GetCollectionMediaListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videocollection.GetCollectionMediaListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 获取合集视频列表 + tags: + - 合集管理 + /api/web/videocollection/media/option: + post: + consumes: + - application/json + description: 修改合集里的视频列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocollection.VideoCollectionOptionMediaReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 修改合集里的视频列表 + tags: + - 合集管理 + /api/web/videocollection/media/rank: + post: + consumes: + - application/json + description: 修改合集里的视频排序 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocollection.UpdateCollectionMediaRankReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 修改合集里的视频排序 + tags: + - 合集管理 + /api/web/videocreater/add: + post: + consumes: + - application/json + description: 添加一个视频作者 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/videocreatermod.VideoCreaterInfo' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个视频作者 + tags: + - 视频作者 + /api/web/videocreater/del: + post: + consumes: + - application/json + description: 删除一个视频作者 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocreater.VideoCreaterDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个视频作者 + tags: + - 视频作者 + /api/web/videocreater/list: + post: + consumes: + - application/json + description: 分组分页获取视频作者 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocreater.VideoCreaterReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videocreater.VideoCreaterResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取视频作者 + tags: + - 视频作者 + /api/web/videocreater/update: + post: + consumes: + - application/json + description: 更新一个视频作者 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videocreater.VideoCreaterUpdate' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个视频作者 + tags: + - 视频作者 + /api/web/videoinfo/addTags: + post: + consumes: + - application/json + description: 根据视频ID集合批量追加统一标签,已存在的标签不会重复追加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.VideoAddTagsReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 批量追加视频标签 -- by 王鸣 + tags: + - 视频管理 + /api/web/videoinfo/addcoll: + post: + consumes: + - application/json + description: 新增一个视频合集 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.AddVideoCollectionReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 新增一个视频合集 + tags: + - 视频管理 + /api/web/videoinfo/del: + post: + consumes: + - application/json + description: 删除一个视频管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.VideoInfoDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个视频管理 + tags: + - 视频管理 + /api/web/videoinfo/list: + post: + consumes: + - application/json + description: 分组分页获取视频管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.VideoInfoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videoinfo.VideoInfoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取视频管理 + tags: + - 视频管理 + /api/web/videoinfo/recommend/set: + post: + consumes: + - application/json + description: 根据视频ID集合批量更新 video_information 集合 isRecommend 字段,传1个ID表示单条设置,传多个ID表示批量设置 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.SetRecommendReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 单/批量设置视频是否推荐 -- by 王鸣 + tags: + - 视频管理 + /api/web/videoinfo/setFakeLikes: + post: + consumes: + - application/json + description: 根据ids批量随机设置视频fake.likes,随机范围包含最小值和最大值 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.SetFakeLikesReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videoinfo.SetFakeCountResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 单/批量设置视频假点赞数 --by 旺鸣 + tags: + - 视频管理 + /api/web/videoinfo/setFakeWatchTimes: + post: + consumes: + - application/json + description: 根据ids批量随机设置视频fake.watchTimes,随机范围包含最小值和最大值 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.SetFakeWatchTimesReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videoinfo.SetFakeCountResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 单/批量设置视频假观看次数 --by 旺鸣 + tags: + - 视频管理 + /api/web/videoinfo/sync: + post: + consumes: + - application/json + description: 从切片列表同步多个视频 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.AddVideoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 从切片列表同步多个视频 + tags: + - 视频管理 + /api/web/videoinfo/syncMediaFile: + post: + consumes: + - application/json + description: 从媒资库直同步到视频列表,后续切片同步时按 mediaSourceId 覆盖 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.SyncMediaFileReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 从媒资库直同步到视频列表 + tags: + - 视频管理 + /api/web/videoinfo/tag/set: + post: + consumes: + - application/json + description: 根据视频ID集合批量覆盖 video_information 集合 tags 字段,传1个ID表示单条设置,传多个ID表示批量设置,tagIds传空数组表示清空标签 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.VideoSetTagsReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 单/批量设置视频标签 -- by 王鸣 + tags: + - 视频管理 + /api/web/videoinfo/transcode: + post: + responses: {} + summary: 提交视频 H265 转码 + tags: + - 视频管理 + /api/web/videoinfo/update: + post: + consumes: + - application/json + description: 更新一个视频管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videoinfo.VideoInfoUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个视频管理 + tags: + - 视频管理 + /api/web/videotag/add: + post: + consumes: + - application/json + description: 添加一个标签管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/videotagmod.VideoTagInfo' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个标签管理 + tags: + - 标签管理 + /api/web/videotag/del: + post: + consumes: + - application/json + description: 删除一个标签管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videotag.VideoTagInfoDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个标签管理 + tags: + - 标签管理 + /api/web/videotag/list: + post: + consumes: + - application/json + description: 分组分页获取标签管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videotag.VideoTagInfoReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videotag.VideoTagInfoResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取标签管理 + tags: + - 标签管理 + /api/web/videotag/update: + post: + consumes: + - application/json + description: 更新一个标签管理 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videotag.VideoTagInfoUpdate' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个标签管理 + tags: + - 标签管理 + /wbapi/hjsq/hottag: + get: + consumes: + - application/json + description: 根据视频累计播放量聚合返回热门标签列表 + parameters: + - description: 返回条数,默认10,最大1000 + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videotag.HotTagResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + summary: 查询播放量最多的标签 + tags: + - 标签管理 + /wbapi/hjsq/ipstat: + get: + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videotag.IpStatReq' + summary: 查询今日和昨日去重访客IP数 + tags: + - IP统计 +schemes: +- http +- https +securityDefinitions: + ApiKeyAuth: + in: header + name: Authorization + type: apiKey + BasicAuth: + type: basic + OAuth2AccessCode: + authorizationUrl: https://example.com/oauth/authorize + flow: accessCode + scopes: + admin: Grants read and write access to administrative information + tokenUrl: https://example.com/oauth/token + type: oauth2 + OAuth2Application: + flow: application + scopes: + admin: Grants read and write access to administrative information + write: Grants write access + tokenUrl: https://example.com/oauth/token + type: oauth2 + OAuth2Implicit: + authorizationUrl: https://example.com/oauth/authorize + flow: implicit + scopes: + admin: Grants read and write access to administrative information + write: Grants write access + type: oauth2 + OAuth2Password: + flow: password + scopes: + admin: Grants read and write access to administrative information + read: Grants read access + write: Grants write access + tokenUrl: https://example.com/oauth/token + type: oauth2 +swagger: "2.0" diff --git a/web/main.go b/web/main.go new file mode 100644 index 0000000..964dc70 --- /dev/null +++ b/web/main.go @@ -0,0 +1,66 @@ +//go:generate swag init --parseDependency --parseDepth 4 +package main + +import ( + "flag" + "os" + "videoweb/pub/common/srvversion" + "videoweb/web/server" +) + +// @title 站群后台接口 +// @version 1.0 +// @description 这是站群后台接口服务. +// @termsOfService + +// @contact.name +// @contact.url +// @contact.email + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host localhost:12667 +// @BasePath / +// @Schemes http https + +// @securityDefinitions.basic BasicAuth + +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization + +// @securitydefinitions.oauth2.application OAuth2Application +// @tokenUrl https://example.com/oauth/token +// @scope.write Grants write access +// @scope.admin Grants read and write access to administrative information + +// @securitydefinitions.oauth2.implicit OAuth2Implicit +// @authorizationUrl https://example.com/oauth/authorize +// @scope.write Grants write access +// @scope.admin Grants read and write access to administrative information + +// @securitydefinitions.oauth2.password OAuth2Password +// @tokenUrl https://example.com/oauth/token +// @scope.read Grants read access +// @scope.write Grants write access +// @scope.admin Grants read and write access to administrative information + +// @securitydefinitions.oauth2.accessCode OAuth2AccessCode +// @tokenUrl https://example.com/oauth/token +// @authorizationUrl https://example.com/oauth/authorize +// @scope.admin Grants read and write access to administrative information + +func cmdLine() { + info := flag.Bool("info", false, "show serivce build info") + //flagHander := flag.String("handler", "rpc", "set commuic ation type example rpc or mdns, default is rpc") + flag.Parse() + if *info { + srvversion.Info() //内部会自动退进程. + } +} + +func main() { + cmdLine() + server.Run(os.Args) +} diff --git a/web/middleware/api_security/body_dump.go b/web/middleware/api_security/body_dump.go new file mode 100644 index 0000000..a9d000e --- /dev/null +++ b/web/middleware/api_security/body_dump.go @@ -0,0 +1,32 @@ +package api_security + +import ( + "github.com/gin-gonic/gin" +) + +func BodyDump() gin.HandlerFunc { + return func(ctx *gin.Context) { + // fmt.Println("=========================== 1") + + // fmt.Printf("%+v\n", ctx.Request.Header) + + // data, e := ioutil.ReadAll(ctx.Request.Body) + // if e != nil { + // ctx.AbortWithStatusJSON(500, "protosign: read data failed") + // return + // } + + // fmt.Println("=========================== 3") + // fmt.Println(hex.Dump(data)) + + // fmt.Println("=========================== 4") + // ctx.Request.Body = &signReader{ + // *bytes.NewReader(data), + // ctx.Request.Body, + // } + // fmt.Println("=========================== 5") + // ctx.Next() + // fmt.Println("=========================== 6") + + } +} diff --git a/web/middleware/api_security/consts.go b/web/middleware/api_security/consts.go new file mode 100644 index 0000000..0bec3ca --- /dev/null +++ b/web/middleware/api_security/consts.go @@ -0,0 +1,5 @@ +package api_security + +const XProtoSign = "X-Proto-Sign" +const XProtoNonce = "X-Proto-Nonce" +const XProtoTimestamp = "X-Proto-Timestamp" diff --git a/web/middleware/api_security/genkey.go b/web/middleware/api_security/genkey.go new file mode 100644 index 0000000..6f072e9 --- /dev/null +++ b/web/middleware/api_security/genkey.go @@ -0,0 +1,25 @@ +package api_security + +import ( + "crypto/sha256" + "math/rand" +) + +func GenerateKey(secret string) []byte { + key := sha256.Sum256([]byte(secret)) + return key[:] +} + +func GenerateIV(secret, salt string) []byte { + return GenerateKey(secret + salt)[:16] +} + +const strPool = "012345679qwertyuioplkjhgfdsazxcvbnmQWERTYUIOPLKJHGFDSAZXCVBNM" + +func GenerateIVSalt() string { + var n [16]byte + for i := 0; i < 16; i++ { + n[i] = byte(strPool[rand.Intn(len(strPool))]) + } + return string(n[:]) +} diff --git a/web/middleware/api_security/genkey_test.go b/web/middleware/api_security/genkey_test.go new file mode 100644 index 0000000..261c7be --- /dev/null +++ b/web/middleware/api_security/genkey_test.go @@ -0,0 +1,16 @@ +package api_security + +import ( + "fmt" + "testing" +) + +func TestGenerateSalt(t *testing.T) { + for i := 0; i < 5; i++ { + fmt.Println(GenerateIVSalt()) + } +} + +func BenchmarkGenerateSalt(b *testing.B) { + GenerateIV("asdfasdfasdf", GenerateIVSalt()) +} diff --git a/web/middleware/api_security/sign.go b/web/middleware/api_security/sign.go new file mode 100644 index 0000000..0596407 --- /dev/null +++ b/web/middleware/api_security/sign.go @@ -0,0 +1,95 @@ +package api_security + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "sort" + "strings" + + "github.com/gin-gonic/gin" +) + +type signReader struct { + bytes.Reader + RC io.ReadCloser +} + +func (thiz *signReader) Close() error { + return thiz.RC.Close() +} + +func calcSign(kv map[string][]byte) string { + keys := make([]string, 0, len(kv)) + for k := range kv { + keys = append(keys, k) + } + + sort.Strings(keys) + + h := sha256.New() + for _, k := range keys { + _, _ = h.Write([]byte(k)) + _, _ = h.Write(kv[k]) + } + + return strings.ToLower(hex.EncodeToString(h.Sum(nil))) +} + +func ProtoSign(secret []byte, + shouldCrypto ShouldCryptoChecker) gin.HandlerFunc { + return func(ctx *gin.Context) { + + if shouldCrypto != nil && !shouldCrypto(ctx) { + ctx.Next() + return + } + + sign := ctx.GetHeader(XProtoSign) + nonce := ctx.GetHeader(XProtoNonce) + ts := ctx.GetHeader(XProtoTimestamp) + + if len(sign) != 64 || len(nonce) < 10 || len(ts) < 6 { + ctx.AbortWithStatusJSON(500, "protosign: argument invalid") + return + } + + data, e := ioutil.ReadAll(ctx.Request.Body) + if e != nil { + ctx.AbortWithStatusJSON(500, "protosign: read data failed") + return + } + + kv := make(map[string][]byte) + kv[XProtoNonce] = []byte(nonce) + kv[XProtoTimestamp] = []byte(ts) + kv["_METHOD_"] = []byte(ctx.Request.Method) + kv["_PATH_"] = []byte(ctx.Request.URL.Path) + kv["_SECRET_"] = secret + + queries := map[string][]string(ctx.Request.URL.Query()) + for k, v := range queries { + if len(v) > 0 { + kv[fmt.Sprintf("_QUERY_%s_", strings.ToUpper(k))] = []byte(v[0]) + } + } + + if len(data) > 0 { + kv["_BODY_"] = []byte(data) + } + + if calcSign(kv) != sign { + ctx.AbortWithStatusJSON(500, "protosign: verify sign failed") + return + } + + ctx.Request.Body = &signReader{ + *bytes.NewReader(data), + ctx.Request.Body, + } + ctx.Next() + } +} diff --git a/web/middleware/api_security/sign_test.go b/web/middleware/api_security/sign_test.go new file mode 100644 index 0000000..14e1cbc --- /dev/null +++ b/web/middleware/api_security/sign_test.go @@ -0,0 +1,16 @@ +package api_security + +import ( + "fmt" + "net/url" + "testing" +) + +func TestUrl(t *testing.T) { + + u, _ := url.Parse("http://a.b.com/api/a/b/c?a=%20123") + fmt.Printf("path: %s\n", u.Path) + fmt.Printf("rawpath: %s\n", u.RawQuery) + fmt.Printf("escapedpath: %s\n", u.EscapedPath()) + +} diff --git a/web/middleware/api_security/types.go b/web/middleware/api_security/types.go new file mode 100644 index 0000000..9bbc702 --- /dev/null +++ b/web/middleware/api_security/types.go @@ -0,0 +1,38 @@ +package api_security + +import ( + "crypto/cipher" + "io" + + "github.com/gin-gonic/gin" +) + +type ShouldCryptoChecker func(ctx *gin.Context) bool + +type cryptoReader struct { + S cipher.Stream + RC io.ReadCloser +} + +func (r *cryptoReader) Read(dst []byte) (n int, err error) { + n, err = r.RC.Read(dst) + r.S.XORKeyStream(dst[:n], dst[:n]) + return +} + +func (r *cryptoReader) Close() error { + return r.RC.Close() +} + +type cryptoWriter struct { + gin.ResponseWriter + writer *cipher.StreamWriter +} + +func (g *cryptoWriter) WriteString(s string) (int, error) { + return g.writer.Write([]byte(s)) +} + +func (g *cryptoWriter) Write(data []byte) (int, error) { + return g.writer.Write(data) +} diff --git a/web/middleware/apilimitHandler/aiplimit.go b/web/middleware/apilimitHandler/aiplimit.go new file mode 100644 index 0000000..a413e7e --- /dev/null +++ b/web/middleware/apilimitHandler/aiplimit.go @@ -0,0 +1,84 @@ +package apilimitHandler + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type counter struct { + Count int + WindowStart time.Time +} + +type RouteHourlyLimiter struct { + mu sync.Mutex + limit int + window time.Duration + counters map[string]*counter +} + +func NewRouteHourlyLimiter(limit int) *RouteHourlyLimiter { + l := &RouteHourlyLimiter{ + limit: limit, + window: time.Hour, + counters: make(map[string]*counter), + } + go l.cleaner() + return l +} + +func (l *RouteHourlyLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 用 FullPath 做“接口维度”,例如 /api/user/:id + route := c.FullPath() + if route == "" { + // 路由未匹配时兜底 + route = c.Request.URL.Path + } + + now := time.Now() + + l.mu.Lock() + defer l.mu.Unlock() + + item, ok := l.counters[route] + if !ok || now.Sub(item.WindowStart) >= l.window { + l.counters[route] = &counter{ + Count: 1, + WindowStart: now, + } + c.Next() + return + } + + if item.Count >= l.limit { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "code": http.StatusTooManyRequests, + "message": "rate limit exceeded: max 100 requests per route per hour", + }) + return + } + + item.Count++ + c.Next() + } +} + +// 定时清理过期窗口,避免 map 无限增长 +func (l *RouteHourlyLimiter) cleaner() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for now := range ticker.C { + l.mu.Lock() + for k, v := range l.counters { + if now.Sub(v.WindowStart) >= l.window { + delete(l.counters, k) + } + } + l.mu.Unlock() + } +} diff --git a/web/middleware/auth/http.go b/web/middleware/auth/http.go new file mode 100644 index 0000000..c59be6b --- /dev/null +++ b/web/middleware/auth/http.go @@ -0,0 +1,298 @@ +package auth + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "strings" + "sync" + "videoweb/pub/common" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/oplog" + "videoweb/web/webg" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +var ( + RoleApiMenus = map[int64][]string{} // 角色Api + mutex sync.RWMutex +) + +var ( + tokener = utils.NewTokenRedis() +) + +const ( + AccId = "ACC_ID" + roleAuthAdrr = "/web/role/getauth" +) + +type APIInfo struct { + Api string + Desc string +} + +var ( + // 白名单,不需要验证token的api + whiteList = []string{ + "/swagger", + "/api/web/file/uploadImg", + "/api/web/file/uploadImgs", + "/api/web/media/videoBody", + "/api/web/media/videoOk", + "/api/web/m3u8", + "/api/web/uploader/m3u8", + "/api/web/enkey", + "/api/web/login/acc", + "/api/web/login/bind", + "/api/web/login/slogin", + "/api/web/stat/media/list", + "/api/web/stat/analytics", + "/api/web/stat/daily/analytics", + "/api/web/sync/video/list", + "/api/web/sync/videocollection/list", + "/api/web/sync/category/list", + "/api/web/sync/creator/list", + "/api/web/sync/tag/list", + "/api/web/contentexcel", + "/api/web/videoinfo/fillZeroFakeFlow", + "/wbapi", + } +) + +// 读取角色信息 +func GetRoleApiMenus(ctx context.Context, accid int64) (roles []string, err error) { + mutex.RLock() + roles, ok := RoleApiMenus[accid] + mutex.RUnlock() + if !ok { + accInfo, dataErr := mwacc.FindOneAccByFilter(nil, bson.M{mwacc.FN.MwAcc.Id: accid}) + if dataErr != nil || accInfo.Id == 0 { + return roles, dataErr + } + + roleInfo, roleErr := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: accInfo.RoleId}) + + if roleErr != nil || roleInfo.Id == 0 { + return roles, roleErr + } + + if roleInfo.Name == "超级管理员" { + return nil, nil + } + + mutex.Lock() + RoleApiMenus[accid] = roleInfo.ApiMenus + mutex.Unlock() + roles = roleInfo.ApiMenus + } + return +} + +// 是否角色api +func HasRoleApi(path string, apis []string) bool { + apimap := make(map[string]struct{}) + for _, v := range apis { + apimap[v] = struct{}{} + } + _, ok := apimap[path] + return ok +} + +// 角色权限 +func handleRoles(ctx *gin.Context) (int, error) { + path := ctx.Request.URL.Path + if strings.HasPrefix(path, roleAuthAdrr) { + return 0, nil + } + + accid := ctx.GetInt(AccId) + apis, err := GetRoleApiMenus(ctx, int64(accid)) + if err != nil { + return int(errors.RoleErr), err + } + if apis == nil { + return 0, nil + } + has := HasRoleApi(path, apis) + if !has { + return int(errors.RoleErr), srvdef.New(srvdef.RoleErr, (srvdef.RoleErr).Msg(), "") + } + return 0, nil +} + +// 楼风经纪人权限 +func handleLouFengBossRoles(ctx *gin.Context) (int, error) { + path := ctx.Request.URL.Path + if strings.HasPrefix(path, roleAuthAdrr) { + return 0, nil + } + + accid := ctx.GetInt64(AccId) + accInfo, dataErr := mwacc.FindOneAccByFilter(nil, bson.M{mwacc.FN.MwAcc.Id: accid}) + if dataErr != nil || accInfo.Id == 0 { + return int(errors.RoleErr), dataErr + } + + return 0, nil +} + +// 验证后台账号IP +func authMwIP(ctx *gin.Context) (int, error) { + + cIP := ctx.ClientIP() + accid := ctx.GetInt64(AccId) + accInfo, dataErr := mwacc.FindOneAccByFilter(nil, bson.M{mwacc.FN.MwAcc.Id: accid}) + if dataErr != nil || accInfo.Id == 0 { + return int(errors.RoleErr), dataErr + } + if len(accInfo.LimitIP) != 0 { + for _, v := range accInfo.LimitIP { + if v == cIP { + return 0, nil + } + } + return int(errors.LimitIPErr), dataErr + } else { + return int(errors.LimitIPErr), dataErr + } + + // return 0, nil +} + +// 验证 +func Auth(ctx *gin.Context) { + //for test + //ctx.Set(AccId, int64(1000)) + //return + p := ctx.Request.URL.Path + if strings.Contains(p, "swagger") { + return + } + for _, w := range whiteList { + if strings.HasPrefix(ctx.Request.URL.Path, w) { + return + } + } + if v, ok := common.ApiSetMaps[p]; ok { + fmt.Println(v.IsWhite) + if v.IsWhite { + return + } + } + + doToken := func() (int, error) { + checkToken := ctx.Request.Header.Get("Authorization") + // 去掉Bearer,如果有 + checkToken = utils.StripBearerPrefixFromTokenString(checkToken) + + if checkToken == "" { + return http.StatusUnauthorized, srvdef.New(srvdef.NoToken, (srvdef.NoToken).Msg(), "") + } + + has, err := mwacc.CheckToken(ctx, checkToken) + if err != nil { + return http.StatusUnauthorized, err + } + if !has { + return http.StatusUnauthorized, srvdef.New(srvdef.ReLogin, (srvdef.ReLogin).Msg(), "") + } + + userId, _, err := utils.ParseUserToken(checkToken, webg.Conf.JwtKey) + if err != nil { + return http.StatusUnauthorized, srvdef.New(srvdef.InvalidToken, (srvdef.InvalidToken).Msg(), "") + } + // log.Info("Auth test", log.Any("checkToken", checkToken), log.Any("userId", userId)) + ctx.Set(AccId, userId) + return 0, nil + } + + statusCode, err := doToken() + if err != nil || statusCode != 0 { + ctx.AbortWithStatusJSON(statusCode, err) + return + } + + roleCode, err := handleRoles(ctx) + if err != nil || roleCode != 0 { + ctx.AbortWithStatusJSON(http.StatusForbidden, err) + } + + roleCode1, err1 := handleLouFengBossRoles(ctx) + if err1 != nil || roleCode1 != 0 { + ctx.AbortWithStatusJSON(http.StatusForbidden, err) + } + + // authIPCode, err2 := authMwIP(ctx) + // if err2 != nil || authIPCode != 0 { + // ctx.AbortWithStatusJSON(http.StatusForbidden, err) + // } +} + +// func userAgentHandler(ctx *gin.Context) { +// userAgent := ctx.Request.Header.Get("User-Agent") +// if userAgent != "" { +// fields := strings.Fields(userAgent) +// curVersion := fields[len(fields)-1] +// if strings.HasPrefix(curVersion, "version:") { +// versionNum := strings.Split(curVersion, ":")[1] +// ctx.Set("VERSION", versionNum) +// } else { +// ctx.Set("VERSION", "") +// } +// } +// if strings.Contains(userAgent, "Android") { +// ctx.Set("SYSTEM", "android") +// } +// if strings.Contains(userAgent, "iPhone") { +// ctx.Set("SYSTEM", "ios") +// } + +// // 增加 不签名,不加密 开关 +// } + +// 操作日志 +func OperateLog(ctx *gin.Context) { + desc := "" + find := false + for _, w := range LogList { + if ctx.Request.URL.Path == w.Api { + find = true + desc = w.Desc + } + } + if !find { + ctx.Next() + return + } + + accId := ctx.GetInt64(AccId) + rawData, err := ctx.GetRawData() + ip := ctx.ClientIP() + if err != nil { + ctx.AbortWithStatusJSON(400, err) + } + + log.Info("OperateLog Test", log.Any("vmap.Path", ctx.Request.URL.Path)) + + err = oplog.AddLog(ctx, ctx.Request.URL.Path, desc, accId, string(rawData), ip) + if err != nil { + ctx.AbortWithStatusJSON(int(errors.OperateLog), err) + return + } + + ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(rawData)) // 将取出来的执行条件放回ctx中 + ctx.Next() +} + +func GetAccount(ctx *gin.Context) int64 { + return ctx.GetInt64(AccId) +} diff --git a/web/middleware/auth/log_list.go b/web/middleware/auth/log_list.go new file mode 100644 index 0000000..3587164 --- /dev/null +++ b/web/middleware/auth/log_list.go @@ -0,0 +1,94 @@ +package auth + +var LogList = []APIInfo{ + {"/api/web/acc/add", "账号-添加"}, + {"/api/web/acc/del", "账号-删除"}, + {"/api/web/acc/up", "账号-更新"}, + {"/api/web/acc/savestatus", "账号-保存状态"}, + {"/api/web/acc/uppwd", "账号-更新密码"}, + {"/api/web/login/acc", "登录-登录"}, + {"/api/web/login/slogin", "管理员sso登陆"}, + {"/api/web/role/add", "角色-添加"}, + {"/api/web/role/del", "角色-删除"}, + {"/api/web/role/up", "角色-更新"}, + {"/api/web/role/enable", "角色-使能"}, + {"/api/web/role/upauth", "角色-更新权限"}, + {"/api/web/syscfg/update", "修改系统配置"}, + {"/api/web/syscfg/appkey", "设置媒资库appKey"}, + {"/api/web/post/comment/del", "删除一个评论"}, + {"/api/web/post/comment/user/del", "删除一个用户所有评论"}, + {"/api/web/post/comment/replay", "回复评论"}, + {"/api/web/post/comment/reviewBatch", "批量审核评论"}, + {"/api/web/post/comment/review", "审核评论"}, + {"/api/web/ipblack/add", "IP黑名单-增加"}, + {"/api/web/ipblack/del", "IP黑名单-删除"}, + {"/api/web/richtextcontent/add", "添加一个富文本内容"}, + {"/api/web/richtextcontent/del", "删除一个富文本内容"}, + {"/api/web/richtextcontent/update", "更新一个富文本内容"}, + {"/api/web/translationcategory/add", "添加一个导航分类"}, + {"/api/web/translationcategory/del", "删除一个导航分类"}, + {"/api/web/translationcategory/update", "更新一个导航分类"}, + {"/api/web/translation/add", "添加一个导航跳转信息"}, + {"/api/web/translation/del", "删除一个导航跳转信息"}, + {"/api/web/translation/update", "更新一个导航跳转信息"}, + {"/api/web/videocategory/add", "添加一个视频分类"}, + {"/api/web/videocategory/del", "删除一个视频分类"}, + {"/api/web/videocategory/update", "更新一个视频分类"}, + {"/api/web/videocreater/add", "添加一个视频作者"}, + {"/api/web/videocreater/del", "删除一个视频作者"}, + {"/api/web/videocreater/update", "更新一个视频作者"}, + {"/api/web/videotag/add", "添加一个标签管理"}, + {"/api/web/videotag/del", "删除一个标签管理"}, + {"/api/web/videotag/update", "更新一个标签管理"}, + {"/api/web/videoinfo/del", "删除一个视频管理"}, + {"/api/web/videoinfo/update", "更新一个视频管理"}, + {"/api/web/videoinfo/recommend/set", "视频-设置推荐"}, + {"/api/web/videoinfo/heat/set", "视频-设置热度"}, + {"/api/web/videocollection/media/option", "修改合集里的视频列表"}, + {"/api/web/videocollection/media/rank", "修改合集里的视频排序"}, + {"/api/web/videoinfo/addcoll", "新增一个视频合集"}, + {"/api/web/videoinfo/addTags", "视频-批量追加标签"}, + {"/api/web/videoinfo/fillZeroFakeFlow", "批量填充低播放量/低收藏视频假数据"}, + {"/api/web/actress/add", "添加一个女优信息"}, + {"/api/web/actress/del", "删除一个女优信息"}, + {"/api/web/actress/update", "更新一个女优信息"}, + {"/api/web/playlet/add", "短剧-新增"}, + {"/api/web/playlet/update", "短剧-编辑"}, + {"/api/web/playlet/del", "短剧-删除"}, + {"/api/web/playlet/status/set", "短剧-设置上下架"}, + {"/api/web/playlet/recommend/set", "短剧-设置推荐"}, + {"/api/web/playlet/featured/set", "短剧-设置爆剧"}, + {"/api/web/playlet/banner/set", "短剧-设置banner展示"}, + {"/api/web/playlet/theme/set", "短剧-设置题材"}, + {"/api/web/playlet/tag/set", "短剧-设置标签"}, + {"/api/web/playlet/like/virtual/set", "短剧-设置虚拟点赞数"}, + {"/api/web/playlet/browse/virtual/set", "短剧-设置虚拟观看数"}, + {"/api/web/playlet/heat/virtual/set", "短剧-设置虚拟热度"}, + {"/api/web/playlet/pay-type/set", "短剧-设置支付方式"}, + {"/api/web/playlet/watermark/reset", "短剧-重置水印"}, + {"/api/web/playlet/chapter/add", "短剧剧集-新增"}, + {"/api/web/playlet/chapter/update", "短剧剧集-更新"}, + {"/api/web/playlet/chapter/del", "短剧剧集-删除"}, + {"/api/web/playlet/chapter/pay-type/set", "短剧剧集-设置支付方式"}, + {"/api/web/playlet/chapter/sync", "短剧剧集-手动同步"}, + {"/api/web/playlet/category/set", "短剧-设置分类"}, + {"/api/web/playlet/category/add", "短剧分类-新增"}, + {"/api/web/playlet/category/update", "短剧分类-更新"}, + {"/api/web/playlet/category/del", "短剧分类-删除"}, + {"/api/web/playlet/home-config/add", "短剧首页配置-新增"}, + {"/api/web/playlet/home-config/update", "短剧首页配置-更新"}, + {"/api/web/playlet/home-config/del", "短剧首页配置-删除"}, + {"/api/web/playlet/theme/add", "短剧题材-新增"}, + {"/api/web/playlet/theme/update", "短剧题材-更新"}, + {"/api/web/playlet/theme/del", "短剧题材-删除"}, + {"/api/web/home/banner/add", "首页banner-新增"}, + {"/api/web/home/banner/update", "首页banner-更新"}, + {"/api/web/home/banner/del", "首页banner-删除"}, + {"/api/web/announcement/add", "公告-增加"}, + {"/api/web/announcement/del", "公告-删除"}, + {"/api/web/announcement/update", "公告-更新"}, + {"/api/web/content/tag/add", "统一标签-新增"}, + {"/api/web/content/tag/update", "统一标签-更新"}, + {"/api/web/content/tag/del", "统一标签-删除"}, + {"/api/web/slug/sync", "同步历史空slug"}, +} diff --git a/web/middleware/cors/cors.go b/web/middleware/cors/cors.go new file mode 100644 index 0000000..c8519b8 --- /dev/null +++ b/web/middleware/cors/cors.go @@ -0,0 +1,135 @@ +package cors + +import ( + "github.com/gin-gonic/gin" + "net/http" + "strconv" + "strings" +) + +type Options struct { + Origin *[]string // 传入空指针,表示允许"*", 传入空数组表示禁止跨域 + Methods []string // Methods + AllowedHeaders []string // AllowHeaders + ExposedHeaders []string // exposeHeader + Credentials bool // cookie + MaxAge int64 // 缓存MaxAge + PreflightContinue bool // 在遇到Options的时候,继续,而不是返回请求 + OptionsSuccessStatus int // Options时,返回的状态码,默认204 +} + +func configureOrigin(o *Options, c *gin.Context) map[string]string { + reqOrigin := c.GetHeader("Origin") + headers := make(map[string]string) + if o.Origin == nil { + headers["Access-Control-Allow-Origin"] = "*" + } else { + isAllowed := false + for _, allowed := range *o.Origin { + if allowed == reqOrigin { + isAllowed = true + break + } + } + if isAllowed { + headers["Access-Control-Allow-Origin"] = reqOrigin + } else { + headers["Access-Control-Allow-Origin"] = strconv.FormatBool(false) + } + headers["Vary"] = "Origin" + } + return headers +} + +func configureMethods(o *Options) map[string]string { + headers := make(map[string]string) + if len(o.Methods) > 0 { + headers["Access-Control-Allow-Methods"] = strings.ToUpper(strings.Join(o.Methods, ",")) + } + return headers +} + +func configureCredentials(o *Options) map[string]string { + headers := make(map[string]string) + if o.Credentials { + headers["Access-Control-Allow-Credentials"] = strconv.FormatBool(true) + } + return headers +} + +func configureAllowedHeaders(o *Options, c *gin.Context) map[string]string { + headers := make(map[string]string) + if len(o.AllowedHeaders) > 0 { + headers["Access-Control-Allow-Headers"] = strings.Join(o.AllowedHeaders, ",") + } + return headers +} + +func configureExposedHeaders(o *Options) map[string]string { + var headers = make(map[string]string) + if len(o.ExposedHeaders) > 0 { + headers["Access-Control-Expose-Headers"] = strings.Join(o.ExposedHeaders, ",") + } + return headers +} + +func configureMaxAge(o *Options) map[string]string { + var headers = make(map[string]string) + if o.MaxAge >= 0 { + headers["Access-Control-Max-Age"] = strconv.FormatInt(o.MaxAge, 10) + } + return headers +} + +func mergeMap(m ...map[string]string) map[string]string { + r := make(map[string]string) + for _, t := range m { + for k, v := range t { + r[k] = v + } + } + return r +} + +func applyHeader(c *gin.Context, h map[string]string) { + for k, v := range h { + c.Header(k, v) + } +} + +// Cors 跨域处理 +func Cors(o *Options) gin.HandlerFunc { + if o.OptionsSuccessStatus == 0 { + o.OptionsSuccessStatus = http.StatusNoContent + } + return func(c *gin.Context) { + method := c.Request.Method + // c.Header("Access-Control-Allow-Origin", "*") + // c.Header("Access-Control-Allow-Headers", "Content-Type,AccessToken,X-CSRF-Token,Authorization,Token,Content-Length,Etag,Content-Range,Accept-Ranges,User-Agent,Range,Bucket,Content-Disposition,Signature,X-Forwarded-For,X-Real-Ip") + if method == "OPTIONS" { + h := mergeMap( + configureOrigin(o, c), + configureCredentials(o), + configureMethods(o), + configureAllowedHeaders(o, c), + configureMaxAge(o), + configureExposedHeaders(o), + ) + applyHeader(c, h) + if o.PreflightContinue { + c.Next() + } else { + c.Header("Content-Length", "0") + c.AbortWithStatus(o.OptionsSuccessStatus) + } + } else { + h := mergeMap( + configureOrigin(o, c), + configureCredentials(o), + configureExposedHeaders(o), + ) + applyHeader(c, h) + c.Next() + } + } +} diff --git a/web/middleware/ipblacklist/ipblacklist.go b/web/middleware/ipblacklist/ipblacklist.go new file mode 100644 index 0000000..62bded1 --- /dev/null +++ b/web/middleware/ipblacklist/ipblacklist.go @@ -0,0 +1,16 @@ +package ipblacklist + +import ( + "errors" + "github.com/gin-gonic/gin" + "net/http" + "videoweb/web/service/srv_ipblack" +) + +func IpBlackListMiddleware(ctx *gin.Context) { + ip := ctx.ClientIP() + if srv_ipblack.CheckIp(ip) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errors.New("login limit")) + return + } +} \ No newline at end of file diff --git a/web/middleware/limitHandler/login.go b/web/middleware/limitHandler/login.go new file mode 100644 index 0000000..a7a21d2 --- /dev/null +++ b/web/middleware/limitHandler/login.go @@ -0,0 +1,120 @@ +package limitHandler + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + "videoweb/pub/common" + httpUtil "videoweb/pub/common/http" + "videoweb/pub/common/srvdef" + "videoweb/web/webg" + + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" +) + +var limitURI = []string{ + "/api/web/login/slogin", + "/api/web/login/acc", +} + +var ( + tgBotToken = "2013237388:AAFwvKvEmQfEvcI7wt_64Zkp9KPfgHo8sow" + tgGroupId = "-1003735177636" +) + +var ( + limitDuration = time.Minute + limitCount = 5 +) + +var memCache = cache.New(1*time.Minute, 10*time.Minute) + +// LoginLimit 登陆频率限制:按账号维度限制,不依赖 ClientIP(可被伪造)。 +func LoginLimit(ctx *gin.Context) { + path := ctx.Request.URL.Path + + check := false + for _, p := range limitURI { + if strings.HasPrefix(path, p) { + check = true + break + } + } + if !check { + ctx.Next() + return + } + + // 测试环境不限制 + if webg.Conf.RunMod != "prod" { + ctx.Next() + return + } + + var params map[string]interface{} + var acc string + if ctx.Request.Method == http.MethodPost { + raw, _ := ctx.GetRawData() + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(raw)) + if err := json.Unmarshal(raw, ¶ms); err == nil { + if a, ok := params["acc"].(string); ok { + acc = strings.TrimSpace(a) + } + } + } + + // 无账号时无法按账号限流,交由后续登录逻辑处理参数错误 + if acc == "" { + ctx.Next() + return + } + + key := fmt.Sprintf("web-limit:%s:%s", path, acc) + + count := 1 + if aCount, ok := memCache.Get(key); ok { + if n, ok2 := aCount.(int); ok2 { + count = n + 1 + } + } + memCache.Set(key, count, limitDuration) + + if count > limitCount { + go func(acc, path string, count int, params map[string]interface{}) { + message := fmt.Sprintf( + "[91porn精选]\n登陆请求频率过高!\n时间: %s\n请求地址: %s\n账号: %s\n请求参数: %+v\n错误次数: %d", + time.Now().Format("2006-01-02 15:04:05"), + path, + acc, + params, + count, + ) + // 请求地址/参数为不可信输入,去掉非法 UTF-8 字节;不用 parse_mode,避免 Markdown 把内容解析成实体后报错 + message = strings.ToValidUTF8(message, "") + + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", tgBotToken) + req := map[string]interface{}{ + "chat_id": tgGroupId, + "text": message, + } + jsonBytes, err := json.Marshal(req) + if err != nil { + return + } + header := map[string]string{"Content-Type": "application/json"} + resp, _ := httpUtil.Post(url, header, bytes.NewReader(jsonBytes)) + _ = resp.Data + }(acc, path, count, params) + + common.ServerJSON(ctx, srvdef.ErrReqForbidden, nil) + ctx.Abort() + return + } + + ctx.Next() +} diff --git a/web/middleware/validator/default-validator.go b/web/middleware/validator/default-validator.go new file mode 100644 index 0000000..daf78ee --- /dev/null +++ b/web/middleware/validator/default-validator.go @@ -0,0 +1,81 @@ +package validator + +import ( + "reflect" + "strings" + "sync" + + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/locales/en" + ut "github.com/go-playground/universal-translator" + "gopkg.in/go-playground/validator.v9" + en_translations "gopkg.in/go-playground/validator.v9/translations/en" +) + +var ( + ValidatorUni *ut.UniversalTranslator +) + +type defaultValidator struct { + once sync.Once + validate *validator.Validate +} + +var v binding.StructValidator = &defaultValidator{} + +func init() { + v.Engine() +} + +func (v *defaultValidator) ValidateStruct(obj interface{}) error { + + if kindOfData(obj) == reflect.Struct { + + v.lazyinit() + + if err := v.validate.Struct(obj); err != nil { + return error(err) + } + } + + return nil +} + +func (v *defaultValidator) Engine() interface{} { + v.lazyinit() + return v.validate +} + +func (v *defaultValidator) lazyinit() { + v.once.Do(func() { + v.validate = validator.New() + + // 依旧使用bingding tag兼容v8 + v.validate.SetTagName("binding") + + // 错误信息转换 + en := en.New() + ValidatorUni = ut.New(en, en) + trans, _ := ValidatorUni.GetTranslator("en") + en_translations.RegisterDefaultTranslations(v.validate, trans) + + v.validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("comment"), ",", 2)[0] + if name == "-" { + return "" + } + return name + }) + }) +} + +func kindOfData(data interface{}) reflect.Kind { + + value := reflect.ValueOf(data) + valueType := value.Kind() + + if valueType == reflect.Ptr { + valueType = value.Elem().Kind() + } + return valueType +} diff --git a/web/middleware/validator/v9.go b/web/middleware/validator/v9.go new file mode 100644 index 0000000..4e2e38e --- /dev/null +++ b/web/middleware/validator/v9.go @@ -0,0 +1,11 @@ +package validator + +import ( + "github.com/gin-gonic/gin/binding" +) + +// 替换gin 默认v8验证器 +// 改为v9 +func init() { + binding.Validator = new(defaultValidator) +} diff --git a/web/router/actress.go b/web/router/actress.go new file mode 100644 index 0000000..fda8b14 --- /dev/null +++ b/web/router/actress.go @@ -0,0 +1,16 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "videoweb/web/api/web_actress" +) + +func actressRouter(router *gin.RouterGroup) { + post := router.Group("/actress") + { + post.GET("/list", web_actress.Get) + post.POST("/update", web_actress.Update) + post.POST("/del", web_actress.Del) + post.POST("/add", web_actress.Add) + } +} diff --git a/web/router/announcement.go b/web/router/announcement.go new file mode 100644 index 0000000..9efc443 --- /dev/null +++ b/web/router/announcement.go @@ -0,0 +1,17 @@ +package router + +import ( + "videoweb/web/api/web_announcement" + + "github.com/gin-gonic/gin" +) + +func announcementRouter(router *gin.RouterGroup) { + group := router.Group("/announcement") + { + group.POST("/list", web_announcement.AnnouncementList) + group.POST("/add", web_announcement.AnnouncementAdd) + group.POST("/del", web_announcement.AnnouncementDel) + group.POST("/update", web_announcement.AnnouncementUpdate) + } +} diff --git a/web/router/app.go b/web/router/app.go new file mode 100644 index 0000000..8c1fad2 --- /dev/null +++ b/web/router/app.go @@ -0,0 +1,17 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "videoweb/web/api/web_app" +) + +func appSyncRouter(router *gin.RouterGroup) { + sync := router.Group("/sync") + { + sync.POST("/video/list", web_app.AppSyncReq) + sync.POST("/videocollection/list", web_app.AppCollectionSyncReq) + sync.POST("/category/list", web_app.AppGetVideoCategory) + sync.POST("/creator/list", web_app.AppGetVideoCreator) + sync.POST("/tag/list", web_app.AppGetVideoTag) + } +} \ No newline at end of file diff --git a/web/router/ather.go b/web/router/ather.go new file mode 100644 index 0000000..9d71290 --- /dev/null +++ b/web/router/ather.go @@ -0,0 +1,23 @@ +package router + +import ( + webanalytics "videoweb/web/api/web_analytics" + "videoweb/web/api/web_cfg" + + "github.com/gin-gonic/gin" +) + +func syscfgRouter(router *gin.RouterGroup) { + system := router.Group("/syscfg") + { + system.GET("/get", web_cfg.GetCfg) + system.POST("/update", web_cfg.UpdateCfg) + system.POST("/appkey", web_cfg.SetAppKey) + } + + stat := router.Group("/stat") + { + stat.GET("/analytics", webanalytics.GetAnalyticsStat) + stat.GET("/daily/analytics", webanalytics.GetAnalyticsDailyStat) + } +} diff --git a/web/router/commend.go b/web/router/commend.go new file mode 100644 index 0000000..537056c --- /dev/null +++ b/web/router/commend.go @@ -0,0 +1,19 @@ +package router + +import ( + "videoweb/web/api/web_commend" + + "github.com/gin-gonic/gin" +) + +func commentRouter(router *gin.RouterGroup) { + group := router.Group("/post") + { + group.POST("/comment/list", web_commend.SearchManyCommend) + group.POST("/comment/replay", web_commend.ReplayPostComment) + group.POST("/comment/del", web_commend.DelCommend) + group.POST("/comment/user/del", web_commend.DelUserCommend) + group.POST("/comment/reviewBatch", web_commend.ReviewCommendBatch) + group.POST("/comment/review", web_commend.ReviewComment) + } +} \ No newline at end of file diff --git a/web/router/contactus.go b/web/router/contactus.go new file mode 100644 index 0000000..fc51f01 --- /dev/null +++ b/web/router/contactus.go @@ -0,0 +1,16 @@ +package router + +import ( + "videoweb/web/api/web_contactus" + + "github.com/gin-gonic/gin" +) + +func contactusRouter(router *gin.RouterGroup) { + group := router.Group("/contactus") + { + group.POST("/list", web_contactus.ContactusList) + group.POST("/del", web_contactus.Del) + group.POST("/update", web_contactus.Update) + } +} diff --git a/web/router/content_tag.go b/web/router/content_tag.go new file mode 100644 index 0000000..6af382f --- /dev/null +++ b/web/router/content_tag.go @@ -0,0 +1,18 @@ +package router + +import ( + "videoweb/web/api/web_contenttag" + + "github.com/gin-gonic/gin" +) + +func contentTagRouter(router *gin.RouterGroup) { + group := router.Group("/content/tag") + { + group.GET("/list", web_contenttag.List) + group.GET("/option", web_contenttag.Option) + group.POST("/add", web_contenttag.Add) + group.POST("/update", web_contenttag.Update) + group.POST("/del", web_contenttag.Del) + } +} diff --git a/web/router/contentexcel.go b/web/router/contentexcel.go new file mode 100644 index 0000000..ea6f9a7 --- /dev/null +++ b/web/router/contentexcel.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/web/api/web_contentexcel" + + "github.com/gin-gonic/gin" +) + +func contentExcelRouter(rootRouter *gin.RouterGroup) { + group := rootRouter.Group("/contentexcel") + { + group.GET("/export", web_contentexcel.Export) + group.POST("/import", web_contentexcel.Import) + } +} diff --git a/web/router/detailnav.go b/web/router/detailnav.go new file mode 100644 index 0000000..3f96d12 --- /dev/null +++ b/web/router/detailnav.go @@ -0,0 +1,17 @@ +package router + +import ( + "videoweb/web/api/web_detailnav" + + "github.com/gin-gonic/gin" +) + +func detailNavRouter(router *gin.RouterGroup) { + group := router.Group("/detailnav") + { + group.POST("/list", web_detailnav.DetailNavList) + group.POST("/add", web_detailnav.DetailNavAdd) + group.POST("/del", web_detailnav.DetailNavDel) + group.POST("/update", web_detailnav.DetailNavUpdate) + } +} diff --git a/web/router/file.go b/web/router/file.go new file mode 100644 index 0000000..b2d5541 --- /dev/null +++ b/web/router/file.go @@ -0,0 +1,16 @@ +package router + +import ( + "videoweb/web/api/web_file" + + "github.com/gin-gonic/gin" +) + +func fileRouter(router *gin.RouterGroup) { + // 文件上传 + file := router.Group("/file") + { + file.POST("/uploadImg", web_file.UploadImage) + file.POST("/uploadImgs", web_file.UploadImages) + } +} diff --git a/web/router/gin.go b/web/router/gin.go new file mode 100644 index 0000000..16b224f --- /dev/null +++ b/web/router/gin.go @@ -0,0 +1,122 @@ +package router + +import ( + "fmt" + "net/http" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + "videoweb/web/middleware/auth" + "videoweb/web/middleware/cors" + "videoweb/web/middleware/ipblacklist" + "videoweb/web/middleware/limitHandler" + "videoweb/web/webg" + + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + "github.com/DeanThompson/ginpprof" + "github.com/gin-gonic/gin" + + _ "videoweb/web/docs" +) + +func Init() (engine *gin.Engine) { + + // 设置启动模式 + switch webg.Conf.RunMod { + case "dev": + gin.SetMode(gin.DebugMode) + case "test": + gin.SetMode(gin.TestMode) + case "prod": + gin.SetMode(gin.ReleaseMode) + default: + gin.SetMode(gin.DebugMode) + } + + // 初始化gin引擎 + engine = gin.Default() + engine.HandleMethodNotAllowed = true + engine.MaxMultipartMemory = 60 << 20 + + engine.NoMethod(noMethod) + engine.NoRoute(noRoute) + + // 注册中间件 + registeMiddleware(engine) + wbVideoDataRouter(engine) + registerRouter(engine) + log.Info("setGinSwaggerxxxxx") + + if webg.Conf.RunMod != "prod" { + log.Info("setGinSwagger") + setGinSwagger(engine) + log.Info("setGinSwagger end") + } + + // 是否开启监控服务 + if webg.Conf.Pprof { + ginpprof.Wrap(engine) + } + + return +} + +func noMethod(c *gin.Context) { + c.JSON(http.StatusMethodNotAllowed, errors.MakeWithMsg(errors.MethodNotFound, "MethodNotFound")) +} + +func noRoute(c *gin.Context) { + c.JSON(http.StatusNotFound, errors.MakeWithMsg(errors.ApiNotFound, "ApiNotFound")) +} + +func registeMiddleware(engine *gin.Engine) { + engine.Use(gin.Recovery()) + engine.Use(cors.Cors(&cors.Options{ + Origin: nil, // "*" + Methods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodHead, http.MethodDelete}, + AllowedHeaders: []string{"Content-Length", "Content-Type", "Authorization", "X-User-Agent", useragent.SDKCommonHeader}, + ExposedHeaders: []string{"Content-Disposition", "Refresh-Authorization"}, + OptionsSuccessStatus: http.StatusOK, + })) + engine.Use(ipblacklist.IpBlackListMiddleware) + engine.Use(auth.Auth) + engine.Use(limitHandler.LoginLimit) + engine.Use(auth.OperateLog) +} + +// 设置swag 接口信息 +func setGinSwagger(router *gin.Engine) { + url := ginSwagger.URL(fmt.Sprintf("http://%s:%s/swagger/doc.json", webg.Conf.Host, webg.Conf.ListenAddr)) + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) +} + +func registerRouter(router *gin.Engine) { + rootRouter := router.Group("/api/web") + { + MainWebRouter(rootRouter) + syscfgRouter(rootRouter) + fileRouter(rootRouter) + videoRouter(rootRouter) + translationRouter(rootRouter) + richTextContent(rootRouter) + commentRouter(rootRouter) + videoCollectionRouter(rootRouter) + ipBlackRouter(rootRouter) + detailNavRouter(rootRouter) + mediaResourceRouter(rootRouter) + playletRouter(rootRouter) + homeBannerRouter(rootRouter) + contentTagRouter(rootRouter) + slugRouter(rootRouter) + hotNavRouter(rootRouter) + appSyncRouter(rootRouter) + actressRouter(rootRouter) + contactusRouter(rootRouter) + announcementRouter(rootRouter) + userRouter(rootRouter) + questionnaireRouter(rootRouter) + contentExcelRouter(rootRouter) + } +} diff --git a/web/router/home_banner.go b/web/router/home_banner.go new file mode 100644 index 0000000..38d9975 --- /dev/null +++ b/web/router/home_banner.go @@ -0,0 +1,17 @@ +package router + +import ( + "videoweb/web/api/web_homebanner" + + "github.com/gin-gonic/gin" +) + +func homeBannerRouter(router *gin.RouterGroup) { + group := router.Group("/home/banner") + { + group.GET("/list", web_homebanner.List) + group.POST("/add", web_homebanner.Add) + group.POST("/update", web_homebanner.Update) + group.POST("/del", web_homebanner.Del) + } +} diff --git a/web/router/hotnav.go b/web/router/hotnav.go new file mode 100644 index 0000000..98f5046 --- /dev/null +++ b/web/router/hotnav.go @@ -0,0 +1,17 @@ +package router + +import ( + "videoweb/web/api/web_hotnav" + + "github.com/gin-gonic/gin" +) + +func hotNavRouter(router *gin.RouterGroup) { + group := router.Group("/hotnav") + { + group.POST("/list", web_hotnav.HotNavList) + group.POST("/add", web_hotnav.HotNavAdd) + group.POST("/del", web_hotnav.HotNavDel) + group.POST("/update", web_hotnav.HotNavUpdate) + } +} diff --git a/web/router/ipblack.go b/web/router/ipblack.go new file mode 100644 index 0000000..1a0963f --- /dev/null +++ b/web/router/ipblack.go @@ -0,0 +1,16 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "videoweb/web/api/web_ipblack" +) + +func ipBlackRouter(router *gin.RouterGroup) { + // 文件上传 + group := router.Group("/ipblack") + { + group.POST("/list", web_ipblack.IpBlackList) + group.POST("/add", web_ipblack.IpBlackAdd) + group.POST("/del", web_ipblack.IpBlackDel) + } +} \ No newline at end of file diff --git a/web/router/mediaresource.go b/web/router/mediaresource.go new file mode 100644 index 0000000..ff5e0d2 --- /dev/null +++ b/web/router/mediaresource.go @@ -0,0 +1,16 @@ +package router + +import ( + "videoweb/web/api/web_mediaresource" + + "github.com/gin-gonic/gin" +) + +func mediaResourceRouter(router *gin.RouterGroup) { + group := router.Group("/resource") + { + group.GET("/list", web_mediaresource.ResourceList) + group.GET("/chapters", web_mediaresource.ResourceChapters) + group.POST("/import", web_mediaresource.ResourceImport) + } +} diff --git a/web/router/playlet.go b/web/router/playlet.go new file mode 100644 index 0000000..5908d42 --- /dev/null +++ b/web/router/playlet.go @@ -0,0 +1,52 @@ +package router + +import ( + "videoweb/web/api/web_playlet" + + "github.com/gin-gonic/gin" +) + +func playletRouter(router *gin.RouterGroup) { + group := router.Group("/playlet") + { + group.GET("/list", web_playlet.List) + group.POST("/add", web_playlet.Add) + group.POST("/update", web_playlet.Update) + group.POST("/del", web_playlet.Del) + group.POST("/status/set", web_playlet.SetStatus) + group.POST("/recommend/set", web_playlet.SetRecommend) + group.POST("/featured/set", web_playlet.SetFeatured) + group.POST("/banner/set", web_playlet.SetBanner) + group.POST("/category/set", web_playlet.SetCategory) + group.POST("/theme/set", web_playlet.SetTheme) + group.POST("/tag/set", web_playlet.SetTag) + group.POST("/like/virtual/set", web_playlet.SetVirtualLike) + group.POST("/browse/virtual/set", web_playlet.SetVirtualBrowse) + group.POST("/heat/virtual/set", web_playlet.SetVirtualHeat) + group.POST("/pay-type/set", web_playlet.SetPayType) + group.POST("/watermark/reset", web_playlet.ResetWatermark) + group.GET("/chapter/list", web_playlet.ChapterList) + group.POST("/chapter/add", web_playlet.ChapterAdd) + group.POST("/chapter/update", web_playlet.ChapterUpdate) + group.POST("/chapter/del", web_playlet.ChapterDel) + group.POST("/chapter/sync", web_playlet.ChapterSync) + group.POST("/chapter/pay-type/set", web_playlet.ChapterSetPayType) + group.GET("/category/list", web_playlet.CategoryList) + group.POST("/category/add", web_playlet.CategoryAdd) + group.POST("/category/update", web_playlet.CategoryUpdate) + group.POST("/category/del", web_playlet.CategoryDel) + group.GET("/tag/list", web_playlet.TagList) + group.POST("/tag/add", web_playlet.TagAdd) + group.POST("/tag/update", web_playlet.TagUpdate) + group.POST("/tag/del", web_playlet.TagDel) + group.GET("/theme/list", web_playlet.ThemeList) + group.POST("/theme/add", web_playlet.ThemeAdd) + group.POST("/theme/update", web_playlet.ThemeUpdate) + group.POST("/theme/del", web_playlet.ThemeDel) + group.GET("/home-config/list", web_playlet.HomeConfigList) + group.GET("/home-config/topic/options", web_playlet.HomeConfigTopicOptions) + group.POST("/home-config/add", web_playlet.HomeConfigAdd) + group.POST("/home-config/update", web_playlet.HomeConfigUpdate) + group.POST("/home-config/del", web_playlet.HomeConfigDel) + } +} diff --git a/web/router/questionnaire.go b/web/router/questionnaire.go new file mode 100644 index 0000000..0652c89 --- /dev/null +++ b/web/router/questionnaire.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/web/api/web_questionnaire" + + "github.com/gin-gonic/gin" +) + +func questionnaireRouter(router *gin.RouterGroup) { + group := router.Group("/questionnaire") + { + group.POST("/list", web_questionnaire.List) + group.POST("/handle", web_questionnaire.Handle) + } +} diff --git a/web/router/richtextcontent.go b/web/router/richtextcontent.go new file mode 100644 index 0000000..d20055e --- /dev/null +++ b/web/router/richtextcontent.go @@ -0,0 +1,17 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "videoweb/web/api/web_richtextcontent" +) + +func richTextContent(router *gin.RouterGroup) { + // 文件上传 + file := router.Group("/richtextcontent") + { + file.POST("/list", web_richtextcontent.List) + file.POST("/add", web_richtextcontent.Add) + file.POST("/del", web_richtextcontent.Del) + file.POST("/update", web_richtextcontent.Update) + } +} \ No newline at end of file diff --git a/web/router/slug.go b/web/router/slug.go new file mode 100644 index 0000000..26c9132 --- /dev/null +++ b/web/router/slug.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/web/api/web_slug" + + "github.com/gin-gonic/gin" +) + +func slugRouter(router *gin.RouterGroup) { + group := router.Group("/slug") + { + group.POST("/sync", web_slug.SyncSlug) + } +} diff --git a/web/router/systerm.go b/web/router/systerm.go new file mode 100644 index 0000000..0f12204 --- /dev/null +++ b/web/router/systerm.go @@ -0,0 +1,42 @@ +package router + +import ( + "videoweb/web/api/web_acc" + + "github.com/gin-gonic/gin" +) + +// 主后台路由 +func MainWebRouter(router *gin.RouterGroup) { + login := router.Group("/login") + { + login.POST("/acc", web_acc.AccLogin) + login.POST("/bind", web_acc.BindQurl) + login.POST("/slogin", web_acc.SLogin) + } + + role := router.Group("/role") + { + role.GET("/list", web_acc.RoleGetList) + role.POST("/add", web_acc.RoleAdd) + role.POST("/del", web_acc.RoleDel) + role.GET("/getinfo", web_acc.RoleGetInfo) + role.POST("/up", web_acc.RoleUp) + role.POST("/enable", web_acc.RoleEnable) + role.POST("/drop", web_acc.RoleGetDrop) + role.GET("/getauth", web_acc.RoleGetAuth) + role.POST("/upauth", web_acc.RoleUpAuth) + } + acc := router.Group("/acc") + { + acc.GET("/list", web_acc.AccGetList) + acc.POST("/add", web_acc.AccAdd) + acc.POST("/del", web_acc.AccDel) + acc.GET("/getinfo", web_acc.AccGetInfo) + acc.POST("/up", web_acc.AccUp) + acc.GET("/getstatus", web_acc.AccGetStatus) + acc.POST("/savestatus", web_acc.AccSaveStatus) + acc.POST("/ops", web_acc.GetAccOpRecords) + acc.POST("/uppwd", web_acc.AccUpdatePwd) + } +} diff --git a/web/router/translation.go b/web/router/translation.go new file mode 100644 index 0000000..651a07f --- /dev/null +++ b/web/router/translation.go @@ -0,0 +1,24 @@ +package router + +import ( + "videoweb/web/api/web_translation" + + "github.com/gin-gonic/gin" +) + +func translationRouter(router *gin.RouterGroup) { + translation := router.Group("/translation") + { + translation.POST("/list", web_translation.GetTranslation) + translation.POST("/update", web_translation.UpdateOnetTranslation) + translation.POST("/del", web_translation.DelOnetTranslation) + translation.POST("/add", web_translation.AddOnetTranslation) + } + translationcategory := router.Group("/translationcategory") + { + translationcategory.POST("/list", web_translation.GetTranslationCategory) + translationcategory.POST("/update", web_translation.UpdateOnetTranslationCategory) + translationcategory.POST("/del", web_translation.DelOnetTranslationCategory) + translationcategory.POST("/add", web_translation.AddOnetTranslationCategory) + } +} diff --git a/web/router/user.go b/web/router/user.go new file mode 100644 index 0000000..4cb9b0f --- /dev/null +++ b/web/router/user.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/web/api/web_user" + + "github.com/gin-gonic/gin" +) + +func userRouter(router *gin.RouterGroup) { + user := router.Group("/user") + { + user.POST("/list", web_user.GetUserList) + user.POST("/update", web_user.UpdateUser) + } +} diff --git a/web/router/video.go b/web/router/video.go new file mode 100644 index 0000000..ebb8e6f --- /dev/null +++ b/web/router/video.go @@ -0,0 +1,54 @@ +package router + +import ( + "videoweb/web/api/web_video" + + "github.com/gin-gonic/gin" +) + +func videoRouter(router *gin.RouterGroup) { + advitise := router.Group("/videoinfo") + { + advitise.POST("/list", web_video.GetVideoInfo) + advitise.POST("/update", web_video.UpdateOneVideoInfo) + advitise.POST("/del", web_video.DelOneVideoInfo) + advitise.POST("/sync", web_video.SyncVideoInfo) + advitise.POST("/syncMediaFile", web_video.SyncVideoFromMediaFile) + advitise.POST("/addcoll", web_video.AddOneVideoCollection) + advitise.POST("/addTags", web_video.AddVideoTags) + advitise.POST("/tag/set", web_video.SetVideoTags) + advitise.POST("/recommend/set", web_video.SetVideoRecommend) + advitise.POST("/setFakeLikes", web_video.SetVideoFakeLikes) + advitise.POST("/setFakeWatchTimes", web_video.SetVideoFakeWatchTimes) + advitise.POST("/heat/set", web_video.SetVideoHeat) + advitise.POST("/fillZeroFakeFlow", web_video.FillZeroVideoFakeFlow) + } + videocategory := router.Group("/videocategory") + { + videocategory.POST("/list", web_video.GetVideoCategory) + videocategory.POST("/update", web_video.UpdateOneVideoCategory) + videocategory.POST("/del", web_video.DelOneVideoCategory) + videocategory.POST("/add", web_video.AddOneVideoCategory) + } + videotag := router.Group("/videotag") + { + videotag.POST("/list", web_video.GetVideoTagInfo) + videotag.POST("/update", web_video.UpdateOneVideoTagInfo) + videotag.POST("/del", web_video.DelOneVideoTagInfo) + videotag.POST("/add", web_video.AddOneVideoTagInfo) + } + + videocreater := router.Group("/videocreater") + { + videocreater.POST("/list", web_video.GetVideoCreater) + videocreater.POST("/update", web_video.UpdateOneVideoCreater) + videocreater.POST("/del", web_video.DelOneVideoCreater) + videocreater.POST("/add", web_video.AddOneVideoCreater) + } + + router.GET("/m3u8/*key", web_video.Play) + router.GET("/transm3u8/*key", web_video.TranscodeM3u8) + router.GET("/enkey", web_video.Enkey) + router.GET("/lsjenkey", web_video.LsjEnkey) + router.POST("/videoinfo/transcode", web_video.TranscodeVideo) +} diff --git a/web/router/videocollection.go b/web/router/videocollection.go new file mode 100644 index 0000000..fc96605 --- /dev/null +++ b/web/router/videocollection.go @@ -0,0 +1,15 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "videoweb/web/api/web_videocollection" +) + +func videoCollectionRouter(router *gin.RouterGroup) { + group := router.Group("/videocollection") + { + group.POST("/media/list", web_videocollection.GetVideoCollectionMediaList) + group.POST("/media/option", web_videocollection.UpdateVideoCollectionMediaList) + group.POST("/media/rank", web_videocollection.UpdateVideoCollectionMediaRank) + } +} \ No newline at end of file diff --git a/web/router/wbvideodata.go b/web/router/wbvideodata.go new file mode 100644 index 0000000..69581e8 --- /dev/null +++ b/web/router/wbvideodata.go @@ -0,0 +1,18 @@ +package router + +import ( + "videoweb/web/api/web_video" + "videoweb/web/middleware/apilimitHandler" + + "github.com/gin-gonic/gin" +) + +func wbVideoDataRouter(r *gin.Engine) { + limiter := apilimitHandler.NewRouteHourlyLimiter(100) + router := r.Group("/wbapi/hjsq") + { + router.Use(limiter.Middleware()) + router.GET("/hottag", web_video.GetVideoPlayData) + router.GET("/ipstat", web_video.GetIpStatData) + } +} diff --git a/web/server/server.go b/web/server/server.go new file mode 100644 index 0000000..33b70fd --- /dev/null +++ b/web/server/server.go @@ -0,0 +1,211 @@ +package server + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/signal" + "syscall" + "time" + "videoweb/pub/common/laosiji" + "videoweb/pub/common/log" + "videoweb/pub/common/srvversion" + "videoweb/pub/dataReport" + "videoweb/pub/esmanager" + "videoweb/pub/models" + "videoweb/pub/mongos" + "videoweb/pub/redis" + "videoweb/pub/seo/googleindex" + "videoweb/pub/storage/proxy" + "videoweb/web/router" + "videoweb/web/service" + "videoweb/web/webg" + + "gopkg.in/yaml.v2" +) + +func init() { + time.Local, _ = time.LoadLocation("Asia/Shanghai") +} +func Run(args []string) { + showVersion() + if err := initConfig(); err != nil { + log.Error("init config err", log.E(err)) + os.Exit(1) + return + } + + shopDb := mongos.NewManager(webg.Conf.Mongodb.Addr) + if err := shopDb.Init(); err != nil { + log.Error("connect shopDb err", log.E(err)) + os.Exit(1) + } + mongos.Shop = shopDb + + shopLog := mongos.NewManager(webg.Conf.Mongodb.LogAddr) + if err := shopLog.Init(); err != nil { + log.Error("connect shopLog err", log.E(err)) + os.Exit(1) + } + mongos.ShopLog = shopLog + + if err := redis.Init(redis.Options{ + Addr: webg.Conf.Redis.Addr, + Psw: webg.Conf.Redis.Passwd, + Db: webg.Conf.Redis.Db, + }); err != nil { + log.Error("redis connect err", log.E(err)) + os.Exit(1) + } + + if err := initCollections(); err != nil { + os.Exit(1) + return + } + + if err := initElasticSearch(); err != nil { + log.Error("init ES failed", log.E(err)) + os.Exit(1) + } + //初始化tg机器人 + if webg.Conf.TgToken != "" { + //tgbot.InitTgBot(webg.Conf.TgToken, "[汁乎Web]") + } + + proxy.InitBucketVideoAddress(webg.Conf.BucketVideoAddress, webg.Conf.BucketVideoAddressV2) + initLaosiji() + mongos.StartLid() + initData() + initSeoPush() + dataReport.Init(dataReport.Config{ + ApiUrl: webg.Conf.DataReport.Url, + RunMod: webg.Conf.RunMod, + }) + + initHttpServer() + + shutdownHandler() +} + +func showVersion() { + log.Info(fmt.Sprintf("sevice version:%s", srvversion.SERVICE_VERSION)) + log.Info(fmt.Sprintf("git version:%s", srvversion.GIT_VERSION)) + log.Info(fmt.Sprintf("build date:%s", srvversion.BUILD_DATE)) + log.Info(fmt.Sprintf("build ip:%s", srvversion.BUILD_IP)) +} + +func initConfig() error { + + buf, err := ioutil.ReadFile("conf/web.yaml") + if err != nil { + return err + } + + if err = yaml.Unmarshal(buf, &webg.Conf); err != nil { + return err + } + + return nil +} + +// mongo表 +func initCollections() error { + return nil +} + +// 数据 +func initData() { + models.Init() + service.Start() +} + +// 搜索引擎推送 +func initSeoPush() { + if webg.Conf.RunMod != "prod" { + return + } + googleindex.Init(context.Background()) +} + +func initElasticSearch() error { + es, err := esmanager.NewManager(webg.Conf.Es.Addr, + webg.Conf.Es.User, webg.Conf.Es.Pass) + if err != nil { + log.Info("new es failed", log.Any("err", err)) + return err + } + if err = es.ClientConnect(); err != nil { + log.Info("connect es failed", log.Any("err", err)) + return err + } + esmanager.Instance = es + log.Info("es:Init Ok!") + return nil +} + +func initHttpServer() { + + addr := fmt.Sprintf(":%s", webg.Conf.ListenAddr) + srv := &http.Server{ + Addr: addr, + Handler: router.Init(), + } + + go func() { + log.Info("服务已启动", log.Any("地址", addr)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal("listen:", log.E(err)) + } + }() +} + +func shutdownHandler() { + quit := make(chan os.Signal) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Kill) + <-quit + log.Warn("Shutdown... server") + service.Stop() + + done := make(chan struct{}) + go func() { + mongos.Shop.DisConnect() + mongos.ShopLog.DisConnect() + redis.Disconnect() + // srv_resource.ClosePrefetch() + done <- struct{}{} + }() + select { + case <-time.After(5 * time.Second): + log.Warn("shutdown timeout....") + case <-done: + log.Info("graceful shutdown") + } +} + +func initLaosiji() { + if webg.Conf.LsjMediaConf.ApiUrl == "" { + return + } + cli := laosiji.New(webg.Conf.LsjMediaConf.ApiUrl, webg.Conf.LsjMediaConf.AppId, webg.Conf.LsjMediaConf.ApiKey) + d := laosiji.NewDomains(cli) + laosiji.LaosijiClient = cli + laosiji.LaosijiDomains = d + + hr := webg.Conf.LsjMediaConf.DomainsRefreshHr + if hr <= 0 { + hr = 3 + } + d.StartAutoRefresh(time.Duration(hr) * time.Hour) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := d.Refresh(ctx); err != nil { + log.Error("Init Laosiji domains 首次拉取失败(后台重试,不影响启动)", log.E(err)) + } else { + log.Info("Init Laosiji OK...") + } + }() +} diff --git a/web/service/services.go b/web/service/services.go new file mode 100644 index 0000000..52d465c --- /dev/null +++ b/web/service/services.go @@ -0,0 +1,21 @@ +package service + +import ( + "videoweb/web/service/srv_ipblack" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/service/srv_search" + "videoweb/web/skd" +) + +func Start() { + skd.Start() + srv_ipblack.Init() + srv_mediaresource.Start() + srv_search.Start() +} + +func Stop() { + skd.Stop() + srv_mediaresource.Stop() + srv_search.Stop() +} diff --git a/web/service/srv_acc/acc.go b/web/service/srv_acc/acc.go new file mode 100644 index 0000000..1e91db4 --- /dev/null +++ b/web/service/srv_acc/acc.go @@ -0,0 +1,60 @@ +package srv_acc + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/oplog" + + "go.mongodb.org/mongo-driver/bson" +) + +func GetOpLogs(ctx context.Context, logSearch *OpLogSearch) ( + int64, OpLogInfoList, *srvdef.SrvErr) { + //log.Info("GetOpLogs Test 1") + filter, findOption := constructFilterAndOpts(logSearch) + total, serr := oplog.CountOpLogs(ctx, filter) + if serr != nil { + return 0, nil, serr + } + list := make(OpLogInfoList, 0) + if total == 0 { + return 0, list, nil + } + //log.Info("GetOpLogs Test 2", log.Any("filter", filter), log.Any("findOption", findOption)) + opLogs, serr := oplog.FindManyOpLogs(ctx, filter, findOption) + if serr != nil { + return 0, nil, serr + } + //log.Info("GetOpLogs Test 3") + actIds := make([]int64, 0) + for _, v := range opLogs { + actIds = append(actIds, v.ActId) + } + + accFilter := bson.M{mwacc.FN.MwAcc.Id: bson.M{"$in": actIds}} + accs, serr := mwacc.FindManyAccByFilter(ctx, accFilter) + if serr != nil { + return 0, nil, serr + } + + //log.Info("GetOpLogs Test 4") + for _, opLog := range opLogs { + olInfo := &OpLogInfo{ + Path: opLog.Path, + ActId: opLog.ActId, + Action: opLog.Action, + Param: opLog.RawData, + CreatedAt: opLog.CreatedAt, + } + for _, acc := range accs { + if acc.Id == olInfo.ActId { + olInfo.ActName = acc.UserName + break + } + } + list = append(list, olInfo) + } + //log.Info("GetOpLogs Test 5") + return total, list, nil +} diff --git a/web/service/srv_acc/define.go b/web/service/srv_acc/define.go new file mode 100644 index 0000000..a4430ba --- /dev/null +++ b/web/service/srv_acc/define.go @@ -0,0 +1,24 @@ +package srv_acc + +import "time" + +type OpLogInfo struct { + Path string `json:"path" bson:"path"` // 请求接口 + ActId int64 `json:"actId" bson:"actId"` // 操作人Id + Action string `json:"action" bson:"action"` // 操作目的 + Param string `json:"param" bson:"rawData"` // 操作内容 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 操作时间 + ActName string `json:"actName" bson:"actName"` // 操作人 +} + +type OpLogInfoList []*OpLogInfo + +type OpLogSearch struct { + PageNum int64 `json:"pageNum"` // 页码 + PageSize int64 `json:"pageSize"` // + Path *string `json:"path"` // 请求接口 + Action *string `json:"action"` // 操作目的 + ActId *int64 `json:"actId"` // 操作人Id + StartTime *time.Time `json:"startTime"` // 开始时间 + EndTime *time.Time `json:"endTime"` // 开始时间 +} diff --git a/web/service/srv_acc/helper.go b/web/service/srv_acc/helper.go new file mode 100644 index 0000000..367b617 --- /dev/null +++ b/web/service/srv_acc/helper.go @@ -0,0 +1,34 @@ +package srv_acc + +import ( + "videoweb/pub/models/oplog" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// constructFilterAndOpts 根据搜索参数构建查询filter和options +func constructFilterAndOpts(param *OpLogSearch) ( + bson.M, *options.FindOptions) { + + filter := bson.M{} + if param.Path != nil && *param.Path != "" { + filter[oplog.FN.OpLog.Path] = bson.M{"$regex": param.Path} + } + if param.Action != nil && *param.Action != "" { + filter[oplog.FN.OpLog.Action] = param.Action + } + if param.ActId != nil && *param.ActId > 0 { + filter[oplog.FN.OpLog.ActId] = param.ActId + } + if param.StartTime != nil && param.EndTime != nil { + filter[oplog.FN.OpLog.CreatedAt] = bson.M{"$gte": param.StartTime, "$lt": param.EndTime} + } + sort := bson.M{oplog.FN.OpLog.CreatedAt: -1} + opt := &options.FindOptions{} + opt.SetSort(sort) + skip := (param.PageNum - 1) * param.PageSize + opt.SetSkip(skip) + opt.SetLimit(param.PageSize) + return filter, opt +} diff --git a/web/service/srv_actress/actress.go b/web/service/srv_actress/actress.go new file mode 100644 index 0000000..8b2e22d --- /dev/null +++ b/web/service/srv_actress/actress.go @@ -0,0 +1,165 @@ +package srv_actress + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/actress" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func ActressList(ctx context.Context, req *ActressListReq) (*ActressListResp, *srvdef.SrvErr) { + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[actress.FN.Actress.Id] = *req.Id + } + if req.TranslationCategoryId != nil && *req.TranslationCategoryId > 0 { + filter[actress.FN.Actress.TranslationCategoryId] = *req.TranslationCategoryId + } + if req.Region != nil && len(*req.Region) > 0 { + filter[actress.FN.Actress.Region] = bson.M{"$regex": *req.Region} + } + if req.Status != nil { + filter[actress.FN.Actress.Status] = *req.Status + } + if req.Alias != nil && *req.Alias != "" { + filter[actress.FN.Actress.Alias] = bson.M{"$regex": *req.Alias} + } + count, err := actress.CountActresss(ctx, filter) + if err != nil { + log.Warn("ActressList CountActresss", log.Any("filter", filter), log.E(err)) + return nil, err + } + resp := &ActressListResp{ + Total: 0, + List: make([]*actress.Actress, 0), + } + if count == 0 { + return resp, nil + } + opts := &options.FindOptions{} + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + sort := bson.M{actress.FN.Actress.CreatedAt: -1} + switch req.SortType { + case 1: + sort = bson.M{actress.FN.Actress.Score: -1} + case 2: + sort = bson.M{actress.FN.Actress.Rank: -1} + case 3: + sort = bson.M{actress.FN.Actress.Works: -1} + case 4: + sort = bson.M{actress.FN.Actress.Real.WatchTimes: -1} + case 5: + sort = bson.M{actress.FN.Actress.HotValue: -1, actress.FN.Actress.Id: -1} + } + opts.SetSort(sort) + list, err := actress.FindManyActresss(ctx, filter, opts) + if err != nil { + log.Warn("ActressList FindManyActresss", log.Any("filter", filter), log.E(err)) + return nil, err + } + resp.Total = count + resp.List = list + return resp, nil +} + +func UpdateActress(ctx context.Context, req *ActressUpdateReq) *srvdef.SrvErr { + current, err := actress.FindOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id}) + if err != nil { + log.Warn("UpdateActress", log.Any("id", req.Id), log.E(err)) + return err + } + alias := current.Alias + if req.Param.Alias != nil { + alias = *req.Param.Alias + } + keywords := current.SeoInfo.Keyworld + if req.Param.SeoInfo != nil { + keywords = req.Param.SeoInfo.Keyworld + } + exists := func(s string) bool { + doc, _ := actress.FindOneActress(ctx, slugx.Filter(actress.FN.Actress.Slug, actress.FN.Actress.Id, s, req.Id)) + return doc != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Param.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, alias), exists) + if serr != nil { + return serr + } + if changed { + req.Param.Slug = &slug + } else { + req.Param.Slug = nil + } + now := time.Now() + req.Param.UpdatedAt = &now + return actress.UpdateOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id}, bson.M{"$set": req.Param}) +} + +func AddActress(ctx context.Context, req *ActressAddReq) *srvdef.SrvErr { + if len(req.Alias) == 0 { + return srvdef.New(srvdef.ErrParamError, "alias 不能为空", "alias 不能为空") + } + _, err := actress.FindOneActress(ctx, bson.M{ + actress.FN.Actress.Alias: req.Alias, + }) + if err != nil && err.Code != srvdef.ErrNoData { + log.Warn("AddActress", log.Any("req", req), log.E(err)) + return err + } + if err == nil { + log.Warn("AddActress Re", log.Any("req", req)) + return srvdef.New(srvdef.RepeatActress) + } + + now := time.Now() + info := &actress.Actress{ + Id: mongos.Lid.Actress.Get(), + Alias: req.Alias, + Slug: req.Slug, + SeoInfo: req.SeoInfo, + Avatar: req.Avatar, + Cover: req.Cover, + Desc: req.Desc, + TranslationCategoryId: req.TranslationCategoryId, + Meas: req.Meas, + Office: req.Office, + DebutDate: req.DebutDate, + BornDate: req.BornDate, + Region: req.Region, + Score: req.Score, + Rank: req.Rank, + HotValue: req.HotValue, + Status: req.Status, + Works: 0, + Real: pubdef.Flow{}, + CreatedAt: now, + UpdatedAt: now, + } + exists := func(s string) bool { + doc, _ := actress.FindOneActress(ctx, slugx.Filter(actress.FN.Actress.Slug, actress.FN.Actress.Id, s, 0)) + return doc != nil + } + if serr := slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Alias), exists); serr != nil { + return serr + } + return actress.InsertOneActress(ctx, info) +} + +func DeleteActress(ctx context.Context, req *ActressDelReq) *srvdef.SrvErr { + _, err := actress.FindOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id}) + if err != nil { + log.Warn("DeleteActress", log.Any("id", req.Id), log.E(err)) + return err + } + _, err = actress.DelOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id}) + return err +} diff --git a/web/service/srv_actress/define.go b/web/service/srv_actress/define.go new file mode 100644 index 0000000..f299945 --- /dev/null +++ b/web/service/srv_actress/define.go @@ -0,0 +1,69 @@ +package srv_actress + +import ( + "time" + "videoweb/pub/models/actress" + "videoweb/pub/pubdef" +) + +type ActressListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` // Id + Alias *string `form:"alias" json:"alias"` // 别名 + TranslationCategoryId *int64 `form:"translationCategoryId" json:"translationCategoryId"` // 关联导航分类id + Region *string `form:"region" json:"region"` // 地区/国家 + Status *bool `form:"status" json:"status" ` // 是否启用 + SortType int64 `form:"sortType" json:"sortType"` // 排序方式 0=创建时间 1=评分 2=排序值 3=作品数 4=总观看次数 5=热度值 +} +type ActressListResp struct { + Total int64 `json:"total"` + List []*actress.Actress `json:"list"` +} + +type ActressUpdateReq struct { + Id int64 `json:"id,omitempty" binding:"required"` + Param ActressUpdateParam `json:"param" binding:"required"` +} + +type ActressUpdateParam struct { + Alias *string `bson:"alias,omitempty" json:"alias"` // 别名 + Slug *string `json:"slug" bson:"slug,omitempty"` // slug + Avatar *string `bson:"avatar,omitempty" json:"avatar"` // 头像 + Cover *string `bson:"cover,omitempty" json:"cover"` // 封面大图 + Desc *string `bson:"desc,omitempty" json:"desc"` // 详细描述 + TranslationCategoryId *int64 `bson:"translationCategoryId,omitempty" json:"translationCategoryId"` // 关联导航分类id + Meas *actress.Meas `bson:"meas,omitempty" json:"meas"` // 三围 + Office *string `json:"office" bson:"office,omitempty"` // 事务所 + DebutDate *time.Time `bson:"debutDate,omitempty" json:"debutDate" ` // 出道日期 + BornDate *time.Time `bson:"bornDate,omitempty" json:"bornDate"` // 出生日期 + Region *string `json:"region" bson:"region,omitempty"` // 地区/国家 + Score *string `json:"score" bson:"score,omitempty"` // 评分 + Rank *int64 `json:"rank" bson:"rank,omitempty"` // 排序 + HotValue *int64 `json:"hotValue" bson:"hotValue,omitempty"` // 热度值 + Status *bool `json:"status" bson:"status,omitempty"` // 是否启用 + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + UpdatedAt *time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` // 更新时间 +} + +type ActressAddReq struct { + Alias string `bson:"alias" json:"alias"` // 别名 + Slug string `json:"slug" bson:"slug"` // slug,不传则按 SEO 关键词或别名自动生成 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Avatar string `bson:"avatar" json:"avatar"` // 头像 + Cover string `bson:"cover" json:"cover"` // 封面大图 + Desc string `bson:"desc" json:"desc"` // 详细描述 + TranslationCategoryId int64 `bson:"translationCategoryId" json:"translationCategoryId"` // 关联导航分类id + Meas actress.Meas `bson:"meas" json:"meas"` // 三围 + Office string `json:"office" bson:"office"` // 事务所 + DebutDate time.Time `bson:"debutDate" json:"debutDate" ` // 出道日期 + BornDate time.Time `bson:"bornDate" json:"bornDate"` // 出生日期 + Region string `json:"region" bson:"region"` // 地区/国家 + Score string `json:"score" bson:"score"` // 评分 + Rank int64 `json:"rank" bson:"rank"` // 排序 + HotValue int64 `json:"hotValue" bson:"hotValue"` // 热度值 + Status bool `json:"status" bson:"status"` // 是否启用 +} + +type ActressDelReq struct { + Id int64 `json:"id"` +} diff --git a/web/service/srv_announcement/announcement.go b/web/service/srv_announcement/announcement.go new file mode 100644 index 0000000..ff2f21e --- /dev/null +++ b/web/service/srv_announcement/announcement.go @@ -0,0 +1,111 @@ +package srv_announcement + +import ( + "context" + "time" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/announcementmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func GetAnnouncementList(ctx context.Context, req *GetAnnouncementListReq) (*GetAnnouncementListResp, *srvdef.SrvErr) { + filter := bson.M{} + if req.Title != nil && *req.Title != "" { + filter[announcementmod.FN.Announcement.Title] = bson.M{"$regex": *req.Title, "$options": "i"} + } + if req.Status != nil { + filter[announcementmod.FN.Announcement.Status] = *req.Status + } + + count, serr := announcementmod.CountAnnouncement(ctx, filter) + if serr != nil { + return nil, serr + } + if count == 0 { + return &GetAnnouncementListResp{ + Total: 0, + List: make(announcementmod.AnnouncementList, 0), + }, nil + } + + opts := options.FindOptions{} + opts.SetSort(bson.D{ + {Key: announcementmod.FN.Announcement.Sort, Value: -1}, + {Key: announcementmod.FN.Announcement.CreatedAt, Value: -1}, + }) + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + + list, serr := announcementmod.FindManyAnnouncement(ctx, filter, &opts) + if serr != nil { + return nil, serr + } + + return &GetAnnouncementListResp{ + Total: count, + List: list, + }, nil +} + +func AddAnnouncement(ctx context.Context, req *AddAnnouncementReq) *srvdef.SrvErr { + now := time.Now() + status := true + if req.Status != nil { + status = *req.Status + } + doc := &announcementmod.Announcement{ + Id: mongos.Lid.Announcement.Get(), + Title: req.Title, + Content: req.Content, + Sort: req.Sort, + Status: status, + CreatedAt: now, + UpdatedAt: now, + } + if err := announcementmod.InsertOneAnnouncement(ctx, doc); err != nil { + return err + } + playletcache.ClearHomeCache() + return nil +} + +func UpdateAnnouncement(ctx context.Context, req *UpdateAnnouncementReq) *srvdef.SrvErr { + filter := bson.M{announcementmod.FN.Announcement.Id: req.Id} + updateFields := bson.M{ + announcementmod.FN.Announcement.UpdatedAt: time.Now(), + } + if req.Param.Title != nil { + updateFields[announcementmod.FN.Announcement.Title] = *req.Param.Title + } + if req.Param.Content != nil { + updateFields[announcementmod.FN.Announcement.Content] = *req.Param.Content + } + if req.Param.Sort != nil { + updateFields[announcementmod.FN.Announcement.Sort] = *req.Param.Sort + } + if req.Param.Status != nil { + updateFields[announcementmod.FN.Announcement.Status] = *req.Param.Status + } + if err := announcementmod.UpdateOneAnnouncement(ctx, filter, bson.M{"$set": updateFields}); err != nil { + return err + } + playletcache.ClearHomeCache() + return nil +} + +func DelAnnouncement(ctx context.Context, req *DelAnnouncementReq) *srvdef.SrvErr { + filter := bson.M{announcementmod.FN.Announcement.Id: req.Id} + count, serr := announcementmod.DelOneAnnouncement(ctx, filter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrAnnouncementNotExsit, "", "") + } + playletcache.ClearHomeCache() + return nil +} diff --git a/web/service/srv_announcement/define.go b/web/service/srv_announcement/define.go new file mode 100644 index 0000000..27e6817 --- /dev/null +++ b/web/service/srv_announcement/define.go @@ -0,0 +1,40 @@ +package srv_announcement + +import ( + "videoweb/pub/models/announcementmod" + "videoweb/pub/pubdef" +) + +type GetAnnouncementListReq struct { + Title *string `json:"title"` // 标题,模糊搜索 + Status *bool `json:"status"` // 是否启用 + pubdef.Page +} + +type GetAnnouncementListResp struct { + Total int64 `json:"total"` + List announcementmod.AnnouncementList `json:"list"` +} + +type AddAnnouncementReq struct { + Title string `json:"title" binding:"required"` // 标题 + Content string `json:"content"` // 内容 + Sort int64 `json:"sort"` // 排序,越大越靠前 + Status *bool `json:"status"` // 是否启用 +} + +type UpdateAnnouncementReq struct { + Id int64 `json:"id" binding:"required"` + Param UpdateAnnouncementParam `json:"param"` +} + +type UpdateAnnouncementParam struct { + Title *string `json:"title"` + Content *string `json:"content"` + Sort *int64 `json:"sort"` + Status *bool `json:"status"` +} + +type DelAnnouncementReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_app/app.go b/web/service/srv_app/app.go new file mode 100644 index 0000000..73be387 --- /dev/null +++ b/web/service/srv_app/app.go @@ -0,0 +1,71 @@ +package srv_app + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +type AppCategoryInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` // Id + Name string `json:"name" bson:"name"` // 分类名称 + Title string `json:"title" bson:"title"` // APP显示 + Desc string `json:"desc" bson:"desc"` // 描述 + Rank int64 `json:"rank" bson:"rank"` // 排序 +} + +type AppCreatorInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Name string `json:"name" bson:"name"` // 帖子标题 + Sort int64 `json:"sort" bson:"sort"` // 排序 +} + +type AppTagInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + Sort int64 `json:"sort" bson:"sort"` // 排序 +} + +// AppVideoInfoSyncReq APP数据同步请求 +type AppVideoInfoSyncReq struct { + AppId int64 `json:"appId" bson:"appId"` + LastSyncTime time.Time `json:"lastSyncTime" bson:"lastSyncTime"` // 最后同步时间 + PageSize int64 `json:"pageSize" bson:"pageSize"` // 页大小 +} + +type AppVideoCollectionSyncReq struct { + AppId int64 `json:"appId" bson:"appId"` + CollectionId []int64 `json:"collectionId" bson:"collectionId"` +} + +type AppVideoCollectionSyncInfo struct { + CollectionID int64 `json:"collectionId" bson:"collectionId"` // 合集ID + VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID + Rank int64 `json:"rank" bson:"rank"` // 排序 +} + +type AppVideoSyncInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + MediaType videoinformationmod.MediaType `json:"mediaType" bson:"mediaType"` // 资源类型 + AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 帖子上架时间 + CreatorId int64 `json:"creatorId" bson:"creatorId"` // 作者Id + CreatorName string `json:"creatorName" bson:"creatorName"` // 作者名称 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Nodes []videoinformationmod.Node `json:"nodes" bson:"nodes"` // 节点 + Status pubdef.InformationStatus `json:"status" bson:"status"` // 帖子上架状态 + Video videoinformationmod.VideoInfo `json:"video" bson:"video"` // 视频 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类Id + Tags []int64 `json:"tags" bson:"tags"` // 标签 + IsHot bool `json:"isHot" bson:"isHot"` // 是否热门 + Sort int64 `json:"sort" bson:"sort"` // 排序 + Real pubdef.Flow `json:"real" bson:"real"` // 真实流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} diff --git a/web/service/srv_app/appser.go b/web/service/srv_app/appser.go new file mode 100644 index 0000000..8770042 --- /dev/null +++ b/web/service/srv_app/appser.go @@ -0,0 +1,124 @@ +package srv_app + +import ( + "context" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocollectionmediamod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" +) + +func AppVideoInfoSync(ctx context.Context, req *AppVideoInfoSyncReq) ([]*videoinformationmod.VideoInformation, *srvdef.SrvErr) { + filter := bson.M{ + videoinformationmod.FN.VideoInformation.UpdatedAt: bson.M{ + "$gt": req.LastSyncTime}, + } + opts := &options.FindOptions{} + opts.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: 1}) + opts.SetLimit(req.PageSize) + list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if err != nil { + log.Error("FindManyVideoInformations Failed", log.Any("err", err)) + return nil, err + } + return list, err +} + +func AppVideoCollectionSync(ctx context.Context, req *AppVideoCollectionSyncReq) ([]*AppVideoCollectionSyncInfo, *srvdef.SrvErr) { + filter := bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: bson.M{"$in": req.CollectionId}, + } + list, err := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, filter) + if err != nil { + log.Error("FindManyVideoCollectionMediaModels Failed", log.Any("err", err)) + return nil, err + } + rsl := make([]*AppVideoCollectionSyncInfo, 0) + for _, v := range list { + rsl = append(rsl, &AppVideoCollectionSyncInfo{ + CollectionID: v.CollectionID, + VideoId: v.VideoId, + Rank: v.Rank, + }) + } + return rsl, nil +} + +func AppCategoryList(ctx context.Context) ([]AppCategoryInfo, *srvdef.SrvErr) { + filter := bson.M{ + videocategorymod.FN.VideoCategory.Status: true, + } + opts := &options.FindOptions{} + opts.SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1}) + list, err := videocategorymod.FindManyVideoCategorys(ctx, filter, opts) + if err != nil { + log.Error("FindManyVideoCategorys Failed", log.Any("err", err)) + return nil, err + } + resp := make([]AppCategoryInfo, 0) + for _, v := range list { + resp = append(resp, AppCategoryInfo{ + Id: v.Id, + Name: v.Name, + Title: v.Title, + Desc: v.Desc, + Rank: v.Rank, + }) + } + return resp, err +} + +func AppCreatorList(ctx context.Context) ([]AppCreatorInfo, *srvdef.SrvErr) { + filter := bson.M{ + videocreatermod.FN.VideoCreaterInfo.Status: true, + } + opts := &options.FindOptions{} + opts.SetSort(bson.M{ + videocreatermod.FN.VideoCreaterInfo.Sort: -1, + }) + list, err := videocreatermod.FindManyVideoCreaterInfos(ctx, filter, opts) + if err != nil { + log.Error("FindManyVideoCreaterInfos Failed", log.Any("err", err)) + return nil, err + } + resp := make([]AppCreatorInfo, 0) + for _, v := range list { + resp = append(resp, AppCreatorInfo{ + Id: v.Id, + Name: v.Name, + Sort: v.Sort, + }) + } + return resp, err +} + +func AppTagList(ctx context.Context) ([]AppTagInfo, *srvdef.SrvErr) { + filter := bson.M{ + contenttagmod.FN.ContentTag.Status: true, + contenttagmod.FN.ContentTag.Types: contenttagmod.ContentTagTypeVideo, + } + opts := &options.FindOptions{} + opts.SetSort(bson.D{ + {Key: contenttagmod.FN.ContentTag.Rank, Value: -1}, + {Key: contenttagmod.FN.ContentTag.Id, Value: -1}, + }) + list, err := contenttagmod.FindManyContentTag(ctx, filter, opts) + if err != nil { + log.Error("FindManyContentTag Failed", log.Any("err", err)) + return nil, err + } + resp := make([]AppTagInfo, 0) + for _, v := range list { + resp = append(resp, AppTagInfo{ + Id: v.Id, + Title: v.Name, + Sort: v.Rank, + }) + } + return resp, err +} diff --git a/web/service/srv_cfg/cfg.go b/web/service/srv_cfg/cfg.go new file mode 100644 index 0000000..cb9d16e --- /dev/null +++ b/web/service/srv_cfg/cfg.go @@ -0,0 +1,76 @@ +package srv_cfg + +import ( + "context" + "fmt" + "strings" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/cfg" + + "go.mongodb.org/mongo-driver/bson" +) + +// GetSystemConfig 得到系统配置 +func GetSystemConfig(ctx context.Context) (*cfg.SysCfg, *srvdef.SrvErr) { + filter := bson.M{ + cfg.FN.SysCfg.Id: 1, //固定id + } + sysCfg, serr := cfg.FindOneSysCfg(ctx, filter) + if serr != nil { + return nil, serr + } + return sysCfg, nil +} + +// emptyWatermark 水印字段为空时也写入空字符串,避免 omitempty 跳过导致旧值残留 +func emptyWatermark(v *string) *string { + empty := "" + if v == nil || strings.TrimSpace(*v) == "" { + return &empty + } + s := strings.TrimSpace(*v) + return &s +} + +func applyEmptyWatermarkFields(info *SysCfgUpdate) { + if info == nil { + return + } + info.WatermarkImage = emptyWatermark(info.WatermarkImage) + info.WatermarkMedia = emptyWatermark(info.WatermarkMedia) + info.WatermarkMediaTop = emptyWatermark(info.WatermarkMediaTop) + info.WatermarkMediaTopRight = emptyWatermark(info.WatermarkMediaTopRight) + info.WatermarkMediaBottomLeft = emptyWatermark(info.WatermarkMediaBottomLeft) +} + +// UpdateSystemConfig 更新系统配置 +func UpdateSystemConfig(ctx context.Context, info *SysCfgUpdate) *srvdef.SrvErr { + applyEmptyWatermarkFields(info) + filter := bson.M{ + cfg.FN.SysCfg.Id: 1, //固定id + } + fmt.Println("filter", filter) + updateDoc := bson.M{"$set": info} + serr := cfg.UpdateOneSysCfg(ctx, filter, updateDoc) + if serr != nil { + return serr + } + + return nil +} + +// SetAppKey 单独更新媒资库 appKey,并刷新内存缓存。 +func SetAppKey(ctx context.Context, appKey string) *srvdef.SrvErr { + appKey = strings.TrimSpace(appKey) + filter := bson.M{ + cfg.FN.SysCfg.Id: 1, + } + updateDoc := bson.M{"$set": bson.M{ + cfg.FN.SysCfg.AppKey: appKey, + }} + if serr := cfg.UpdateOneSysCfg(ctx, filter, updateDoc); serr != nil { + return serr + } + cfg.CacheAppKey(appKey) + return nil +} diff --git a/web/service/srv_cfg/define.go b/web/service/srv_cfg/define.go new file mode 100644 index 0000000..923d7fe --- /dev/null +++ b/web/service/srv_cfg/define.go @@ -0,0 +1,114 @@ +package srv_cfg + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type CDNNode struct { + Url *string `json:"url" bson:"url,omitempty"` + Ratio *int `json:"ratio" bson:"ratio,omitempty"` +} + +type SysCfgUpdate struct { + UnencryptedCdn *string `json:"unencryptedCdn" bson:"unencryptedCdn,omitempty"` // 不加密资源使用的cdn + SitemapDefaultImages *[]string `json:"sitemapDefaultImages" bson:"sitemapDefaultImages,omitempty"` // Sitemap里面默认封面图 + PdfAddr *string `json:"pdfAddr" bson:"pdfAddr"` // 回家的路PDF + ImgAppCdn *string `json:"imgAppCdn" bson:"imgAppCdn"` // 图片CDN(app使用) + ImgAppUsCdn *string `json:"imgAppUsCdn" bson:"imgAppUsCdn"` // 图片CDN(海外app使用) + ImgWebCdn *string `json:"imgWebCdn" bson:"imgWebCdn"` // 图片CDN(web使用) + MediaWebCdn *string `json:"mediaWebCdn" bson:"mediaWebCdn"` // 视频CDN(web使用) + MediaAppCdn *string `json:"mediaAppCdn" bson:"mediaAppCdn"` // 视频CDN(app使用) + MediaAppUsCdn *string `json:"mediaAppUsCdn" bson:"mediaAppUsCdn"` // 视频CDN(海外app使用) + WatermarkImage *string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件 + WatermarkMedia *string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件(底部右边) + WatermarkMediaTop *string `json:"watermarkMediaTop" bson:"watermarkMediaTop"` // 视频水印文件(顶部左边) + WatermarkMediaTopRight *string `json:"watermarkMediaTopRight" bson:"watermarkMediaTopRight"` // 视频水印文件(顶部右边) + WatermarkMediaBottomLeft *string `json:"watermarkMediaBottomLeft" bson:"watermarkMediaBottomLeft"` // 视频水印文件(底部左边) + Mp4Addr *string `json:"mp4Addr" bson:"mp4Addr"` // mp4地址(web使用) + NewUrlAddr *string `json:"newUrlAddr" bson:"newUrlAddr"` //最新地址 + NewUrlAddr1 *string `json:"newUrlAddr1" bson:"newUrlAddr1"` //最新地址(备用地址1) + NewUrlAddr2 *string `json:"newUrlAddr2" bson:"newUrlAddr2"` //最新地址(备用地址2) + ForeverUrlAddr *string `json:"foreverUrlAddr" bson:"foreverUrlAddr"` //永久地址 + GmailAddr *string `json:"gmailAddr" bson:"gmailAddr"` //邮箱地址 + AppAddr *string `json:"appAddr" bson:"appAddr"` //APP下载地址(落地页) + DownloadVideoUrl *string `json:"downloadVideoUrl" bson:"downloadVideoUrl"` // 下载视频URL + WeiChart *string `json:"weiChart" bson:"weiChart"` //微信群 + QQ *string `json:"qq" bson:"qq"` //qq群 + TgGroup *string `json:"tgGroup" bson:"tgGroup"` //tg群 + PotaoGroup *string `json:"potaoGroup" bson:"potaoGroup"` //土豆群 + GitAddr *string `json:"gitAddr" bson:"gitAddr"` //git地址 + TwitterAddr *string `json:"twitterAddr" bson:"twitterAddr"` //推特地址 + Telegram *string `json:"telegram" bson:"telegram"` //商务tg + TelegramAdd *string `json:"telegramAdd" bson:"telegramAdd"` //加入我们tg + TranslationAddr *string `json:"translationAddr" bson:"translationAddr"` //应用导航 + //SEO + WebTitle *string `json:"webTitle" bson:"webTitle"` // 网站SEO标题 + WebKeyworld *[]string `json:"webKeyworld" bson:"webKeyworld"` // 网站SEO关键字 + WebDescription *string `json:"webDescription" bson:"webDescription"` // 网站SEO描述 + WebSiteName *string `json:"webSiteName" bson:"webSiteName"` // 网站SEO网站名称 + WebAddr *string `json:"webAddr" bson:"webAddr"` // 网站SEO网站地址 + DHNewUrlAddr1 *string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1"` // 导航页-线路一 + DHNewUrlAddr2 *string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2"` // 导航页-线路2 + DHNewUrlAddr3 *string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3"` // 导航页-线路3 + DHTitle *string `json:"dhTitle" bson:"dhTitle"` // 导航SEO标题 + DHKeyworld *[]string `json:"dhKeyworld" bson:"dhKeyworld"` // 导航SEO关键字 + DHDescription *string `json:"dhDescription" bson:"dhDescription"` // 导航SEO描述 + DHSiteName *string `json:"dhSiteName" bson:"dhSiteName"` // 导航SEO网站名称 + ChannelCode *string `json:"channelCode" bson:"channelCode"` // 渠道码 + EnableH265 *bool `json:"enableH265" bson:"enableH265"` // 是否允许客户端使用 H265 地址播放 + M3U8DayRateLimitMax *int64 `json:"m3u8DayRateLimitMax" bson:"m3u8DayRateLimitMax"` // 每 IP 每天最多不同正片数,<=0 使用默认 80 + + // Acc2UserCDN *string `json:"acc2UserCDN" bson:"acc2UserCDN"` // up主视频播放CDN + // InviteAddr *string `json:"inviteAddr" bson:"inviteAddr"` // 用户邀请地址 + // TelegramContact *string `json:"telegramContact" bson:"telegramContact"` // Telegram联系方式 + // PotatoContact *string `json:"potatoContact" bson:"potatoContact"` // Potato联系方式 + // DriverGroup *string `json:"driverGroup" bson:"driverGroup"` // 开车群 + // CoinPrice *int64 `json:"coinPrice" bson:"coinPrice"` // 金币单价,单位分 + // OfficialEmail *string `json:"officialEmail" bson:"officialEmail"` // 官方邮箱 + // HeaderM3U8 *string `json:"headerM3U8" bson:"headerM3U8"` // 片头m3u8 + // AppCenter *string `json:"appCenter" bson:"appCenter"` // 应用中心 + // PayGuidPageUrl *string `json:"payGuidPageUrl" bson:"payGuidPageUrl"` // 支付引导页面地址 + // ApiLimit *cfg.ApiCallLimit `json:"apiLimit" bson:"apiLimit"` // 访问接口和频率参数限制(每10秒) + // H5ShareUrl *string `json:"h5ShareUrl" bson:"h5ShareUrl"` // H5分享域名 + // OnlineNum *int64 `json:"onlineNum" bson:"onlineNum"` // 在线人数 + // Mp4Url *string `json:"mp4Url" bson:"mp4Url"` // MP4域名 + // HotScoreConf *cfg.HotScoreConf `json:"hotScoreConf" bson:"hotScoreConf"` // 视频热度值配置 + // MinimumDiscount *int64 `json:"minimumDiscount" bson:"minimumDiscount"` // 用户最低折扣 + // AndroidGuestKey *string `json:"androdiGuestkey" bson:"androdiGuestkey"` // android注册签名 + // H5GuestKey *string `json:"h5GuestKey" bson:"h5GuestKey"` // h5注册签名 + // CustomerServiceHost *string `json:"customerServiceHost" bson:"customerServiceHost"` // 客服Host + // AdsType *cfg.AdsTypeConfig `json:"adsType" bson:"adsType"` // 广告模式 + // FloatingWindow *FloatingWindow `json:"floatingWindow" bson:"floatingWindow"` // 悬浮窗 + // IsLoliBlur *bool `json:"isLoliBlur" bson:"isLoliBlur,omitempty"` // 是否开启站群蒙版 + // ChargeGiftChannel *string `json:"chargeGiftChannel" bson:"chargeGiftChannel"` // 充值赠送金币的渠道 + // ChargeGiftGoldNumber *int64 `json:"chargeGiftGoldNumber" bson:"chargeGiftGoldNumber"` // 充值赠送金币数量 + // AdminTG *string `json:"adminTG" bson:"adminTG"` // 管理员tg + // Theme *cfg.Theme `json:"theme" bson:"theme"` +} +type SysCfgMapParam struct { + Key string `json:"key" bson:"key"` //Key + StringVal string `json:"stringVal" bson:"stringVal"` //字符串的值 + IntVal int64 `json:"intVal" bson:"intVal"` //int64的值 + Desc string `json:"desc" bson:"desc"` //配置描述 +} + +type UpdateSysCfgMapReq struct { + Id primitive.ObjectID `json:"id" bson:"_id"` //ID + Param *SysCfgMapParam `json:"param" bson:"param"` +} + +type DeleteSysCfgMapReq struct { + Id primitive.ObjectID `json:"id" bson:"_id"` //ID +} + +type GetSysCfgMapReq struct { + Key *string `json:"key" bson:"key"` + Desc *string `json:"desc" bson:"desc"` +} + +type FloatingWindow struct { + Enable bool `json:"enable" bson:"enable"` + Image string `json:"image" bson:"image"` //浮窗图片 + Url string `json:"url" bson:"url"` //浮窗URL + ImageClose string `json:"imageClose" bson:"imageClose"` //浮窗图片 +} diff --git a/web/service/srv_commend/commend.go b/web/service/srv_commend/commend.go new file mode 100644 index 0000000..c23b266 --- /dev/null +++ b/web/service/srv_commend/commend.go @@ -0,0 +1,417 @@ +package srv_commend + +import ( + "context" + "strconv" + "time" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/commentlikemod" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "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" +) + +func SearchComandList(ctx context.Context, param *CommendSearch) (*CommendSearchResp, *srvdef.SrvErr) { + + filter := bson.M{} + opt := &options.FindOptions{} + + if param.ObjectId != nil { + filter[commentmod.FN.Comment.ObjectId] = *param.ObjectId + } + if param.ObjectType != nil { + filter[commentmod.FN.Comment.ObjectType] = *param.ObjectType + } + if param.Level != nil { + filter[commentmod.FN.Comment.Level] = *param.Level + } + + if param.UserName != nil { + filter[commentmod.FN.Comment.UserName] = bson.M{"$regex": *param.UserName} + } + if param.Text != nil { + filter[commentmod.FN.Comment.Text] = bson.M{"$regex": *param.Text} + } + + if param.ReviewStatus != nil { + filter[commentmod.FN.Comment.ReviewStatus] = *param.ReviewStatus + } + + sort := bson.D{ + {commentmod.FN.Comment.TopRank, -1}, + {commentmod.FN.Comment.CreatedAt, -1}, + } + + opt.SetSort(sort) + skip := (param.PageNum - 1) * param.PageSize + opt.SetSkip(skip) + opt.SetLimit(param.PageSize) + + total, serr := commentmod.CountComments(ctx, filter) + if serr != nil { + return nil, serr + } + if total == 0 { + return &CommendSearchResp{}, nil + } + + commends, serr := commentmod.FindManyComment(ctx, filter, opt) + if serr != nil { + return nil, serr + } + + resp := &CommendSearchResp{ + Total: total, + List: make([]*PostCommentInfo, 0), + } + + for _, commendInfo := range commends { + tmp := &PostCommentInfo{ + Id: commendInfo.Id, + ObjectId: commendInfo.ObjectId, + ObjectName: commendInfo.ObjectName, + UserName: commendInfo.UserName, + Level: commendInfo.Level, + ParentsId: commendInfo.ParentsId, + ReplyUserName: commendInfo.ReplyUserName, + Text: commendInfo.Text, + ReviewStatus: commendInfo.ReviewStatus, + UpdatedAt: commendInfo.UpdatedAt, + Ip: commendInfo.Ip, + CreatedAt: commendInfo.CreatedAt, + ObjectType: commendInfo.ObjectType, + Likes: commendInfo.CountLike, + } + resp.List = append(resp.List, tmp) + } + + return resp, nil +} + +// DelCommend 删除一条评论 +func DelCommend(ctx context.Context, commendId int64) *srvdef.SrvErr { + commendInfo, serr := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: commendId}) + if serr != nil { + return serr + } + _, serr = commentmod.DelOneComment(ctx, bson.M{commentmod.FN.Comment.Id: commendId}) + if serr != nil { + return serr + } + if serr = commentlikemod.DeleteByCommentIds(ctx, []int64{commendId}); serr != nil { + return serr + } + clearCommentListCacheByComments(commentmod.CommentModelList{commendInfo}) + + if err := refreshParentReplyCounts(ctx, commentmod.CommentModelList{commendInfo}); err != nil { + return err + } + return updateCommentCountByComments(ctx, commentmod.CommentModelList{commendInfo}) +} + +// DelCommend 删除用户所有评论 +func DelUserCommend(ctx context.Context, ip string) *srvdef.SrvErr { + commendList, serr := commentmod.FindManyComment(ctx, bson.M{commentmod.FN.Comment.Ip: ip}) + if serr != nil { + return serr + } + _, serr = commentmod.DelManyComment(ctx, bson.M{commentmod.FN.Comment.Ip: ip}) + if serr != nil { + return serr + } + if serr = commentlikemod.DeleteByCommentIds(ctx, commentIdsFromModels(commendList)); serr != nil { + return serr + } + clearCommentListCacheByComments(commendList) + + if err := refreshParentReplyCounts(ctx, commendList); err != nil { + return err + } + return updateCommentCountByComments(ctx, commendList) +} + +func commentIdsFromModels(comments commentmod.CommentModelList) []int64 { + ids := make([]int64, 0, len(comments)) + for _, comment := range comments { + if comment != nil { + ids = append(ids, comment.Id) + } + } + return ids +} + +// AddPostComment 发布评论 +func AddPostComment(ctx context.Context, req *CommendReplay) *srvdef.SrvErr { + now := time.Now() + if req.Score > 10 { + log.Error("AddPostComment ErrParamError", log.Any("Score", req.Score)) + return srvdef.New(srvdef.ErrParamError, "", "") + } + + // 2. 生成评论相关信息 + commentInfo := &commentmod.CommentModel{ + Id: mongos.Lid.Comment.Get(), + ObjectId: req.ObjectId, + ObjectType: pubdef.CommentObjectTypeVideo, + ObjectName: pubdef.CommentObjectTypeName(pubdef.CommentObjectTypeVideo), + UserName: req.UserName, + ParentsId: req.ParentsId, + Text: req.Text, + Level: 1, + UpdatedAt: now, + CreatedAt: now, + } + + if commentInfo.ParentsId > 0 { + commentInfo.Level = 2 + } else { + commentInfo.Level = 1 + } + commentInfo.ReviewStatus = 1 // 管理端的默认审核通过 + + // 3. 插入数据库 + serr := commentmod.InsertOneComment(ctx, commentInfo) + if serr != nil { + log.Error("AddPostComment InsertOnePostComment", log.Any("req", req), log.Any("err", serr)) + return serr + } + clearCommentListCacheByComments(commentmod.CommentModelList{commentInfo}) + + go func() { + _ = updateCommentCountByComments(ctx, commentmod.CommentModelList{commentInfo}) + + // 4. 修改回复条数 + if req.ParentsId > 0 { + filter := bson.M{ + commentmod.FN.Comment.Id: req.ParentsId, + } + updateDoc := bson.M{ + "$inc": bson.M{ + commentmod.FN.Comment.CommentCount: 1, + }, + } + serr = commentmod.UpdateOneComment(ctx, filter, updateDoc) + if serr != nil { + log.Error("AddPostComment UpdateOnePostComment", log.Any("req", req), log.Any("err", serr)) + return + } + } + + }() + return nil +} + +// 置顶评论 +func TopPostComment(ctx context.Context, id int64) *srvdef.SrvErr { + filter := bson.M{ + commentmod.FN.Comment.Id: id, + } + comment, serr := commentmod.FindOneComment(ctx, filter) + if serr != nil { + return serr + } + //获取置顶的一条评论 + topRankComment, serr := getTopRankComment(ctx, comment.ObjectId) + if serr != nil { + return serr + } + if topRankComment.Id == id && topRankComment.TopRank != 0 { + return nil + } + serr = commentmod.UpdateOneComment(ctx, filter, bson.M{ + "$set": bson.M{ + commentmod.FN.Comment.TopRank: topRankComment.TopRank + 1, + }, + }) + if serr != nil { + return serr + } + clearCommentListCacheByComments(commentmod.CommentModelList{comment}) + return nil +} + +func getTopRankComment(ctx context.Context, objectId int64) (*commentmod.CommentModel, *srvdef.SrvErr) { + filter := bson.M{ + commentmod.FN.Comment.ObjectId: objectId, + } + opts := options.FindOne().SetSort(bson.M{ + commentmod.FN.Comment.TopRank: -1, + }) + comment, serr := commentmod.FindOneComment(ctx, filter, opts) + if serr != nil { + return nil, serr + } + return comment, nil +} + +func ReviewComment(ctx context.Context, id int64, reviewStatus int) *srvdef.SrvErr { + comment, err := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: id}) + if err != nil { + return err + } + updateDoc := bson.M{ + commentmod.FN.Comment.ReviewStatus: reviewStatus, + } + filter := bson.M{ + commentmod.FN.Comment.Id: id, + } + serr := commentmod.UpdateOneComment(ctx, filter, bson.M{"$set": updateDoc}) + if serr != nil { + log.Error("审核评论失败", log.Any("id", id), log.Any("reviewStatus", reviewStatus), log.Any("err", serr)) + return serr + } + + clearCommentListCacheByComments(commentmod.CommentModelList{comment}) + if comment.ReviewStatus != reviewStatus { + if err := refreshParentReplyCounts(ctx, commentmod.CommentModelList{comment}); err != nil { + return err + } + } + return updateCommentCountByComments(ctx, commentmod.CommentModelList{comment}) +} + +// ReviewCommendBatch 批量更新 +func ReviewCommendBatch(ctx context.Context, commendIds *[]int64, reviewStatus int) *srvdef.SrvErr { + if commendIds == nil || len(*commendIds) == 0 { + return srvdef.New(srvdef.ErrParamError, "", "") + } + commentList, err := commentmod.FindManyComment(ctx, bson.M{ + commentmod.FN.Comment.Id: bson.M{"$in": *commendIds}, + }) + if err != nil { + return err + } + updateDoc := bson.M{ + commentmod.FN.Comment.ReviewStatus: reviewStatus, + } + filter := bson.M{commentmod.FN.Comment.Id: bson.M{"$in": commendIds}} + serr := commentmod.UpdateManyComment(ctx, filter, bson.M{"$set": updateDoc}) + if serr != nil { + log.Error("批量审核评论失败", log.Any("commendId", *commendIds), log.Any("reviewStatus", reviewStatus), log.Any("err", serr)) + return serr + } + + clearCommentListCacheByComments(commentList) + if err := refreshParentReplyCounts(ctx, commentList); err != nil { + return err + } + return updateCommentCountByComments(ctx, commentList) +} + +// refreshParentReplyCounts 按已审核的直接回复重算父评论回复数 +func refreshParentReplyCounts(ctx context.Context, comments commentmod.CommentModelList) *srvdef.SrvErr { + seen := make(map[int64]struct{}, len(comments)) + for _, comment := range comments { + if comment == nil || comment.ParentsId <= 0 { + continue + } + if _, ok := seen[comment.ParentsId]; ok { + continue + } + seen[comment.ParentsId] = struct{}{} + count, err := commentmod.CountComments(ctx, bson.M{ + commentmod.FN.Comment.ParentsId: comment.ParentsId, + commentmod.FN.Comment.ReviewStatus: 1, + }) + if err != nil { + return err + } + if err := commentmod.UpdateOneComment(ctx, + bson.M{commentmod.FN.Comment.Id: comment.ParentsId}, + bson.M{"$set": bson.M{commentmod.FN.Comment.CommentCount: count}}); err != nil && err.Code != srvdef.ErrNoData { + return err + } + } + return nil +} + +// clearCommentListCacheByComments 按对象ID和对象类型精确清理评论列表缓存 +func clearCommentListCacheByComments(commentList commentmod.CommentModelList) { + seen := make(map[string]struct{}, len(commentList)) + for _, comment := range commentList { + if comment == nil { + continue + } + key := commentCacheUniqueKey(comment.ObjectId, comment.ObjectType) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + cacheKey := redisconst.CommentListCacheKey(comment.ObjectId, comment.ObjectType) + if err := redis.Del(cacheKey); err != nil { + log.Warn("清理评论列表缓存失败", log.Any("cacheKey", cacheKey), log.E(err)) + } + } +} + +// commentCacheUniqueKey 生成对象维度去重key,避免批量审核时重复清理同一对象缓存 +func commentCacheUniqueKey(objectId int64, objectType pubdef.CommentObjectType) string { + return strconv.FormatInt(objectId, 10) + "_" + strconv.FormatInt(int64(objectType), 10) +} + +// UpdateCommentCount 评论审核通过/删除后,同步更新对应媒体内容的真实评论数 +// objectType: 0=视频 4=短剧 11=短剧剧集 +func UpdateCommentCount(ctx context.Context, commendIds []int64) *srvdef.SrvErr { + commentList, _ := commentmod.FindManyComment(ctx, bson.M{ + commentmod.FN.Comment.Id: bson.M{"$in": commendIds}, + }) + return updateCommentCountByComments(ctx, commentList) +} + +func updateCommentCountByComments(ctx context.Context, commentList commentmod.CommentModelList) *srvdef.SrvErr { + seen := make(map[string]struct{}, len(commentList)) + for _, comment := range commentList { + if comment == nil { + continue + } + key := commentCacheUniqueKey(comment.ObjectId, comment.ObjectType) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + count, countErr := commentmod.CountComments(ctx, bson.M{ + commentmod.FN.Comment.ObjectId: comment.ObjectId, + commentmod.FN.Comment.ObjectType: comment.ObjectType, + commentmod.FN.Comment.ReviewStatus: 1, + }) + if countErr != nil { + return countErr + } + var serr *srvdef.SrvErr + switch comment.ObjectType { + case pubdef.CommentObjectTypeVideo: + serr = videoinformationmod.UpdateOneVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: comment.ObjectId}, + bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.Real.Comments: count}}, + ) + case pubdef.CommentObjectTypePlaylet: + serr = playletmod.UpdateOnePlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: comment.ObjectId}, + bson.M{"$set": bson.M{playletmod.FN.Playlet.CountComment: count}}, + ) + case pubdef.CommentObjectTypePlayletChapter: + serr = playletchaptermod.UpdateOnePlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.Id: comment.ObjectId}, + bson.M{"$set": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: count}}, + ) + } + if serr != nil { + log.Error("UpdateCommentCount 更新评论数失败", + log.Any("objectId", comment.ObjectId), + log.Any("objectType", comment.ObjectType), + log.Any("err", serr), + ) + return serr + } + } + return nil +} diff --git a/web/service/srv_commend/define.go b/web/service/srv_commend/define.go new file mode 100644 index 0000000..161d274 --- /dev/null +++ b/web/service/srv_commend/define.go @@ -0,0 +1,73 @@ +package srv_commend + +import ( + "time" + "videoweb/pub/pubdef" +) + +// CommendSearch 查询评论 +type CommendSearch struct { + ObjectId *int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectType *pubdef.CommentObjectType `json:"objectType" bson:"objectType"` // 对象类型 0=视频 4=短剧 11=短剧剧集 + UserName *string `json:"userName" bson:"userName"` // 用户名称 + Level *int64 `json:"level" bson:"level"` // 评论级别 + Text *string `json:"text" bson:"text"` // 评论内容 + ReviewStatus *int `json:"reviewStatus"` + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` +} + +type PostCommentInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID + Ip string `json:"ip" bson:"ip"` // 评论IP + ObjectType pubdef.CommentObjectType `json:"objectType" bson:"objectType"` // 对象类型 0=视频 4=短剧 11=短剧剧集 + ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectName string `json:"objectName" bson:"objectName"` // 对象类型名称 视频/短剧 + UserName string `json:"userName" bson:"userName"` // 用户名称 + Level int64 `json:"level" bson:"level"` // 评论级别 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + ReplyUserName string `json:"replyUserName" bson:"replyUserName"` // 回复名称 + Text string `json:"text" bson:"text"` // 评论内容 + ReviewStatus int `json:"reviewStatus" bson:"reviewStatus"` // 审核状态 0 未审核 1审核成功 2 审核失败 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // 创建时间 + TopRank int64 `json:"topRank" bson:"topRank"` // 置顶排序 + Likes int64 `json:"likes" bson:"likes"` // 点赞数 + Status pubdef.UserStatus `json:"status" bson:"status"` // 评论用户的状态 +} + +type CommendSearchResp struct { + Total int64 `json:"total"` + List []*PostCommentInfo `json:"list"` +} + +type CommendDelete struct { + Id *int64 `json:"id" bson:"_id" binding:"required"` // Id +} + +type CommendReplay struct { + ObjectId int64 `json:"objectId"` // 对象ID + UserName string `json:"userName"` // 用户名 + ParentsId int64 `json:"parentsId"` // 评论ID + Text string `json:"text"` // 评论内容 + Score int64 `json:"score"` // 评分 +} + +type CommendUpdate struct { + Score *int64 `json:"score" bson:"score,omitempty"` // 评分 + Likes *int64 `json:"likes" bson:"likes,omitempty"` // 点赞数量 +} + +type CommendUpdateReq struct { + Param CommendUpdate `json:"param" bson:"param"` // 参数 + Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID +} + +type CommendTopReq struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID +} + +type CommendReviewReq struct { + Id int64 `json:"id"` + ReviewStatus int `json:"reviewStatus"` +} diff --git a/web/service/srv_contactus/contactus.go b/web/service/srv_contactus/contactus.go new file mode 100644 index 0000000..7f65cfe --- /dev/null +++ b/web/service/srv_contactus/contactus.go @@ -0,0 +1,68 @@ +package srv_contactus + +import ( + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/contactusmod" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func GetContactusList(ctx *gin.Context, req *GetContactusListReq) (resp *GetContactusListResp, err *srvdef.SrvErr) { + filter := bson.M{} + if req.CategoryName != nil && len(*req.CategoryName) > 0 { + filter[contactusmod.FN.ContactUsModel.CategoryName] = *req.CategoryName + } + if req.Ip != nil && len(*req.Ip) > 0 { + filter[contactusmod.FN.ContactUsModel.Ip] = *req.Ip + } + if req.Name != nil && len(*req.Name) > 0 { + filter[contactusmod.FN.ContactUsModel.Name] = *req.Name + } + if req.Email != nil && len(*req.Email) > 0 { + filter[contactusmod.FN.ContactUsModel.Email] = *req.Email + } + var total int64 + total, err = contactusmod.CountContactUsModels(ctx, filter) + if err != nil { + log.Warn("GetContactusList.CountContactUsModels", log.Any("filter", filter), log.E(err)) + return + } + resp = &GetContactusListResp{ + Total: 0, + List: make(contactusmod.ContactUsModelList, 0), + } + if total == 0 { + return resp, nil + } + opts := &options.FindOptions{} + opts.SetSort(bson.M{contactusmod.FN.ContactUsModel.CreatedAt: -1}) + opts.SetLimit(req.PageSize).SetSkip((req.PageNum - 1) * req.PageSize) + list, err := contactusmod.FindManyContactUsModel(ctx, filter, opts) + if err != nil { + log.Warn("GetContactusList.FindManyContactUsModels", log.Any("filter", filter), log.E(err)) + return + } + resp.List = list + return resp, nil +} +func DelContactus(ctx *gin.Context, id primitive.ObjectID) *srvdef.SrvErr { + if id.IsZero() { + return srvdef.New(srvdef.ErrParamError) + } + _, err := contactusmod.DelOneContactUsModel(ctx, bson.M{contactusmod.FN.ContactUsModel.Id: id}) + return err +} + +func UpdateContactus(ctx *gin.Context, id primitive.ObjectID, status int64) *srvdef.SrvErr { + if id.IsZero() { + return srvdef.New(srvdef.ErrParamError) + } + err := contactusmod.UpdateOneContactUsModel(ctx, bson.M{contactusmod.FN.ContactUsModel.Id: id}, bson.M{ + "$set": bson.M{contactusmod.FN.ContactUsModel.Status: status}, + }) + return err +} diff --git a/web/service/srv_contactus/define.go b/web/service/srv_contactus/define.go new file mode 100644 index 0000000..bc92b15 --- /dev/null +++ b/web/service/srv_contactus/define.go @@ -0,0 +1,30 @@ +package srv_contactus + +import ( + "videoweb/pub/models/contactusmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type GetContactusListReq struct { + pubdef.Page + Ip *string `form:"ip" json:"ip"` // ip + Name *string `form:"name" json:"name"` // 姓名 + Email *string `form:"email" json:"email"` // 邮箱 + CategoryName *string `form:"categoryName" json:"categoryName"` // 分类 +} + +type GetContactusListResp struct { + Total int64 `json:"total"` + List contactusmod.ContactUsModelList `json:"list"` +} + +type DelOneContactusReq struct { + Id primitive.ObjectID `form:"id" json:"id"` +} + +type UpdateContactusReq struct { + Id primitive.ObjectID `form:"id" json:"id"` + Status int64 `form:"status" json:"status"` +} diff --git a/web/service/srv_contentexcel/contentexcel.go b/web/service/srv_contentexcel/contentexcel.go new file mode 100644 index 0000000..aa25010 --- /dev/null +++ b/web/service/srv_contentexcel/contentexcel.go @@ -0,0 +1,325 @@ +package srv_contentexcel + +import ( + "context" + "fmt" + "io" + "math/rand" + "strconv" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const queryBatchSize = 500 + +var importAddedTimeStart = time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local) + +func randomImportAddedTime(r *rand.Rand) time.Time { + now := time.Now() + if !now.After(importAddedTimeStart) { + return now + } + if r == nil { + r = rand.New(rand.NewSource(time.Now().UnixNano())) + } + delta := now.UnixNano() - importAddedTimeStart.UnixNano() + if delta <= 0 { + return now + } + return importAddedTimeStart.Add(time.Duration(r.Int63n(delta + 1))) +} + +// Export 按条件导出视频 SEO 字段为 Excel。 +func Export(ctx context.Context, req *ExportReq) ([]byte, string, *srvdef.SrvErr) { + contentType, serr := normalizeContentType(req.Type) + if serr != nil { + return nil, "", serr + } + + dateFilter, serr := buildTimeRangeFilter(req.StartTime, req.EndTime) + if serr != nil { + return nil, "", serr + } + + ids, serr := normalizeExportIDs(req.Ids) + if serr != nil { + return nil, "", serr + } + + var rows []ContentRow + var err error + switch contentType { + case ContentTypeVideo: + rows, err = queryVideoRows(ctx, dateFilter, req.Status, ids) + } + if err != nil { + return nil, "", srvdef.New(srvdef.ExcelExportErr, err.Error()) + } + + data, err := buildWorkbook(rows) + if err != nil { + return nil, "", srvdef.New(srvdef.ExcelExportErr, err.Error()) + } + + filename := fmt.Sprintf("%s_content_%s.xlsx", contentType, time.Now().Format("20060102150405")) + return data, filename, nil +} + +// Import 读取 Excel 并按 ID 批量更新标题、描述、关键词。 +func Import(ctx context.Context, contentType string, reader io.Reader) (*ImportResult, *srvdef.SrvErr) { + normalizedType, serr := normalizeContentType(contentType) + if serr != nil { + return nil, serr + } + + data, err := io.ReadAll(reader) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "读取上传文件失败") + } + + rows, err := parseWorkbook(data) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, err.Error()) + } + + result := &ImportResult{Total: len(rows)} + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for _, row := range rows { + var updateErr *srvdef.SrvErr + switch normalizedType { + case ContentTypeVideo: + updateErr = updateVideoRow(ctx, row, r) + } + if updateErr != nil { + result.Failed++ + result.Errors = append(result.Errors, fmt.Sprintf("ID=%d: %s", row.ID, updateErr.Tips)) + continue + } + result.Success++ + } + return result, nil +} + +func normalizeContentType(raw string) (string, *srvdef.SrvErr) { + switch stringsTrimLower(raw) { + case ContentTypeVideo: + return stringsTrimLower(raw), nil + default: + return "", srvdef.New(srvdef.ErrParamError, "type 仅支持 video") + } +} + +func stringsTrimLower(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +func buildTimeRangeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) { + filter := bson.M{} + start, err := parseOptionalTime(startTime, false) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "startTime 格式错误,支持 2006-01-02 或 2006-01-02 15:04:05") + } + end, err := parseOptionalTime(endTime, true) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "endTime 格式错误,支持 2006-01-02 或 2006-01-02 15:04:05") + } + if start != nil && end != nil && start.After(*end) { + return nil, srvdef.New(srvdef.ErrParamError, "startTime 不能晚于 endTime") + } + if start != nil { + filter["$gte"] = *start + } + if end != nil { + filter["$lte"] = *end + } + if len(filter) == 0 { + return nil, nil + } + return filter, nil +} + +func parseOptionalTime(raw string, endOfDay bool) (*time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + time.RFC3339, + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return &t, nil + } + } + if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil { + if endOfDay { + end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()) + return &end, nil + } + return &t, nil + } + return nil, fmt.Errorf("invalid time: %s", raw) +} + +func applyTimeFilter(base bson.M, field string, rangeFilter bson.M) bson.M { + if len(rangeFilter) == 0 { + return base + } + base[field] = rangeFilter + return base +} + +func applyStatusFilter(base bson.M, field string, status *pubdef.InformationStatus) bson.M { + if status == nil { + return base + } + base[field] = *status + return base +} + +func applyIDsFilter(base bson.M, field string, ids []int64) bson.M { + if len(ids) == 0 { + return base + } + base[field] = bson.M{"$in": ids} + return base +} + +// ParseExportIDs 解析导出接口的 ids 查询参数,支持 ids=1&ids=2 或 ids=1,2,3。 +func ParseExportIDs(raw []string) ([]int64, *srvdef.SrvErr) { + if len(raw) == 0 { + return nil, nil + } + + ids := make([]int64, 0, len(raw)) + seen := make(map[int64]struct{}, len(raw)) + for _, item := range raw { + item = strings.TrimSpace(item) + if item == "" { + continue + } + for _, part := range strings.Split(item, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil || id <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids 格式错误,需为正整数") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + return ids, nil +} + +func normalizeExportIDs(raw []int64) ([]int64, *srvdef.SrvErr) { + if len(raw) == 0 { + return nil, nil + } + + ids := make([]int64, 0, len(raw)) + seen := make(map[int64]struct{}, len(raw)) + for _, id := range raw { + if id <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids 格式错误,需为正整数") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids, nil +} + +func queryVideoRows(ctx context.Context, dateFilter bson.M, status *pubdef.InformationStatus, ids []int64) ([]ContentRow, error) { + filter := applyIDsFilter(applyStatusFilter(applyTimeFilter(bson.M{ + videoinformationmod.FN.VideoInformation.MediaType: bson.M{ + "$in": []videoinformationmod.MediaType{ + videoinformationmod.MediaType_Video, + videoinformationmod.MediaType_AV, + }, + }, + }, videoinformationmod.FN.VideoInformation.CreatedAt, dateFilter), + videoinformationmod.FN.VideoInformation.Status, status), + videoinformationmod.FN.VideoInformation.Id, ids) + + return queryRowsInBatches(ctx, func(skip int64) ([]ContentRow, bool, error) { + opt := options.Find(). + SetSkip(skip). + SetLimit(queryBatchSize). + SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}). + SetProjection(bson.M{ + videoinformationmod.FN.VideoInformation.Id: 1, + videoinformationmod.FN.VideoInformation.Title: 1, + videoinformationmod.FN.VideoInformation.Desc: 1, + videoinformationmod.FN.VideoInformation.Keyworld: 1, + }) + + list, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt) + if serr != nil { + return nil, false, fmt.Errorf("%s", serr.Tips) + } + rows := make([]ContentRow, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + rows = append(rows, ContentRow{ + ID: item.Id, + Title: item.Title, + Desc: item.Desc, + Keyworld: item.Keyworld, + }) + } + return rows, int64(len(list)) == queryBatchSize, nil + }) +} + +func queryRowsInBatches(ctx context.Context, fetch func(skip int64) ([]ContentRow, bool, error)) ([]ContentRow, error) { + all := make([]ContentRow, 0) + var skip int64 + for { + rows, hasMore, err := fetch(skip) + if err != nil { + return nil, err + } + all = append(all, rows...) + if !hasMore { + break + } + skip += queryBatchSize + } + return all, nil +} + +func updateVideoRow(ctx context.Context, row ContentRow, r *rand.Rand) *srvdef.SrvErr { + now := time.Now() + addedTime := randomImportAddedTime(r) + update := bson.M{ + "$set": bson.M{ + videoinformationmod.FN.VideoInformation.Title: row.Title, + videoinformationmod.FN.VideoInformation.Desc: row.Desc, + videoinformationmod.FN.VideoInformation.Keyworld: row.Keyworld, + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + videoinformationmod.FN.VideoInformation.AddedTime: addedTime, + videoinformationmod.FN.VideoInformation.UpdatedAt: now, + }, + } + return videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: row.ID, + }, update) +} diff --git a/web/service/srv_contentexcel/define.go b/web/service/srv_contentexcel/define.go new file mode 100644 index 0000000..15ec415 --- /dev/null +++ b/web/service/srv_contentexcel/define.go @@ -0,0 +1,32 @@ +package srv_contentexcel + +import "videoweb/pub/pubdef" + +const ( + ContentTypeVideo = "video" +) + +// ExportReq 导出查询参数。 +type ExportReq struct { + Type string `form:"type" binding:"required"` // video + StartTime string `form:"startTime"` // 可选,按 createdAt 筛选,格式 2006-01-02 或 2006-01-02 15:04:05 + EndTime string `form:"endTime"` // 可选,按 createdAt 筛选 + Status *pubdef.InformationStatus `form:"status"` // 可选,0初始化 1上架 2下架,不传查全部 + Ids []int64 // 可选,按 ID 筛选,支持 ids=1&ids=2 或 ids=1,2,3 +} + +// ImportResult 导入结果。 +type ImportResult struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Errors []string `json:"errors,omitempty"` +} + +// ContentRow Excel 单行数据。 +type ContentRow struct { + ID int64 + Title string + Desc string + Keyworld []string +} diff --git a/web/service/srv_contentexcel/excel.go b/web/service/srv_contentexcel/excel.go new file mode 100644 index 0000000..e6026d1 --- /dev/null +++ b/web/service/srv_contentexcel/excel.go @@ -0,0 +1,160 @@ +package srv_contentexcel + +import ( + "bytes" + "fmt" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" +) + +const ( + colID = "ID" + colTitle = "标题" + colDesc = "描述" + colKeyworld = "关键词" + colRedLineRemark = "备注(红线-建议下架)" + sheetName = "Sheet1" + excelHeaders = 4 + colRedLineRemarkIdx = 4 +) + +var headerAliases = map[string]int{ + "id": 0, colID: 0, + "title": 1, colTitle: 1, + "desc": 2, "description": 2, colDesc: 2, + "keyworld": 3, "keywords": 3, "keyword": 3, colKeyworld: 3, + colRedLineRemark: colRedLineRemarkIdx, +} + +func buildWorkbook(rows []ContentRow) ([]byte, error) { + f := excelize.NewFile() + defer f.Close() + + if err := f.SetSheetRow(sheetName, "A1", &[]string{colID, colTitle, colDesc, colKeyworld}); err != nil { + return nil, err + } + + for i, row := range rows { + cell, err := excelize.CoordinatesToCellName(1, i+2) + if err != nil { + return nil, err + } + if err := f.SetSheetRow(sheetName, cell, &[]any{ + row.ID, + row.Title, + row.Desc, + strings.Join(row.Keyworld, ","), + }); err != nil { + return nil, err + } + } + + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func parseWorkbook(data []byte) ([]ContentRow, error) { + f, err := excelize.OpenReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("打开 Excel 失败: %w", err) + } + defer f.Close() + + sheets := f.GetSheetList() + if len(sheets) == 0 { + return nil, fmt.Errorf("Excel 无工作表") + } + + rows, err := f.GetRows(sheets[0]) + if err != nil { + return nil, fmt.Errorf("读取 Excel 失败: %w", err) + } + if len(rows) <= 1 { + return nil, fmt.Errorf("Excel 无数据行") + } + + colIndex := mapHeaderColumns(rows[0]) + if len(colIndex) < excelHeaders { + return nil, fmt.Errorf("Excel 表头需包含: ID、标题、描述、关键词") + } + + result := make([]ContentRow, 0, len(rows)-1) + for lineNo, row := range rows[1:] { + contentRow, ok, err := parseDataRow(row, colIndex, lineNo+2) + if err != nil { + return nil, err + } + if !ok { + continue + } + result = append(result, contentRow) + } + if len(result) == 0 { + return nil, fmt.Errorf("Excel 无有效数据行") + } + return result, nil +} + +func mapHeaderColumns(header []string) map[int]int { + index := make(map[int]int, excelHeaders) + for i, name := range header { + key := strings.ToLower(strings.TrimSpace(name)) + if col, ok := headerAliases[key]; ok { + index[col] = i + } + } + return index +} + +func parseDataRow(row []string, colIndex map[int]int, lineNo int) (ContentRow, bool, error) { + if remarkIdx, ok := colIndex[colRedLineRemarkIdx]; ok { + if cellValue(row, remarkIdx) != "" { + return ContentRow{}, false, nil + } + } + + idStr := cellValue(row, colIndex[0]) + if idStr == "" { + return ContentRow{}, false, nil + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + return ContentRow{}, false, fmt.Errorf("第 %d 行 ID 无效: %q", lineNo, idStr) + } + + return ContentRow{ + ID: id, + Title: cellValue(row, colIndex[1]), + Desc: cellValue(row, colIndex[2]), + Keyworld: parseKeywords(cellValue(row, colIndex[3])), + }, true, nil +} + +func cellValue(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) +} + +func parseKeywords(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + raw = strings.NewReplacer(",", ",", ";", ",", ";", ",").Replace(raw) + parts := strings.Split(raw, ",") + keywords := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + keywords = append(keywords, part) + } + } + return keywords +} diff --git a/web/service/srv_contentexcel/excel_test.go b/web/service/srv_contentexcel/excel_test.go new file mode 100644 index 0000000..fe85e4f --- /dev/null +++ b/web/service/srv_contentexcel/excel_test.go @@ -0,0 +1,201 @@ +package srv_contentexcel + +import ( + "bytes" + "math/rand" + "testing" + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/xuri/excelize/v2" +) + +func TestBuildTimeRangeFilter(t *testing.T) { + t.Parallel() + + filter, err := buildTimeRangeFilter("2026-01-01", "2026-01-31") + if err != nil { + t.Fatalf("buildTimeRangeFilter() err = %v", err) + } + if filter["$gte"] == nil || filter["$lte"] == nil { + t.Fatalf("buildTimeRangeFilter() = %#v, want range filter", filter) + } + + empty, err := buildTimeRangeFilter("", "") + if err != nil { + t.Fatalf("buildTimeRangeFilter() empty err = %v", err) + } + if len(empty) != 0 { + t.Fatalf("buildTimeRangeFilter() empty = %#v, want nil/empty", empty) + } +} + +func TestApplyStatusFilter(t *testing.T) { + t.Parallel() + + base := bson.M{"a": 1} + if got := applyStatusFilter(base, "status", nil); len(got) != 1 { + t.Fatalf("nil status should not add filter: %#v", got) + } + + status := pubdef.InformationStatusVerifyOK + got := applyStatusFilter(bson.M{}, videoinformationmod.FN.VideoInformation.Status, &status) + if got[videoinformationmod.FN.VideoInformation.Status] != pubdef.InformationStatusVerifyOK { + t.Fatalf("applyStatusFilter() status = %#v", got[videoinformationmod.FN.VideoInformation.Status]) + } + + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local) + end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.Local) + dateFilter := bson.M{"$gte": start, "$lte": end} + videoFilter := applyIDsFilter(applyStatusFilter( + applyTimeFilter(bson.M{}, videoinformationmod.FN.VideoInformation.CreatedAt, dateFilter), + videoinformationmod.FN.VideoInformation.Status, &status, + ), videoinformationmod.FN.VideoInformation.Id, []int64{1001, 1002}) + if videoFilter[videoinformationmod.FN.VideoInformation.CreatedAt] == nil { + t.Fatal("expected createdAt filter") + } + if videoFilter[videoinformationmod.FN.VideoInformation.Status] != pubdef.InformationStatusVerifyOK { + t.Fatal("expected status filter") + } + if videoFilter[videoinformationmod.FN.VideoInformation.Id] == nil { + t.Fatal("expected id filter") + } +} + +func TestParseExportIDs(t *testing.T) { + t.Parallel() + + ids, err := ParseExportIDs([]string{"1001,1002", "1003"}) + if err != nil { + t.Fatalf("ParseExportIDs() err = %v", err) + } + if len(ids) != 3 || ids[0] != 1001 || ids[1] != 1002 || ids[2] != 1003 { + t.Fatalf("ParseExportIDs() = %v", ids) + } + + dup, err := ParseExportIDs([]string{"1001", "1001,1002"}) + if err != nil { + t.Fatalf("ParseExportIDs() dup err = %v", err) + } + if len(dup) != 2 { + t.Fatalf("ParseExportIDs() dup = %v, want 2 unique ids", dup) + } + + if _, err := ParseExportIDs([]string{"abc"}); err == nil { + t.Fatal("ParseExportIDs() invalid id should fail") + } +} + +func TestParseKeywords(t *testing.T) { + t.Parallel() + + got := parseKeywords("a,b, c;d") + want := []string{"a", "b", "c", "d"} + if len(got) != len(want) { + t.Fatalf("parseKeywords() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parseKeywords()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestMapHeaderColumns(t *testing.T) { + t.Parallel() + + index := mapHeaderColumns([]string{"ID", "标题", "描述", "关键词"}) + for i := 0; i < excelHeaders; i++ { + if _, ok := index[i]; !ok { + t.Fatalf("missing column index %d", i) + } + } +} + +func TestParseWorkbook(t *testing.T) { + t.Parallel() + + rows := []ContentRow{{ + ID: 1001, + Title: "标题A", + Desc: "描述A", + Keyworld: []string{"k1", "k2"}, + }} + data, err := buildWorkbook(rows) + if err != nil { + t.Fatalf("buildWorkbook() err = %v", err) + } + + parsed, err := parseWorkbook(data) + if err != nil { + t.Fatalf("parseWorkbook() err = %v", err) + } + if len(parsed) != 1 { + t.Fatalf("parseWorkbook() len = %d, want 1", len(parsed)) + } + if parsed[0].ID != 1001 || parsed[0].Title != "标题A" { + t.Fatalf("parseWorkbook() = %+v", parsed[0]) + } +} + +func TestParseWorkbookSkipRedLineRemark(t *testing.T) { + t.Parallel() + + f := excelize.NewFile() + defer f.Close() + + if err := f.SetSheetRow(sheetName, "A1", &[]string{colID, colTitle, colDesc, colKeyworld, colRedLineRemark}); err != nil { + t.Fatalf("SetSheetRow header err = %v", err) + } + if err := f.SetSheetRow(sheetName, "A2", &[]any{1001, "标题A", "描述A", "k1", "建议下架"}); err != nil { + t.Fatalf("SetSheetRow row1 err = %v", err) + } + if err := f.SetSheetRow(sheetName, "A3", &[]any{1002, "标题B", "描述B", "k2", ""}); err != nil { + t.Fatalf("SetSheetRow row2 err = %v", err) + } + + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatalf("Write() err = %v", err) + } + + parsed, err := parseWorkbook(buf.Bytes()) + if err != nil { + t.Fatalf("parseWorkbook() err = %v", err) + } + if len(parsed) != 1 { + t.Fatalf("parseWorkbook() len = %d, want 1", len(parsed)) + } + if parsed[0].ID != 1002 { + t.Fatalf("parseWorkbook() ID = %d, want 1002", parsed[0].ID) + } +} + +func TestRandomImportAddedTime(t *testing.T) { + t.Parallel() + + r := rand.New(rand.NewSource(1)) + for i := 0; i < 20; i++ { + got := randomImportAddedTime(r) + if got.Before(importAddedTimeStart) { + t.Fatalf("randomImportAddedTime() = %v, before %v", got, importAddedTimeStart) + } + if got.After(time.Now()) { + t.Fatalf("randomImportAddedTime() = %v, after now", got) + } + } +} + +func TestNormalizeContentType(t *testing.T) { + t.Parallel() + + if _, err := normalizeContentType("video"); err != nil { + t.Fatalf("video should be valid: %v", err) + } + if _, err := normalizeContentType("post"); err == nil { + t.Fatal("post should be invalid") + } +} diff --git a/web/service/srv_contenttag/tag.go b/web/service/srv_contenttag/tag.go new file mode 100644 index 0000000..ba2713f --- /dev/null +++ b/web/service/srv_contenttag/tag.go @@ -0,0 +1,592 @@ +package srv_contenttag + +import ( + "context" + "regexp" + "sort" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/mwacc" + "videoweb/pub/pubdef" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultContentTagPageSize int64 = 10 + maxContentTagPageSize int64 = 100 + maxContentTagOptionSize int64 = 200 +) + +var contentTagWebProjection = bson.D{ + {Key: contenttagmod.FN.ContentTag.Id, Value: 1}, + {Key: contenttagmod.FN.ContentTag.Name, Value: 1}, + {Key: contenttagmod.FN.ContentTag.Slug, Value: 1}, + {Key: contenttagmod.FN.ContentTag.Types, Value: 1}, + {Key: contenttagmod.FN.ContentTag.Status, Value: 1}, + {Key: contenttagmod.FN.ContentTag.Rank, Value: 1}, + {Key: contenttagmod.FN.ContentTag.VideoCount, Value: 1}, + {Key: contenttagmod.FN.ContentTag.PlayletCount, Value: 1}, + {Key: contenttagmod.FN.ContentTag.SeoInfo.Title, Value: 1}, + {Key: contenttagmod.FN.ContentTag.SeoInfo.Description, Value: 1}, + {Key: contenttagmod.FN.ContentTag.SeoInfo.Keyworld, Value: 1}, + {Key: contenttagmod.FN.ContentTag.CreatedById, Value: 1}, + {Key: contenttagmod.FN.ContentTag.CreatedByName, Value: 1}, + {Key: contenttagmod.FN.ContentTag.UpdatedByName, Value: 1}, + {Key: contenttagmod.FN.ContentTag.CreatedAt, Value: 1}, + {Key: contenttagmod.FN.ContentTag.UpdatedAt, Value: 1}, +} + +// ContentTagBindChange 保存一次内容标签变更前后的标签ID集合 +type ContentTagBindChange struct { + OldTagIds []int64 + NewTagIds []int64 +} + +// ListContentTag 分页查询统一标签列表 +func ListContentTag(ctx context.Context, req *proto.ContentTagListReq) (*proto.ContentTagListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[contenttagmod.FN.ContentTag.Id] = *req.Id + } + if req.Status != nil { + filter[contenttagmod.FN.ContentTag.Status] = *req.Status + } + if req.Type != nil { + if !isValidContentTagType(*req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "") + } + filter[contenttagmod.FN.ContentTag.Types] = *req.Type + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[contenttagmod.FN.ContentTag.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + pageNum, pageSize := normalizeContentTagPage(req.PageNum, req.PageSize) + totalCount, err := contenttagmod.CountContentTag(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.ContentTagListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.ContentTagListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := contenttagmod.FindManyContentTag(ctx, filter, options.Find(). + SetProjection(contentTagWebProjection). + SetSort(bson.D{ + {Key: contenttagmod.FN.ContentTag.Rank, Value: -1}, + {Key: contenttagmod.FN.ContentTag.UpdatedAt, Value: -1}, + {Key: contenttagmod.FN.ContentTag.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.ContentTagListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, contentTagListItem(item)) + } + resp.List = items + return resp, nil +} + +// AddContentTag 新增统一标签 +func AddContentTag(ctx context.Context, req *proto.ContentTagAddReq, operatorId int64) (*proto.ContentTagAddResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "") + } + if req.Status == nil { + return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "") + } + types, err := normalizeContentTagTypes(req.Types) + if err != nil { + return nil, err + } + operatorId, operatorName := resolveContentTagOperator(ctx, operatorId) + doc := &contenttagmod.ContentTag{ + Name: name, + Slug: req.Slug, + Types: types, + Status: *req.Status, + Rank: req.Rank, + VideoCount: 0, + PlayletCount: 0, + SeoInfo: normalizeContentTagSeoValue(req.SeoInfo), + CreatedById: operatorId, + CreatedByName: operatorName, + UpdatedByName: operatorName, + } + exists := func(s string) bool { + item, _ := contenttagmod.FindOneContentTag(ctx, slugx.Filter( + contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.Id, s, 0)) + return item != nil + } + if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, name), exists); serr != nil { + return nil, serr + } + if err := contenttagmod.InsertOneContentTag(ctx, doc); err != nil { + return nil, err + } + return &proto.ContentTagAddResp{Id: doc.Id}, nil +} + +// UpdateContentTag 更新统一标签 +func UpdateContentTag(ctx context.Context, req *proto.ContentTagUpdateReq, operatorId int64) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "标签ID无效", "") + } + current, err := contenttagmod.FindOneContentTag(ctx, bson.M{ + contenttagmod.FN.ContentTag.Id: req.Id, + }, options.FindOne().SetProjection(contentTagWebProjection)) + if err != nil { + return err + } + nextTypes := append([]contenttagmod.ContentTagType{}, current.Types...) + nextVideoCount := current.VideoCount + nextPlayletCount := current.PlayletCount + setDoc := bson.M{contenttagmod.FN.ContentTag.UpdatedAt: time.Now()} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "") + } + setDoc[contenttagmod.FN.ContentTag.Name] = name + } + if req.Types != nil { + types, err := normalizeContentTagTypes(*req.Types) + if err != nil { + return err + } + nextTypes = types + setDoc[contenttagmod.FN.ContentTag.Types] = types + } + if req.Status != nil { + setDoc[contenttagmod.FN.ContentTag.Status] = *req.Status + } + if req.Rank != nil { + setDoc[contenttagmod.FN.ContentTag.Rank] = *req.Rank + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeContentTagSeoValue(*req.SeoInfo) + } + name := current.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + keywords := current.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeContentTagSeoValue(*req.SeoInfo).Keyworld + } + exists := func(s string) bool { + item, _ := contenttagmod.FindOneContentTag(ctx, slugx.Filter( + contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.Id, s, req.Id)) + return item != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists) + if serr != nil { + return serr + } + if changed { + setDoc[contenttagmod.FN.ContentTag.Slug] = slug + } + if err := validateContentTagCounts(nextTypes, nextVideoCount, nextPlayletCount); err != nil { + return err + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + _, operatorName := resolveContentTagOperator(ctx, operatorId) + setDoc[contenttagmod.FN.ContentTag.UpdatedByName] = operatorName + return contenttagmod.UpdateOneContentTag(ctx, + bson.M{contenttagmod.FN.ContentTag.Id: req.Id}, + bson.M{"$set": setDoc}, + ) +} + +// DelContentTag 删除统一标签,存在任一类型绑定数量时禁止删除 +func DelContentTag(ctx context.Context, req *proto.ContentTagDelReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "标签ID无效", "") + } + tag, err := contenttagmod.FindOneContentTag(ctx, bson.M{ + contenttagmod.FN.ContentTag.Id: req.Id, + }, options.FindOne().SetProjection(contentTagWebProjection)) + if err != nil { + return err + } + if tag.VideoCount > 0 || tag.PlayletCount > 0 { + return srvdef.New(srvdef.ErrParamError, "标签已被内容绑定,无法删除", "") + } + count, err := contenttagmod.DelOneContentTag(ctx, bson.M{contenttagmod.FN.ContentTag.Id: req.Id}) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// ListContentTagOption 查询启用的统一标签下拉列表,传type时按支持类型过滤 +func ListContentTagOption(ctx context.Context, req *proto.ContentTagOptionReq) (*proto.ContentTagOptionResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{ + contenttagmod.FN.ContentTag.Status: true, + } + if req.Type != nil { + if !isValidContentTagType(*req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "") + } + filter[contenttagmod.FN.ContentTag.Types] = *req.Type + } + list, err := contenttagmod.FindManyContentTag(ctx, filter, options.Find(). + SetProjection(contentTagWebProjection). + SetSort(bson.D{ + {Key: contenttagmod.FN.ContentTag.Rank, Value: -1}, + {Key: contenttagmod.FN.ContentTag.Id, Value: -1}, + }). + SetLimit(maxContentTagOptionSize)) + if err != nil { + return nil, err + } + resp := &proto.ContentTagOptionResp{List: make([]*proto.ContentTagOptionItem, 0, len(list))} + for _, item := range list { + if item == nil { + continue + } + resp.List = append(resp.List, &proto.ContentTagOptionItem{ + Id: item.Id, + Name: item.Name, + Types: append([]contenttagmod.ContentTagType{}, item.Types...), + TypeNames: contentTagTypeNames(item.Types), + Rank: item.Rank, + BindCount: contentTagOptionBindCount(item, req.Type), + }) + } + return resp, nil +} + +// NormalizeContentTagIds 校验标签ID并去重,保留原有顺序便于内容详情按后台提交顺序展示 +func NormalizeContentTagIds(tagIds []int64) ([]int64, *srvdef.SrvErr) { + result := normalizeContentTagIdsForDiff(tagIds) + if len(result) != len(tagIds) { + for _, tagId := range tagIds { + if tagId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "标签ID无效", "") + } + } + } + return result, nil +} + +// ValidateContentTags 校验统一标签存在、启用,并且支持当前内容类型 +func ValidateContentTags(ctx context.Context, tagIds []int64, tagType contenttagmod.ContentTagType) *srvdef.SrvErr { + ids, err := NormalizeContentTagIds(tagIds) + if err != nil { + return err + } + if len(ids) == 0 { + return nil + } + if !isValidContentTagType(tagType) { + return srvdef.New(srvdef.ErrParamError, "标签类型无效", "") + } + count, err := contenttagmod.CountContentTag(ctx, bson.M{ + contenttagmod.FN.ContentTag.Id: bson.M{"$in": ids}, + contenttagmod.FN.ContentTag.Status: true, + contenttagmod.FN.ContentTag.Types: tagType, + }) + if err != nil { + return err + } + if count != int64(len(ids)) { + return srvdef.New(srvdef.ErrParamError, "标签不存在、未启用或不支持当前内容类型", "") + } + return nil +} + +// SyncContentTagBindCount 按绑定差异增减统一标签对应类型的绑定数量 +func SyncContentTagBindCount(ctx context.Context, oldTagIds, newTagIds []int64, tagType contenttagmod.ContentTagType) *srvdef.SrvErr { + return SyncContentTagBindCountBatch(ctx, []ContentTagBindChange{ + {OldTagIds: oldTagIds, NewTagIds: newTagIds}, + }, tagType) +} + +// SyncContentTagBindCountBatch 聚合多条内容标签变更后批量增减统一标签对应类型的绑定数量 +func SyncContentTagBindCountBatch(ctx context.Context, changes []ContentTagBindChange, tagType contenttagmod.ContentTagType) *srvdef.SrvErr { + if !isValidContentTagType(tagType) { + return srvdef.New(srvdef.ErrParamError, "标签类型无效", "") + } + field, err := contentTagCountField(tagType) + if err != nil { + return err + } + deltaMap := make(map[int64]int64) + for _, change := range changes { + addIds, removeIds := diffContentTagIds( + normalizeContentTagIdsForDiff(change.OldTagIds), + normalizeContentTagIdsForDiff(change.NewTagIds), + ) + for _, tagId := range addIds { + deltaMap[tagId]++ + } + for _, tagId := range removeIds { + deltaMap[tagId]-- + } + } + if len(deltaMap) == 0 { + return nil + } + now := time.Now() + docs := make([]mongo.WriteModel, 0, len(deltaMap)) + for tagId, delta := range deltaMap { + if delta == 0 { + continue + } + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{contenttagmod.FN.ContentTag.Id: tagId}). + SetUpdate(bson.A{ + bson.M{"$set": bson.M{ + field: bson.M{"$max": bson.A{ + 0, + bson.M{"$add": bson.A{ + bson.M{"$ifNull": bson.A{"$" + field, 0}}, + delta, + }}, + }}, + contenttagmod.FN.ContentTag.UpdatedAt: now, + }}, + })) + } + if len(docs) == 0 { + return nil + } + _, err = contenttagmod.BulkWriteContentTag(ctx, docs, options.BulkWrite().SetOrdered(false)) + if err != nil { + return err + } + return nil +} + +// normalizeContentTagPage 规范分页参数,限制单页数量避免大分页拖慢查询 +func normalizeContentTagPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultContentTagPageSize + } + if pageSize > maxContentTagPageSize { + pageSize = maxContentTagPageSize + } + return pageNum, pageSize +} + +func normalizeContentTagIdsForDiff(tagIds []int64) []int64 { + result := make([]int64, 0, len(tagIds)) + seen := make(map[int64]struct{}, len(tagIds)) + for _, tagId := range tagIds { + if tagId <= 0 { + continue + } + if _, ok := seen[tagId]; ok { + continue + } + seen[tagId] = struct{}{} + result = append(result, tagId) + } + return result +} + +func diffContentTagIds(oldTagIds, newTagIds []int64) ([]int64, []int64) { + oldMap := make(map[int64]struct{}, len(oldTagIds)) + newMap := make(map[int64]struct{}, len(newTagIds)) + for _, tagId := range oldTagIds { + oldMap[tagId] = struct{}{} + } + for _, tagId := range newTagIds { + newMap[tagId] = struct{}{} + } + addIds := make([]int64, 0) + removeIds := make([]int64, 0) + for _, tagId := range newTagIds { + if _, ok := oldMap[tagId]; !ok { + addIds = append(addIds, tagId) + } + } + for _, tagId := range oldTagIds { + if _, ok := newMap[tagId]; !ok { + removeIds = append(removeIds, tagId) + } + } + return addIds, removeIds +} + +func contentTagCountField(tagType contenttagmod.ContentTagType) (string, *srvdef.SrvErr) { + switch tagType { + case contenttagmod.ContentTagTypeVideo: + return contenttagmod.FN.ContentTag.VideoCount, nil + case contenttagmod.ContentTagTypePlaylet: + return contenttagmod.FN.ContentTag.PlayletCount, nil + default: + return "", srvdef.New(srvdef.ErrParamError, "标签类型无效", "") + } +} + +func contentTagBindCount(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 contentTagOptionBindCount(item *contenttagmod.ContentTag, tagType *contenttagmod.ContentTagType) int64 { + if tagType == nil { + return 0 + } + return contentTagBindCount(item, *tagType) +} + +// normalizeContentTagTypes 去重并校验统一标签支持的内容类型 +func normalizeContentTagTypes(types []contenttagmod.ContentTagType) ([]contenttagmod.ContentTagType, *srvdef.SrvErr) { + if len(types) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "标签类型不能为空", "") + } + seen := make(map[contenttagmod.ContentTagType]struct{}, len(types)) + result := make([]contenttagmod.ContentTagType, 0, len(types)) + for _, item := range types { + if !isValidContentTagType(item) { + return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "") + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { + return result[i] < result[j] + }) + return result, nil +} + +// isValidContentTagType 判断统一标签类型是否为视频、短剧之一 +func isValidContentTagType(tagType contenttagmod.ContentTagType) bool { + return tagType == contenttagmod.ContentTagTypeVideo || + tagType == contenttagmod.ContentTagTypePlaylet +} + +// validateContentTagCounts 校验各类型绑定数量非负且数量字段和支持类型保持一致 +func validateContentTagCounts(types []contenttagmod.ContentTagType, videoCount, playletCount int64) *srvdef.SrvErr { + if videoCount < 0 || playletCount < 0 { + return srvdef.New(srvdef.ErrParamError, "绑定数量不能小于0", "") + } + typeMap := make(map[contenttagmod.ContentTagType]struct{}, len(types)) + for _, item := range types { + typeMap[item] = struct{}{} + } + if videoCount > 0 && !hasContentTagType(typeMap, contenttagmod.ContentTagTypeVideo) { + return srvdef.New(srvdef.ErrParamError, "存在视频绑定数量时必须支持视频类型", "") + } + if playletCount > 0 && !hasContentTagType(typeMap, contenttagmod.ContentTagTypePlaylet) { + return srvdef.New(srvdef.ErrParamError, "存在短剧绑定数量时必须支持短剧类型", "") + } + return nil +} + +func hasContentTagType(typeMap map[contenttagmod.ContentTagType]struct{}, tagType contenttagmod.ContentTagType) bool { + _, ok := typeMap[tagType] + return ok +} + +// contentTagListItem 将统一标签模型转换为后台列表响应 +func contentTagListItem(item *contenttagmod.ContentTag) *proto.ContentTagListResp { + return &proto.ContentTagListResp{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Types: append([]contenttagmod.ContentTagType{}, item.Types...), + TypeNames: contentTagTypeNames(item.Types), + Status: item.Status, + Rank: item.Rank, + VideoCount: item.VideoCount, + PlayletCount: item.PlayletCount, + SeoInfo: normalizeContentTagSeoValue(item.SeoInfo), + CreatedById: item.CreatedById, + CreatedByName: item.CreatedByName, + UpdatedByName: item.UpdatedByName, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// contentTagTypeNames 将统一标签类型转换为后台展示名称 +func contentTagTypeNames(types []contenttagmod.ContentTagType) []string { + names := make([]string, 0, len(types)) + for _, item := range types { + switch item { + case contenttagmod.ContentTagTypeVideo: + names = append(names, "视频") + case contenttagmod.ContentTagTypePlaylet: + names = append(names, "短剧") + } + } + return names +} + +// normalizeContentTagSeoValue 规整 SEO 字段,避免空切片返回 null +func normalizeContentTagSeoValue(seo pubdef.SeoValue) pubdef.SeoValue { + if seo.Keyworld == nil { + seo.Keyworld = make([]string, 0) + } + return seo +} + +// resolveContentTagOperator 从后台账号表解析当前操作人名称 +func resolveContentTagOperator(ctx context.Context, operatorId int64) (int64, string) { + if operatorId <= 0 { + return 0, "" + } + acc, err := mwacc.FindOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: operatorId, + }, options.FindOne().SetProjection(bson.D{ + {Key: mwacc.FN.MwAcc.Id, Value: 1}, + {Key: mwacc.FN.MwAcc.UserName, Value: 1}, + })) + if err != nil || acc == nil { + return operatorId, "" + } + return acc.Id, acc.UserName +} diff --git a/web/service/srv_detailnav/define.go b/web/service/srv_detailnav/define.go new file mode 100644 index 0000000..3928dca --- /dev/null +++ b/web/service/srv_detailnav/define.go @@ -0,0 +1,38 @@ +package srv_detailnav + +import ( + "videoweb/pub/models/detailnavmod" + "videoweb/pub/pubdef" +) + +type GetDetailNavListReq struct { + pubdef.Page +} + +type GetDetailNavListResp struct { + Total int64 `json:"total"` + List detailnavmod.DetailNavModelList `json:"list"` +} + +type AddDetailNavReq struct { + Name string `json:"name" binding:"required"` // 导航名称 + Url string `json:"url" binding:"required"` // 导航链接 + Sort int64 `json:"sort"` // 排序 + Status *bool `json:"status"` // 是否启用 +} + +type UpdateDetailNavReq struct { + Id string `json:"id" binding:"required"` + Param UpdateDetailNavParam `json:"param"` +} + +type UpdateDetailNavParam struct { + Name *string `json:"name" bson:"name,omitempty"` + Url *string `json:"url" bson:"url,omitempty"` + Sort *int64 `json:"sort" bson:"sort,omitempty"` + Status *bool `json:"status" bson:"status,omitempty"` +} + +type DelDetailNavReq struct { + Id string `json:"id" binding:"required"` +} diff --git a/web/service/srv_detailnav/detailnav.go b/web/service/srv_detailnav/detailnav.go new file mode 100644 index 0000000..5d5c111 --- /dev/null +++ b/web/service/srv_detailnav/detailnav.go @@ -0,0 +1,110 @@ +package srv_detailnav + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/detailnavmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetDetailNavList 获取详情页导航列表 +func GetDetailNavList(ctx context.Context, req *GetDetailNavListReq) (*GetDetailNavListResp, *srvdef.SrvErr) { + filter := bson.M{} + + count, serr := detailnavmod.CountDetailNav(ctx, filter) + if serr != nil { + return nil, serr + } + if count == 0 { + return &GetDetailNavListResp{ + Total: 0, + List: make(detailnavmod.DetailNavModelList, 0), + }, nil + } + + opts := options.FindOptions{} + opts.SetSort(bson.D{ + {Key: detailnavmod.FN.DetailNavModel.Sort, Value: 1}, + {Key: detailnavmod.FN.DetailNavModel.CreatedAt, Value: -1}, + }) + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + + list, serr := detailnavmod.FindManyDetailNav(ctx, filter, &opts) + if serr != nil { + return nil, serr + } + + return &GetDetailNavListResp{ + Total: count, + List: list, + }, nil +} + +// AddDetailNav 添加详情页导航 +func AddDetailNav(ctx context.Context, req *AddDetailNavReq) *srvdef.SrvErr { + now := time.Now() + status := true + if req.Status != nil { + status = *req.Status + } + doc := &detailnavmod.DetailNavModel{ + ID: primitive.NewObjectID(), + Name: req.Name, + Url: req.Url, + Sort: req.Sort, + Status: status, + CreatedAt: now, + UpdatedAt: now, + } + return detailnavmod.InsertOneDetailNav(ctx, doc) +} + +// UpdateDetailNav 更新详情页导航 +func UpdateDetailNav(ctx context.Context, req *UpdateDetailNavReq) *srvdef.SrvErr { + objId, err := primitive.ObjectIDFromHex(req.Id) + if err != nil { + return srvdef.New(srvdef.ErrParamError, "", "") + } + + filter := bson.M{detailnavmod.FN.DetailNavModel.ID: objId} + updateFields := bson.M{ + detailnavmod.FN.DetailNavModel.UpdatedAt: time.Now(), + } + if req.Param.Name != nil { + updateFields[detailnavmod.FN.DetailNavModel.Name] = *req.Param.Name + } + if req.Param.Url != nil { + updateFields[detailnavmod.FN.DetailNavModel.Url] = *req.Param.Url + } + if req.Param.Sort != nil { + updateFields[detailnavmod.FN.DetailNavModel.Sort] = *req.Param.Sort + } + if req.Param.Status != nil { + updateFields[detailnavmod.FN.DetailNavModel.Status] = *req.Param.Status + } + + return detailnavmod.UpdateOneDetailNav(ctx, filter, bson.M{"$set": updateFields}) +} + +// DelDetailNav 删除详情页导航 +func DelDetailNav(ctx context.Context, req *DelDetailNavReq) *srvdef.SrvErr { + objId, err := primitive.ObjectIDFromHex(req.Id) + if err != nil { + return srvdef.New(srvdef.ErrParamError, "", "") + } + + filter := bson.M{detailnavmod.FN.DetailNavModel.ID: objId} + count, serr := detailnavmod.DelOneDetailNav(ctx, filter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/web/service/srv_homebanner/banner.go b/web/service/srv_homebanner/banner.go new file mode 100644 index 0000000..a80fb0e --- /dev/null +++ b/web/service/srv_homebanner/banner.go @@ -0,0 +1,226 @@ +package srv_homebanner + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/homebannermod" + "videoweb/pub/models/mwacc" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultHomeBannerPageSize int64 = 10 + maxHomeBannerPageSize int64 = 100 +) + +var homeBannerWebProjection = 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}, + {Key: homebannermod.FN.HomeBanner.CreatedById, Value: 1}, + {Key: homebannermod.FN.HomeBanner.CreatedByName, Value: 1}, + {Key: homebannermod.FN.HomeBanner.UpdatedByName, Value: 1}, + {Key: homebannermod.FN.HomeBanner.CreatedAt, Value: 1}, + {Key: homebannermod.FN.HomeBanner.UpdatedAt, Value: 1}, +} + +// ListHomeBanner 分页查询首页 banner 列表 +func ListHomeBanner(ctx context.Context, req *proto.HomeBannerListReq) (*proto.HomeBannerListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[homebannermod.FN.HomeBanner.Id] = *req.Id + } + if req.Status != nil { + filter[homebannermod.FN.HomeBanner.Status] = *req.Status + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[homebannermod.FN.HomeBanner.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + pageNum, pageSize := normalizeHomeBannerPage(req.PageNum, req.PageSize) + totalCount, err := homebannermod.CountHomeBanner(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.HomeBannerListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.HomeBannerListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := homebannermod.FindManyHomeBanner(ctx, filter, options.Find(). + SetProjection(homeBannerWebProjection). + SetSort(bson.D{ + {Key: homebannermod.FN.HomeBanner.Rank, Value: -1}, + {Key: homebannermod.FN.HomeBanner.UpdatedAt, Value: -1}, + {Key: homebannermod.FN.HomeBanner.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.HomeBannerListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.HomeBannerListResp{ + Id: item.Id, + Name: item.Name, + Image: item.Image, + VerticalImage: item.VerticalImage, + Url: item.Url, + Rank: item.Rank, + Status: item.Status, + CreatedById: item.CreatedById, + CreatedByName: item.CreatedByName, + UpdatedByName: item.UpdatedByName, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// AddHomeBanner 新增首页 banner +func AddHomeBanner(ctx context.Context, req *proto.HomeBannerAddReq, operatorId int64) (*proto.HomeBannerAddResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + name := strings.TrimSpace(req.Name) + image := strings.TrimSpace(req.Image) + if name == "" { + return nil, srvdef.New(srvdef.ErrParamError, "名称不能为空", "") + } + if image == "" { + return nil, srvdef.New(srvdef.ErrParamError, "图片不能为空", "") + } + if req.Status == nil { + return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "") + } + operatorId, operatorName := resolveHomeBannerOperator(ctx, operatorId) + doc := &homebannermod.HomeBanner{ + Name: name, + Image: image, + VerticalImage: strings.TrimSpace(req.VerticalImage), + Url: strings.TrimSpace(req.Url), + Rank: req.Rank, + Status: *req.Status, + CreatedById: operatorId, + CreatedByName: operatorName, + UpdatedByName: operatorName, + } + if err := homebannermod.InsertOneHomeBanner(ctx, doc); err != nil { + return nil, err + } + return &proto.HomeBannerAddResp{Id: doc.Id}, nil +} + +// UpdateHomeBanner 更新首页 banner +func UpdateHomeBanner(ctx context.Context, req *proto.HomeBannerUpdateReq, operatorId int64) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "首页banner ID无效", "") + } + setDoc := bson.M{homebannermod.FN.HomeBanner.UpdatedAt: time.Now()} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "名称不能为空", "") + } + setDoc[homebannermod.FN.HomeBanner.Name] = name + } + if req.Image != nil { + image := strings.TrimSpace(*req.Image) + if image == "" { + return srvdef.New(srvdef.ErrParamError, "图片不能为空", "") + } + setDoc[homebannermod.FN.HomeBanner.Image] = image + } + if req.VerticalImage != nil { + setDoc[homebannermod.FN.HomeBanner.VerticalImage] = strings.TrimSpace(*req.VerticalImage) + } + if req.Url != nil { + setDoc[homebannermod.FN.HomeBanner.Url] = strings.TrimSpace(*req.Url) + } + if req.Rank != nil { + setDoc[homebannermod.FN.HomeBanner.Rank] = *req.Rank + } + if req.Status != nil { + setDoc[homebannermod.FN.HomeBanner.Status] = *req.Status + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + _, operatorName := resolveHomeBannerOperator(ctx, operatorId) + setDoc[homebannermod.FN.HomeBanner.UpdatedByName] = operatorName + return homebannermod.UpdateOneHomeBanner(ctx, + bson.M{homebannermod.FN.HomeBanner.Id: req.Id}, + bson.M{"$set": setDoc}, + ) +} + +// DelHomeBanner 删除首页 banner +func DelHomeBanner(ctx context.Context, req *proto.HomeBannerDelReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "首页banner ID无效", "") + } + count, err := homebannermod.DelOneHomeBanner(ctx, bson.M{homebannermod.FN.HomeBanner.Id: req.Id}) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// normalizeHomeBannerPage 规范分页参数,限制单页数量避免大分页拖慢查询 +func normalizeHomeBannerPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultHomeBannerPageSize + } + if pageSize > maxHomeBannerPageSize { + pageSize = maxHomeBannerPageSize + } + return pageNum, pageSize +} + +// resolveHomeBannerOperator 从后台账号表解析当前操作人名称 +func resolveHomeBannerOperator(ctx context.Context, operatorId int64) (int64, string) { + if operatorId <= 0 { + return 0, "" + } + acc, err := mwacc.FindOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: operatorId, + }, options.FindOne().SetProjection(bson.D{ + {Key: mwacc.FN.MwAcc.Id, Value: 1}, + {Key: mwacc.FN.MwAcc.UserName, Value: 1}, + })) + if err != nil || acc == nil { + return operatorId, "" + } + return acc.Id, acc.UserName +} diff --git a/web/service/srv_hotnav/define.go b/web/service/srv_hotnav/define.go new file mode 100644 index 0000000..d765000 --- /dev/null +++ b/web/service/srv_hotnav/define.go @@ -0,0 +1,42 @@ +package srv_hotnav + +import ( + "videoweb/pub/models/hotnavmod" + "videoweb/pub/pubdef" +) + +type GetHotNavListReq struct { + pubdef.Page +} + +type GetHotNavListResp struct { + Total int64 `json:"total"` + List hotnavmod.HotNavList `json:"list"` +} + +type AddHotNavReq struct { + Title string `json:"title" binding:"required"` // 标题 + Intro string `json:"intro"` // 介绍 + Url string `json:"url" binding:"required"` // 链接 + Icon string `json:"icon"` // 图标 + Sort int64 `json:"sort"` // 排序 + Status *bool `json:"status"` // 是否启用 +} + +type UpdateHotNavReq struct { + Id int64 `json:"id" binding:"required"` + Param UpdateHotNavParam `json:"param"` +} + +type UpdateHotNavParam struct { + Title *string `json:"title"` + Intro *string `json:"intro"` + Url *string `json:"url"` + Icon *string `json:"icon"` + Sort *int64 `json:"sort"` + Status *bool `json:"status"` +} + +type DelHotNavReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_hotnav/hotnav.go b/web/service/srv_hotnav/hotnav.go new file mode 100644 index 0000000..4a48135 --- /dev/null +++ b/web/service/srv_hotnav/hotnav.go @@ -0,0 +1,100 @@ +package srv_hotnav + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/hotnavmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func GetHotNavList(ctx context.Context, req *GetHotNavListReq) (*GetHotNavListResp, *srvdef.SrvErr) { + filter := bson.M{} + count, serr := hotnavmod.CountHotNav(ctx, filter) + if serr != nil { + return nil, serr + } + if count == 0 { + return &GetHotNavListResp{ + Total: 0, + List: make(hotnavmod.HotNavList, 0), + }, nil + } + + opts := options.FindOptions{} + opts.SetSort(bson.D{ + {Key: hotnavmod.FN.HotNav.Sort, Value: -1}, + {Key: hotnavmod.FN.HotNav.CreatedAt, Value: -1}, + }) + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + list, serr := hotnavmod.FindManyHotNav(ctx, filter, &opts) + if serr != nil { + return nil, serr + } + return &GetHotNavListResp{ + Total: count, + List: list, + }, nil +} + +func AddHotNav(ctx context.Context, req *AddHotNavReq) *srvdef.SrvErr { + now := time.Now() + status := true + if req.Status != nil { + status = *req.Status + } + doc := &hotnavmod.HotNav{ + Id: mongos.Lid.HotNav.Get(), + Title: req.Title, + Intro: req.Intro, + Url: req.Url, + Icon: req.Icon, + Sort: req.Sort, + Status: status, + CreatedAt: now, + UpdatedAt: now, + } + return hotnavmod.InsertOneHotNav(ctx, doc) +} + +func UpdateHotNav(ctx context.Context, req *UpdateHotNavReq) *srvdef.SrvErr { + filter := bson.M{hotnavmod.FN.HotNav.Id: req.Id} + updateFields := bson.M{ + hotnavmod.FN.HotNav.UpdatedAt: time.Now(), + } + if req.Param.Title != nil { + updateFields[hotnavmod.FN.HotNav.Title] = *req.Param.Title + } + if req.Param.Intro != nil { + updateFields[hotnavmod.FN.HotNav.Intro] = *req.Param.Intro + } + if req.Param.Url != nil { + updateFields[hotnavmod.FN.HotNav.Url] = *req.Param.Url + } + if req.Param.Icon != nil { + updateFields[hotnavmod.FN.HotNav.Icon] = *req.Param.Icon + } + if req.Param.Sort != nil { + updateFields[hotnavmod.FN.HotNav.Sort] = *req.Param.Sort + } + if req.Param.Status != nil { + updateFields[hotnavmod.FN.HotNav.Status] = *req.Param.Status + } + return hotnavmod.UpdateOneHotNav(ctx, filter, bson.M{"$set": updateFields}) +} + +func DelHotNav(ctx context.Context, req *DelHotNavReq) *srvdef.SrvErr { + filter := bson.M{hotnavmod.FN.HotNav.Id: req.Id} + count, serr := hotnavmod.DelOneHotNav(ctx, filter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/web/service/srv_ipblack/define.go b/web/service/srv_ipblack/define.go new file mode 100644 index 0000000..9da1d67 --- /dev/null +++ b/web/service/srv_ipblack/define.go @@ -0,0 +1,25 @@ +package srv_ipblack + +import ( + "videoweb/pub/models/ipblackmod" + "videoweb/pub/pubdef" +) + +type GetIPBlackListReq struct { + pubdef.Page + IP *string `json:"ip"` +} + +type GetIPBlackListResp struct { + Total int64 `json:"total"` + List []*ipblackmod.IPBlackModel `json:"list"` +} + +type AddIPBlackListReq struct { + Ip string `json:"ip"` + Desc string `json:"desc" bson:"desc"` // 备注 +} + +type DelIpBlackListReq struct { + Ip string `json:"ip"` +} \ No newline at end of file diff --git a/web/service/srv_ipblack/ipblack.go b/web/service/srv_ipblack/ipblack.go new file mode 100644 index 0000000..253b338 --- /dev/null +++ b/web/service/srv_ipblack/ipblack.go @@ -0,0 +1,102 @@ +package srv_ipblack + +import ( + "context" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/ipblackmod" +) + +var ( + ipBlackMap map[string]struct{} + lock sync.RWMutex +) + +func Init() { + ctx := context.Background() + list, err := ipblackmod.FindManyIPBlackModel(ctx, bson.M{}) + if err != nil { + log.Error("ipBlackInit FindManyIPBlackModel error", log.E(err)) + return + } + tempMaps := make(map[string]struct{}) + for _, v := range list { + tempMaps[v.Ip] = struct{}{} + } + lock.Lock() + defer lock.Unlock() + ipBlackMap = tempMaps +} + +func CheckIp(ip string) bool { + lock.RLock() + defer lock.RUnlock() + if _, ok := ipBlackMap[ip]; ok { + return true + } + return false +} + +func GetIpBlackList(ctx context.Context, req *GetIPBlackListReq) (*GetIPBlackListResp, *srvdef.SrvErr) { + filter := bson.M{} + if req.IP != nil && len(*req.IP) > 0 { + filter[ipblackmod.FN.IPBlackModel.Ip] = req.IP + } + count, err := ipblackmod.CountIPBlackModel(ctx, filter) + if err != nil { + return nil, err + } + if count == 0 { + return &GetIPBlackListResp{ + Total: 0, + List: make([]*ipblackmod.IPBlackModel, 0), + }, nil + } + opts := options.FindOptions{} + opts.SetSort(bson.M{ipblackmod.FN.IPBlackModel.CreatedAt: -1}) + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + list, err := ipblackmod.FindManyIPBlackModel(ctx, filter, &opts) + if err != nil { + return nil, err + } + resp := &GetIPBlackListResp{ + Total: count, + List: list, + } + return resp, nil +} + +func AddIpBlack(ctx context.Context, req *AddIPBlackListReq) *srvdef.SrvErr { + err := ipblackmod.InsertOneIPBlackModel(ctx, &ipblackmod.IPBlackModel{ + ID: primitive.NewObjectID(), + Ip: req.Ip, + Desc: req.Desc, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil { + lock.Lock() + defer lock.Unlock() + ipBlackMap[req.Ip] = struct{}{} + } + return err +} + +func DelIpBlack(ctx context.Context, req *DelIpBlackListReq) *srvdef.SrvErr { + _, err := ipblackmod.DelOneIPBlackModel(ctx, bson.M{ + ipblackmod.FN.IPBlackModel.Ip: req.Ip, + }) + if err != nil { + lock.Lock() + defer lock.Unlock() + delete(ipBlackMap, req.Ip) + ipBlackMap[req.Ip] = struct{}{} + } + return err +} \ No newline at end of file diff --git a/web/service/srv_mediaresource/ai_content_backflow.go b/web/service/srv_mediaresource/ai_content_backflow.go new file mode 100644 index 0000000..3d3fcc5 --- /dev/null +++ b/web/service/srv_mediaresource/ai_content_backflow.go @@ -0,0 +1,1068 @@ +package srv_mediaresource + +import ( + "context" + "fmt" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" + "videoweb/pub/pubdef" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + aidatacenter "videoweb/pub/data_center" + "videoweb/pub/models/playletbackflowstatmod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/web/webg" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // defaultAIContentBackflowPageSize 每页扫描原创短剧数量,避免一次性加载过多 Mongo 数据。 + defaultAIContentBackflowPageSize = int64(200) + // defaultAIContentBackflowBatchSize 每批提交给内容中台的记录数。 + defaultAIContentBackflowBatchSize = int64(500) + // maxAIContentBackflowBatchSize 内容中台单批最多允许 1000 条。 + maxAIContentBackflowBatchSize = int64(1000) + // defaultAIContentBackflowReportTimeout 单批同步上报的最长等待时间。 + defaultAIContentBackflowReportTimeout = 60 * time.Second +) + +var ( + // aiContentBackflowPlatformBaseURL 覆盖短剧播放页域名;App-only 产品为空时 platform_url 回退到 video_url。 + aiContentBackflowPlatformBaseURL = "" + // aiContentBackflowVideoBaseURL 覆盖视频回源域名;空则使用 dataCenterBackflow.selfSourceBaseUrl。 + aiContentBackflowVideoBaseURL = "" + // aiContentBackflowPageSize 运行时扫描分页大小,非法值回退默认值。 + aiContentBackflowPageSize = int64(200) + // aiContentBackflowBatchSize 运行时上报批量大小,非法值回退默认值。 + aiContentBackflowBatchSize = int64(500) + // aiContentBackflowCronSpec 运行时 cron 表达式,空值回退每天 02:30。 + aiContentBackflowCronSpec = "" +) + +// aiContentBackflowStats 记录一次任务的扫描、构造和跳过数量。 +type aiContentBackflowStats struct { + DataDate string // 数据日期,格式 DD/MM/YYYY + Scanned int64 // 扫描到的原创短剧数 + Built int64 // 成功构造的回流记录数 + Stored int64 // 已写入本地统计表的记录数 + Sent int64 // 已发送给内容中台的记录数 + Batches int64 // 发送批次数 + Reported int64 // 已标记上报成功的记录数 + Failed int64 // 已标记上报失败的记录数 + Skipped int64 // 因数据缺口跳过的短剧数 + MissingChapter int64 // 缺少可用章节的数量 + MissingVideo int64 // 缺少视频地址的数量 + MissingMediaContent int64 // 第一集缺少媒资章节 ID 的数量 + FuturePublish int64 // 上线日期晚于统计日期的数量 +} + +// aiContentBackflowChapters 一部短剧回流需要的章节:第一集,以及按集数最早的付费集。 +type aiContentBackflowChapters struct { + First *playletchaptermod.PlayletChapter + FirstPaid *playletchaptermod.PlayletChapter +} + +// aiContentBackflowStore 抽象数据读写和发送函数,方便单测注入假实现。 +type aiContentBackflowStore struct { + findPlaylets func(context.Context, bson.M, *options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) + findChapters func(context.Context, []int64) (map[int64]*aiContentBackflowChapters, *srvdef.SrvErr) +} + +// aiContentBackflowClient 复用 SDK client,避免每次定时任务重复创建后台队列。 +var aiContentBackflowClient = struct { + sync.Mutex + client *aidatacenter.Client + apiKey string + endpoint string +}{} + +// AIContentBackflowCronSpec 返回原创短剧数据回流的每日调度时间。 +func AIContentBackflowCronSpec() string { + if spec := strings.TrimSpace(aiContentBackflowCronSpec); spec != "" { + return spec + } + return "0 30 2 * * ?" +} + +// AIContentBackflowTodayStatCronSpec 返回当天统计表小时级刷新时间。 +func AIContentBackflowTodayStatCronSpec() string { + return "0 0 * * * ?" +} + +// ReportAIContentBackflowYesterday 上报昨日原创短剧数据。 +// +// 内容中台日记录按自然日覆盖更新,重复执行同一天不会累加。 +func ReportAIContentBackflowYesterday() { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + targetDate := time.Now().In(loc).AddDate(0, 0, -1) + stats, serr := reportAIContentBackflow(context.Background(), targetDate) + if serr != nil { + log.Error("AI 内容中台原创短剧数据回流失败", log.Any("err", serr), log.Any("stats", stats)) + return + } + if stats != nil { + log.Info("AI 内容中台原创短剧数据回流完成", log.Any("stats", stats)) + } +} + +// RefreshAIContentBackflowTodayStats 刷新当天原创短剧回流统计,便于上线后观察数据。 +func RefreshAIContentBackflowTodayStats() { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + targetDate := time.Now().In(loc) + stats, serr := refreshAIContentBackflowStats(context.Background(), targetDate) + if serr != nil { + log.Error("AI 内容中台原创短剧当天统计刷新失败", log.Any("err", serr), log.Any("stats", stats)) + return + } + log.Info("AI 内容中台原创短剧当天统计刷新完成", log.Any("stats", stats)) +} + +// CloseAIContentBackflowClient 关闭复用的内容中台 SDK client。 +func CloseAIContentBackflowClient() { + aiContentBackflowClient.Lock() + client := aiContentBackflowClient.client + aiContentBackflowClient.client = nil + aiContentBackflowClient.apiKey = "" + aiContentBackflowClient.endpoint = "" + aiContentBackflowClient.Unlock() + if client != nil { + client.Close() + } +} + +// reportAIContentBackflow 从数据回流配置读取 API Key;空 key 表示未启用真实回流。 +func reportAIContentBackflow(ctx context.Context, targetDate time.Time) (*aiContentBackflowStats, *srvdef.SrvErr) { + apiKey := strings.TrimSpace(webg.Conf.DataCenterBackflow.Key) + if apiKey == "" { + log.Info("AI 内容中台原创短剧数据回流未启用", + log.Any("dataDate", formatAIContentBackflowDate(targetDate)), + log.Any("reason", "dataCenterBackflow.key empty"), + ) + return nil, nil + } + stats, serr := ensureAIContentBackflowStats(ctx, targetDate) + if serr != nil { + return stats, serr + } + client, serr := getAIContentBackflowClient(apiKey) + if serr != nil { + return stats, serr + } + reportStats, serr := reportAIContentBackflowStats(ctx, targetDate, client) + if reportStats != nil { + mergeAIContentBackflowStats(stats, *reportStats) + } + return stats, serr +} + +// refreshAIContentBackflowStats 计算指定日期数据并写入本地统计表,不依赖内容中台 API Key。 +func refreshAIContentBackflowStats(ctx context.Context, targetDate time.Time) (*aiContentBackflowStats, *srvdef.SrvErr) { + return refreshAIContentBackflowStatsWithStore(ctx, targetDate, aiContentBackflowStore{ + findPlaylets: findOriginalPlayletsForBackflow, + findChapters: findFirstPlayletChaptersForBackflow, + }) +} + +// getAIContentBackflowClient 创建或复用 data_center SDK client。 +func getAIContentBackflowClient(apiKey string) (*aidatacenter.Client, *srvdef.SrvErr) { + if !strings.HasPrefix(apiKey, "ld_") { + return nil, srvdef.New(srvdef.Failure, "AI内容中台API Key格式错误", "AI内容中台API Key格式错误") + } + aiContentBackflowClient.Lock() + defer aiContentBackflowClient.Unlock() + if aiContentBackflowClient.client != nil && aiContentBackflowClient.apiKey == apiKey { + return aiContentBackflowClient.client, nil + } + if aiContentBackflowClient.client != nil { + aiContentBackflowClient.client.Close() + } + client := aidatacenter.New(apiKey) + endpoint := client.Endpoint + client.OnError = func(err error) { + log.Error("AI 内容中台原创短剧异步上报失败", + log.Any("endpoint", endpoint), + log.Any("companyPlatform", pubdef.CompanyPlatform), + log.E(err), + ) + } + log.Info("AI 内容中台原创短剧数据回流客户端初始化", + log.Any("endpoint", endpoint), + log.Any("companyPlatform", pubdef.CompanyPlatform), + ) + aiContentBackflowClient.client = client + aiContentBackflowClient.apiKey = apiKey + aiContentBackflowClient.endpoint = endpoint + return client, nil +} + +// refreshAIContentBackflowStatsWithStore 分页扫描原创短剧并写入本地日统计表。 +func refreshAIContentBackflowStatsWithStore(ctx context.Context, targetDate time.Time, store aiContentBackflowStore) (*aiContentBackflowStats, *srvdef.SrvErr) { + if serr := validateAIContentBackflowRuntime(); serr != nil { + return nil, serr + } + pageSize := normalizeAIContentBackflowPageSize(aiContentBackflowPageSize) + stats := &aiContentBackflowStats{DataDate: formatAIContentBackflowDate(targetDate)} + log.Info("AI 内容中台原创短剧日统计刷新开始", + log.Any("dataDate", stats.DataDate), + log.Any("companyPlatform", pubdef.CompanyPlatform), + log.Any("pageSize", pageSize), + log.Any("videoBaseURL", resolveAIContentBackflowVideoBaseURL()), + ) + for lastID := int64(0); ; { + list, serr := store.findPlaylets(ctx, originalPlayletBackflowFilter(lastID), originalPlayletBackflowFindOptions(pageSize)) + if serr != nil { + return stats, serr + } + if len(list) == 0 { + break + } + stats.Scanned += int64(len(list)) + docs, pageStats, nextID, serr := buildAIContentBackflowDailyStats(ctx, targetDate, list, store.findChapters) + mergeAIContentBackflowStats(stats, pageStats) + if serr != nil { + return stats, serr + } + if serr := playletbackflowstatmod.UpsertManyPlayletBackflowDailyStats(ctx, docs); serr != nil { + return stats, serr + } + stats.Stored += int64(len(docs)) + log.Info("AI 内容中台原创短剧日统计分页刷新完成", + log.Any("dataDate", stats.DataDate), + log.Any("lastID", lastID), + log.Any("nextID", nextID), + log.Any("scanned", len(list)), + log.Any("built", pageStats.Built), + log.Any("stored", len(docs)), + log.Any("skipped", pageStats.Skipped), + log.Any("missingChapter", pageStats.MissingChapter), + log.Any("missingVideo", pageStats.MissingVideo), + log.Any("missingMediaContent", pageStats.MissingMediaContent), + log.Any("futurePublish", pageStats.FuturePublish), + ) + if int64(len(list)) < pageSize || nextID <= lastID { + break + } + lastID = nextID + } + return stats, nil +} + +// ensureAIContentBackflowStats 确保指定日期已有本地统计,避免每日上报重复扫描。 +func ensureAIContentBackflowStats(ctx context.Context, targetDate time.Time) (*aiContentBackflowStats, *srvdef.SrvErr) { + dataDate := formatAIContentBackflowDate(targetDate) + total, serr := playletbackflowstatmod.CountPlayletBackflowDailyStats(ctx, bson.M{ + playletbackflowstatmod.FN.PlayletBackflowDailyStat.DataDate: dataDate, + }) + if serr != nil { + return nil, serr + } + success, serr := playletbackflowstatmod.CountPlayletBackflowDailyStats(ctx, bson.M{ + playletbackflowstatmod.FN.PlayletBackflowDailyStat.DataDate: dataDate, + playletbackflowstatmod.FN.PlayletBackflowDailyStat.ReportStatus: playletbackflowstatmod.ReportStatusSuccess, + }) + if serr != nil { + return nil, serr + } + if total > 0 && total == success { + stats := &aiContentBackflowStats{DataDate: dataDate, Stored: total} + log.Info("AI 内容中台原创短剧日统计已全部上报成功", + log.Any("dataDate", stats.DataDate), + log.Any("count", total), + ) + return stats, nil + } + log.Info("AI 内容中台原创短剧上报前刷新日统计", + log.Any("dataDate", dataDate), + log.Any("total", total), + log.Any("success", success), + ) + return refreshAIContentBackflowStats(ctx, targetDate) +} + +// reportAIContentBackflowStats 从本地统计表读取待上报数据,同步发送后标记状态。 +func reportAIContentBackflowStats(ctx context.Context, targetDate time.Time, client *aidatacenter.Client) (*aiContentBackflowStats, *srvdef.SrvErr) { + batchSize := normalizeAIContentBackflowBatchSize(aiContentBackflowBatchSize) + stats := &aiContentBackflowStats{DataDate: formatAIContentBackflowDate(targetDate)} + log.Info("AI 内容中台原创短剧统计上报开始", + log.Any("dataDate", stats.DataDate), + log.Any("batchSize", batchSize), + ) + for lastID, hasLastID := primitive.NilObjectID, false; ; { + filter := bson.M{ + playletbackflowstatmod.FN.PlayletBackflowDailyStat.DataDate: stats.DataDate, + playletbackflowstatmod.FN.PlayletBackflowDailyStat.ReportStatus: bson.M{ + "$ne": playletbackflowstatmod.ReportStatusSuccess, + }, + } + if hasLastID { + filter[playletbackflowstatmod.FN.PlayletBackflowDailyStat.Id] = bson.M{"$gt": lastID} + } + list, serr := playletbackflowstatmod.FindManyPlayletBackflowDailyStats(ctx, filter, options.Find(). + SetSort(bson.D{{Key: playletbackflowstatmod.FN.PlayletBackflowDailyStat.Id, Value: 1}}). + SetLimit(batchSize)) + if serr != nil { + return stats, serr + } + if len(list) == 0 { + break + } + records := make([]aidatacenter.Record, 0, len(list)) + ids := make([]primitive.ObjectID, 0, len(list)) + for _, item := range list { + if item == nil || item.Id.IsZero() { + continue + } + lastID = item.Id + hasLastID = true + if !shouldReportAIContentBackflowStat(item) { + stats.Skipped++ + stats.FuturePublish++ + log.Warn("AI 内容中台原创短剧统计跳过未来上线快照", + log.Any("dataDate", item.DataDate), + log.Any("playletId", item.PlayletId), + log.Any("publishedData", item.PublishedData), + ) + continue + } + if item.MediaContentId <= 0 || item.ChapterId <= 0 { + stats.Skipped++ + stats.MissingMediaContent++ + log.Warn("AI 内容中台原创短剧统计跳过缺少剧集标识的快照", + log.Any("dataDate", item.DataDate), + log.Any("playletId", item.PlayletId), + log.Any("chapterId", item.ChapterId), + log.Any("mediaContentId", item.MediaContentId), + ) + continue + } + records = append(records, makeAIContentBackflowRecordFromStat(item)) + ids = append(ids, item.Id) + } + if len(records) == 0 { + if int64(len(list)) < batchSize { + break + } + continue + } + if len(ids) == 0 { + break + } + sendCtx, cancel := context.WithTimeout(ctx, defaultAIContentBackflowReportTimeout) + _, err := client.Send(sendCtx, records...) + cancel() + if err != nil { + reportErr := err.Error() + if serr := playletbackflowstatmod.MarkPlayletBackflowDailyStatsFailed(ctx, ids, reportErr); serr != nil { + return stats, serr + } + stats.Failed += int64(len(ids)) + log.Error("AI 内容中台原创短剧统计批次上报失败", + log.Any("dataDate", stats.DataDate), + log.Any("batchSize", len(records)), + log.Any("ids", ids), + log.E(err), + ) + return stats, srvdef.New(srvdef.Failure, "AI内容中台原创短剧上报失败", "AI内容中台原创短剧上报失败") + } + reportAt := time.Now() + if serr := playletbackflowstatmod.MarkPlayletBackflowDailyStatsReported(ctx, ids, reportAt); serr != nil { + return stats, serr + } + stats.Sent += int64(len(records)) + stats.Reported += int64(len(ids)) + stats.Batches++ + log.Info("AI 内容中台原创短剧统计批次上报成功", + log.Any("dataDate", stats.DataDate), + log.Any("batchNo", stats.Batches), + log.Any("batchSize", len(records)), + log.Any("sent", stats.Sent), + ) + if int64(len(list)) < batchSize { + break + } + } + return stats, nil +} + +// validateAIContentBackflowRuntime 校验代码内置口径是否能生成中台必填字段。 +func validateAIContentBackflowRuntime() *srvdef.SrvErr { + if strings.TrimSpace(pubdef.CompanyPlatform) == "" { + return srvdef.New(srvdef.Failure, "AI内容中台产品名称未配置", "AI内容中台产品名称未配置") + } + if resolveAIContentBackflowVideoBaseURL() == "" { + return srvdef.New(srvdef.Failure, "AI内容中台视频回源地址未配置", "AI内容中台视频回源地址未配置") + } + return nil +} + +// normalizeAIContentBackflowPageSize 修正扫描分页大小。 +func normalizeAIContentBackflowPageSize(v int64) int64 { + if v <= 0 { + return defaultAIContentBackflowPageSize + } + return v +} + +// normalizeAIContentBackflowBatchSize 修正上报批量大小,并限制在中台上限内。 +func normalizeAIContentBackflowBatchSize(v int64) int64 { + if v <= 0 { + return defaultAIContentBackflowBatchSize + } + if v > maxAIContentBackflowBatchSize { + return maxAIContentBackflowBatchSize + } + return v +} + +// originalPlayletBackflowFilter 只选择已标记媒资来源且已上架的短剧。 +func originalPlayletBackflowFilter(lastID int64) bson.M { + filter := bson.M{ + playletmod.FN.Playlet.MediaBusiness: bson.M{"$gt": ""}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + if lastID > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$gt": lastID} + } + return filter +} + +// originalPlayletBackflowFindOptions 按 ID 游标分页,只读取组装回流记录需要的字段。 +func originalPlayletBackflowFindOptions(pageSize int64) *options.FindOptions { + return options.Find(). + SetSort(bson.D{{Key: playletmod.FN.Playlet.Id, Value: 1}}). + SetLimit(pageSize). + SetProjection(bson.M{ + playletmod.FN.Playlet.Id: 1, + playletmod.FN.Playlet.MediaCenterId: 1, + playletmod.FN.Playlet.Title: 1, + playletmod.FN.Playlet.Status: 1, + playletmod.FN.Playlet.OnlineAt: 1, + playletmod.FN.Playlet.MediaCreatedAt: 1, + playletmod.FN.Playlet.CreatedAt: 1, + playletmod.FN.Playlet.CountBrowse: 1, + playletmod.FN.Playlet.CountLike: 1, + playletmod.FN.Playlet.CountComment: 1, + playletmod.FN.Playlet.TotalEpisode: 1, + }) +} + +// findOriginalPlayletsForBackflow 查询待回流原创短剧。 +func findOriginalPlayletsForBackflow(ctx context.Context, filter bson.M, opts *options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) { + return playletmod.FindManyPlaylet(ctx, filter, opts) +} + +// findFirstPlayletChaptersForBackflow 取每部短剧的第一集,以及按集数最早的付费集。 +func findFirstPlayletChaptersForBackflow(ctx context.Context, playletIDs []int64) (map[int64]*aiContentBackflowChapters, *srvdef.SrvErr) { + if len(playletIDs) == 0 { + return map[int64]*aiContentBackflowChapters{}, nil + } + list, serr := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": playletIDs}, + }, options.Find(). + SetSort(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + }). + SetProjection(bson.M{ + playletchaptermod.FN.PlayletChapter.Id: 1, + playletchaptermod.FN.PlayletChapter.MediaContentId: 1, + playletchaptermod.FN.PlayletChapter.PlayletId: 1, + playletchaptermod.FN.PlayletChapter.CurrentEpisode: 1, + playletchaptermod.FN.PlayletChapter.VideoUrl: 1, + playletchaptermod.FN.PlayletChapter.CountBrowse: 1, + playletchaptermod.FN.PlayletChapter.CountLike: 1, + playletchaptermod.FN.PlayletChapter.CountComment: 1, + playletchaptermod.FN.PlayletChapter.CountBuy: 1, + playletchaptermod.FN.PlayletChapter.PayType: 1, + })) + if serr != nil { + return nil, serr + } + result := make(map[int64]*aiContentBackflowChapters, len(playletIDs)) + for _, chapter := range list { + if chapter == nil { + continue + } + item := result[chapter.PlayletId] + if item == nil { + item = &aiContentBackflowChapters{First: chapter} + result[chapter.PlayletId] = item + } + if item.FirstPaid == nil && isPaidPlayletChapter(chapter) { + item.FirstPaid = chapter + } + } + return result, nil +} + +// isPaidPlayletChapter 金币或 VIP 剧集需要解锁后才能完整观看。 +func isPaidPlayletChapter(chapter *playletchaptermod.PlayletChapter) bool { + if chapter == nil { + return false + } + return chapter.PayType == playletchaptermod.PayTypeCoin || chapter.PayType == playletchaptermod.PayTypeVip +} + +// buildAIContentBackflowRecords 把短剧模型转换成内容中台 records。 +func buildAIContentBackflowRecords( + ctx context.Context, + targetDate time.Time, + playlets playletmod.PlayletList, + findChapters func(context.Context, []int64) (map[int64]*aiContentBackflowChapters, *srvdef.SrvErr), +) ([]aidatacenter.Record, aiContentBackflowStats, int64, *srvdef.SrvErr) { + stats := aiContentBackflowStats{DataDate: formatAIContentBackflowDate(targetDate)} + ids := make([]int64, 0, len(playlets)) + nextID := int64(0) + for _, playlet := range playlets { + if playlet == nil { + continue + } + ids = append(ids, playlet.Id) + if playlet.Id > nextID { + nextID = playlet.Id + } + } + chapterByPlayletID, serr := findChapters(ctx, ids) + if serr != nil { + return nil, stats, nextID, serr + } + records := make([]aidatacenter.Record, 0, len(playlets)) + for _, playlet := range playlets { + if playlet == nil { + continue + } + if !shouldReportAIContentBackflowPlaylet(targetDate, playlet) { + stats.Skipped++ + stats.FuturePublish++ + continue + } + chapters := chapterByPlayletID[playlet.Id] + if chapters == nil || chapters.First == nil { + stats.Skipped++ + stats.MissingChapter++ + continue + } + videoURL := buildAIContentBackflowVideoURL(chapters.First.VideoUrl) + if videoURL == "" { + stats.Skipped++ + stats.MissingVideo++ + continue + } + if chapters.First.MediaContentId == 0 { + stats.Skipped++ + stats.MissingMediaContent++ + continue + } + records = append(records, makeAIContentBackflowRecord(targetDate, playlet, chapters, videoURL)) + stats.Built++ + } + return records, stats, nextID, nil +} + +// buildAIContentBackflowDailyStats 把短剧模型转换成本地日统计快照。 +func buildAIContentBackflowDailyStats( + ctx context.Context, + targetDate time.Time, + playlets playletmod.PlayletList, + findChapters func(context.Context, []int64) (map[int64]*aiContentBackflowChapters, *srvdef.SrvErr), +) (playletbackflowstatmod.PlayletBackflowDailyStatList, aiContentBackflowStats, int64, *srvdef.SrvErr) { + stats := aiContentBackflowStats{DataDate: formatAIContentBackflowDate(targetDate)} + ids := make([]int64, 0, len(playlets)) + nextID := int64(0) + for _, playlet := range playlets { + if playlet == nil { + continue + } + ids = append(ids, playlet.Id) + if playlet.Id > nextID { + nextID = playlet.Id + } + } + chapterByPlayletID, serr := findChapters(ctx, ids) + if serr != nil { + return nil, stats, nextID, serr + } + docs := make(playletbackflowstatmod.PlayletBackflowDailyStatList, 0, len(playlets)) + for _, playlet := range playlets { + if playlet == nil { + continue + } + if !shouldReportAIContentBackflowPlaylet(targetDate, playlet) { + stats.Skipped++ + stats.FuturePublish++ + continue + } + chapters := chapterByPlayletID[playlet.Id] + if chapters == nil || chapters.First == nil { + stats.Skipped++ + stats.MissingChapter++ + continue + } + videoURL := buildAIContentBackflowVideoURL(chapters.First.VideoUrl) + if videoURL == "" { + stats.Skipped++ + stats.MissingVideo++ + continue + } + if chapters.First.MediaContentId == 0 { + stats.Skipped++ + stats.MissingMediaContent++ + continue + } + record := makeAIContentBackflowRecord(targetDate, playlet, chapters, videoURL) + docs = append(docs, makeAIContentBackflowDailyStat(targetDate, playlet, chapters.First, record)) + stats.Built++ + } + return docs, stats, nextID, nil +} + +// makeAIContentBackflowRecord 组装一条内容中台数据,缺失指标保持空或 unknown。 +func makeAIContentBackflowRecord(targetDate time.Time, playlet *playletmod.Playlet, chapters *aiContentBackflowChapters, videoURL string) aidatacenter.Record { + var first, firstPaid *playletchaptermod.PlayletChapter + if chapters != nil { + first = chapters.First + firstPaid = chapters.FirstPaid + } + views := aiContentBackflowViews(playlet) + clicks := aiContentBackflowClicks(first) + likes := aiContentBackflowLikes(playlet, first) + comments := aiContentBackflowComments(playlet, first) + platformURL := "" + if first != nil { + platformURL = buildAIContentBackflowPlatformURL(playlet.Id, first.Id) + } + if platformURL == "" { + // 内容中台把 platform_url 设为必填;哔哩2是 App-only 产品时没有真实 Web 播放页。 + // 使用已验证可访问的视频回源 URL 兜底,避免伪造不存在的播放站点链接。 + platformURL = videoURL + } + record := aidatacenter.Record{ + DataDate: formatAIContentBackflowDate(targetDate), + CompanyPlatform: strings.TrimSpace(pubdef.CompanyPlatform), + PublishedData: formatAIContentBackflowDate(resolveAIContentBackflowPublishedAt(playlet)), + ScriptName: strings.TrimSpace(playlet.Title), + DramaName: strings.TrimSpace(playlet.Title), + DramaNo: aiContentBackflowDramaNo(playlet, first), + VideoURL: videoURL, + PlatformURL: platformURL, + VideoID: aiContentBackflowVideoID(first), + PlatformVideoID: aiContentBackflowPlatformVideoID(first), + Views: views, + Clicks: clicks, + Likes: likes, + Comments: aidatacenter.Int(comments), + Shares: aidatacenter.Int(0), + CompletionRateScope: "unknown", + } + if record.ScriptName == "" { + record.ScriptName = record.VideoID + } + if record.DramaName == "" { + record.DramaName = record.VideoID + } + if clicks > 0 { + record.LikeRate = percentString(likes, clicks) + } + record.PaidRate = aiContentBackflowPaidRate(playlet, firstPaid) + return record +} + +// aiContentBackflowVideoID 使用第一集的媒资章节 ID 作为中台视频编号。 +func aiContentBackflowVideoID(chapter *playletchaptermod.PlayletChapter) string { + if chapter == nil || chapter.MediaContentId == 0 { + return "" + } + return strconv.FormatUint(uint64(chapter.MediaContentId), 10) +} + +// aiContentBackflowPlatformVideoID 使用本项目剧集 ID 作为产品内视频编号。 +func aiContentBackflowPlatformVideoID(chapter *playletchaptermod.PlayletChapter) string { + if chapter == nil || chapter.Id <= 0 { + return "" + } + return strconv.FormatInt(chapter.Id, 10) +} + +// aiContentBackflowStatVideoID 从日统计快照还原中台视频编号。 +func aiContentBackflowStatVideoID(stat *playletbackflowstatmod.PlayletBackflowDailyStat) string { + if stat == nil || stat.MediaContentId <= 0 { + return "" + } + return strconv.FormatInt(stat.MediaContentId, 10) +} + +// aiContentBackflowStatPlatformVideoID 从日统计快照还原产品内视频编号。 +func aiContentBackflowStatPlatformVideoID(stat *playletbackflowstatmod.PlayletBackflowDailyStat) string { + if stat == nil || stat.ChapterId <= 0 { + return "" + } + return strconv.FormatInt(stat.ChapterId, 10) +} + +// aiContentBackflowViews 浏览量使用短剧累计浏览数。 +func aiContentBackflowViews(playlet *playletmod.Playlet) int64 { + if playlet == nil { + return 0 + } + return nonNegativeInt64(playlet.CountBrowse) +} + +// aiContentBackflowClicks 播放量使用第一集累计播放数。剧集浏览计数在播放时增加。 +func aiContentBackflowClicks(chapter *playletchaptermod.PlayletChapter) int64 { + if chapter == nil { + return 0 + } + return nonNegativeInt64(chapter.CountBrowse) +} + +// aiContentBackflowLikes 点赞优先用第一集,第一集没有点赞时回退到短剧点赞数。 +func aiContentBackflowLikes(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) int64 { + if chapter != nil && chapter.CountLike > 0 { + return chapter.CountLike + } + if playlet == nil { + return 0 + } + return nonNegativeInt64(playlet.CountLike) +} + +// aiContentBackflowComments 评论优先用第一集,第一集没有评论时回退到短剧评论数。 +func aiContentBackflowComments(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) int64 { + if chapter != nil && chapter.CountComment > 0 { + return chapter.CountComment + } + if playlet == nil { + return 0 + } + return nonNegativeInt64(playlet.CountComment) +} + +// aiContentBackflowPaidRate 付费率 = 第一个付费剧集解锁数 / 短剧浏览数。没有付费剧集时记 0.00%。 +func aiContentBackflowPaidRate(playlet *playletmod.Playlet, firstPaid *playletchaptermod.PlayletChapter) string { + if firstPaid == nil { + return "0.00%" + } + if playlet == nil || playlet.CountBrowse <= 0 { + return "" + } + return percentString(firstPaid.CountBuy, playlet.CountBrowse) +} + +// makeAIContentBackflowDailyStat 保存一条可观察、可重试的本地统计快照。 +func makeAIContentBackflowDailyStat(targetDate time.Time, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, record aidatacenter.Record) *playletbackflowstatmod.PlayletBackflowDailyStat { + stat := &playletbackflowstatmod.PlayletBackflowDailyStat{ + DataDate: record.DataDate, + PlayletId: playlet.Id, + ChapterId: chapter.Id, + CurrentEpisode: chapter.CurrentEpisode, + MediaCenterId: int64(playlet.MediaCenterId), + MediaContentId: int64(chapter.MediaContentId), + Title: strings.TrimSpace(playlet.Title), + PublishedData: record.PublishedData, + ScriptName: record.ScriptName, + DramaName: record.DramaName, + DramaNo: record.DramaNo, + VideoURL: record.VideoURL, + PlatformURL: record.PlatformURL, + Views: record.Views, + Clicks: record.Clicks, + Likes: record.Likes, + LikeRate: record.LikeRate, + CompletionRate: record.CompletionRate, + PaidRate: record.PaidRate, + CompletionRateScope: record.CompletionRateScope, + ReportStatus: playletbackflowstatmod.ReportStatusPending, + } + if record.Comments != nil { + stat.Comments = *record.Comments + } + if record.Shares != nil { + stat.Shares = *record.Shares + } + return stat +} + +// makeAIContentBackflowRecordFromStat 还原 SDK records,用于统计表和上报逻辑解耦。 +func makeAIContentBackflowRecordFromStat(stat *playletbackflowstatmod.PlayletBackflowDailyStat) aidatacenter.Record { + record := aidatacenter.Record{ + DataDate: stat.DataDate, + CompanyPlatform: strings.TrimSpace(pubdef.CompanyPlatform), + PublishedData: stat.PublishedData, + ScriptName: stat.ScriptName, + DramaName: stat.DramaName, + DramaNo: stat.DramaNo, + VideoURL: stat.VideoURL, + PlatformURL: stat.PlatformURL, + VideoID: aiContentBackflowStatVideoID(stat), + PlatformVideoID: aiContentBackflowStatPlatformVideoID(stat), + Views: stat.Views, + Clicks: stat.Clicks, + Likes: stat.Likes, + LikeRate: stat.LikeRate, + CompletionRate: stat.CompletionRate, + PaidRate: stat.PaidRate, + Comments: aidatacenter.Int(stat.Comments), + Shares: aidatacenter.Int(stat.Shares), + CompletionRateScope: stat.CompletionRateScope, + } + return record +} + +// mergeAIContentBackflowStats 合并单页构造统计。 +func mergeAIContentBackflowStats(dst *aiContentBackflowStats, src aiContentBackflowStats) { + if dst == nil { + return + } + dst.Built += src.Built + dst.Stored += src.Stored + dst.Sent += src.Sent + dst.Batches += src.Batches + dst.Reported += src.Reported + dst.Failed += src.Failed + dst.Skipped += src.Skipped + dst.MissingChapter += src.MissingChapter + dst.MissingVideo += src.MissingVideo + dst.MissingMediaContent += src.MissingMediaContent + dst.FuturePublish += src.FuturePublish +} + +// splitAIContentBackflowRecords 按中台批量上限切分 records。 +func splitAIContentBackflowRecords(records []aidatacenter.Record, batchSize int64) [][]aidatacenter.Record { + if len(records) == 0 { + return nil + } + if batchSize <= 0 { + batchSize = defaultAIContentBackflowBatchSize + } + batchLimit := int(batchSize) + if batchLimit <= 0 { + batchLimit = int(defaultAIContentBackflowBatchSize) + } + batches := make([][]aidatacenter.Record, 0, (len(records)+batchLimit-1)/batchLimit) + for len(records) > 0 { + n := batchLimit + if len(records) < n { + n = len(records) + } + batches = append(batches, records[:n]) + records = records[n:] + } + return batches +} + +// shouldReportAIContentBackflowPlaylet 判断短剧在统计日当天是否已经上线。 +func shouldReportAIContentBackflowPlaylet(targetDate time.Time, playlet *playletmod.Playlet) bool { + publishedAt, ok := resolveAIContentBackflowPublishedAtCandidate(playlet) + if !ok { + // 没有任何可用时间时保留旧兜底逻辑,交给后续必填校验和服务端校验处理。 + return true + } + return !aiContentBackflowDateOnly(publishedAt).After(aiContentBackflowDateOnly(targetDate)) +} + +// shouldReportAIContentBackflowStat 判断历史快照是否适合在当前数据日上报。 +func shouldReportAIContentBackflowStat(stat *playletbackflowstatmod.PlayletBackflowDailyStat) bool { + if stat == nil { + return false + } + dataDate, okDataDate := parseAIContentBackflowDate(stat.DataDate) + publishedDate, okPublishedDate := parseAIContentBackflowDate(stat.PublishedData) + if !okDataDate || !okPublishedDate { + // 日期格式异常时不在这里吞掉,继续让 SDK 或内容中台返回明确错误。 + return true + } + return !publishedDate.After(dataDate) +} + +// resolveAIContentBackflowPublishedAt 按上架时间、媒资创建时间、本地创建时间依次兜底。 +func resolveAIContentBackflowPublishedAt(playlet *playletmod.Playlet) time.Time { + if t, ok := resolveAIContentBackflowPublishedAtCandidate(playlet); ok { + return t + } + return time.Now() +} + +// resolveAIContentBackflowPublishedAtCandidate 返回业务上可确认的短剧上线候选时间。 +func resolveAIContentBackflowPublishedAtCandidate(playlet *playletmod.Playlet) (time.Time, bool) { + if playlet == nil { + return time.Time{}, false + } + for _, t := range []time.Time{playlet.OnlineAt, playlet.MediaCreatedAt, playlet.CreatedAt} { + if !t.IsZero() { + return t, true + } + } + return time.Time{}, false +} + +// buildAIContentBackflowVideoURL 按配置的回源地址生成完整 m3u8 链接 +func buildAIContentBackflowVideoURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + if isAbsoluteHTTPURL(rawURL) { + return rawURL + } + if strings.ContainsAny(rawURL, "\r\n\x00\\") || strings.Contains(strings.ToLower(rawURL), "%2e") || len(rawURL) > 4096 { + return "" + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.User != nil { + return "" + } + name := strings.TrimLeft(parsed.Path, "/") + if name == "" || hasAIContentBackflowParentPathSegment(name) { + return "" + } + if ext := path.Ext(name); ext == "" { + name += ".m3u8" + } else if !strings.EqualFold(ext, ".m3u8") { + return "" + } + result := joinAIContentBackflowURL(resolveAIContentBackflowVideoBaseURL(), name) + if result != "" && parsed.RawQuery != "" { + result += "?" + parsed.RawQuery + } + return result +} + +// buildAIContentBackflowPlatformURL 生成产品内播放页 URL。 +func buildAIContentBackflowPlatformURL(playletID, chapterID int64) string { + return joinAIContentBackflowURL(resolveAIContentBackflowPlatformBaseURL(), fmt.Sprintf("/play?id=%d&chapterId=%d", playletID, chapterID)) +} + +// resolveAIContentBackflowPlatformBaseURL 返回短剧播放页域名。 +func resolveAIContentBackflowPlatformBaseURL() string { + if baseURL := strings.TrimSpace(aiContentBackflowPlatformBaseURL); baseURL != "" { + return strings.TrimRight(baseURL, "/") + } + return "" +} + +// resolveAIContentBackflowVideoBaseURL 返回数据回流专用视频回源域名。 +func resolveAIContentBackflowVideoBaseURL() string { + if baseURL := strings.TrimSpace(aiContentBackflowVideoBaseURL); baseURL != "" { + return strings.TrimRight(baseURL, "/") + } + if baseURL := strings.TrimSpace(webg.Conf.DataCenterBackflow.SelfSourceBaseUrl); baseURL != "" { + return strings.TrimRight(baseURL, "/") + } + return "" +} + +// normalizeAIContentBackflowBaseURL 兼容配置里的裸域名和完整 URL。 +func normalizeAIContentBackflowBaseURL(host string, listenAddr string) string { + host = strings.TrimRight(strings.TrimSpace(host), "/") + if host == "" { + return "" + } + if isAbsoluteHTTPURL(host) { + return host + } + if listenAddr = strings.TrimSpace(listenAddr); listenAddr != "" && !strings.Contains(host, ":") { + host += ":" + listenAddr + } + return "https://" + host +} + +// joinAIContentBackflowURL 拼接 baseURL 和相对路径;绝对路径直接返回。 +func joinAIContentBackflowURL(baseURL string, path string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + path = strings.TrimSpace(path) + if baseURL == "" || path == "" { + return "" + } + if isAbsoluteHTTPURL(path) { + return path + } + return baseURL + "/" + strings.TrimLeft(path, "/") +} + +// isAbsoluteHTTPURL 判断是否为 http/https 完整 URL。 +func isAbsoluteHTTPURL(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") +} + +// hasAIContentBackflowParentPathSegment 拒绝路径穿越。 +func hasAIContentBackflowParentPathSegment(name string) bool { + for _, segment := range strings.Split(name, "/") { + if segment == ".." { + return true + } + } + return false +} + +// formatAIContentBackflowDate 使用北京时间输出中台要求的 DD/MM/YYYY。 +func formatAIContentBackflowDate(t time.Time) string { + return t.In(aiContentBackflowLocation()).Format(aidatacenter.DateLayout) +} + +// parseAIContentBackflowDate 解析中台 DD/MM/YYYY 日期为北京时间零点。 +func parseAIContentBackflowDate(value string) (time.Time, bool) { + t, err := time.ParseInLocation(aidatacenter.DateLayout, strings.TrimSpace(value), aiContentBackflowLocation()) + return t, err == nil +} + +// aiContentBackflowDateOnly 只保留北京时间日期,用于比较发布日和统计日。 +func aiContentBackflowDateOnly(t time.Time) time.Time { + local := t.In(aiContentBackflowLocation()) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, aiContentBackflowLocation()) +} + +// aiContentBackflowLocation 返回内容中台日期口径使用的北京时间。 +func aiContentBackflowLocation() *time.Location { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + return loc +} + +// aiContentBackflowDramaNo 生成中台要求的集数文案。 +func aiContentBackflowDramaNo(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string { + if chapter != nil && chapter.CurrentEpisode > 0 { + return fmt.Sprintf("第%d集", chapter.CurrentEpisode) + } + if playlet != nil && playlet.TotalEpisode > 0 { + return fmt.Sprintf("共%d集", playlet.TotalEpisode) + } + return "第1集" +} + +// nonNegativeInt64 把业务累计计数转成中台非负 int64。 +func nonNegativeInt64(v int64) int64 { + if v <= 0 { + return 0 + } + return v +} + +// percentString 生成保留两位小数的百分比字符串。 +func percentString(numerator, denominator int64) string { + if numerator < 0 { + numerator = 0 + } + if denominator <= 0 { + return "" + } + return fmt.Sprintf("%.2f%%", float64(numerator)*100/float64(denominator)) +} diff --git a/web/service/srv_mediaresource/cover_backfill.go b/web/service/srv_mediaresource/cover_backfill.go new file mode 100644 index 0000000..67c191f --- /dev/null +++ b/web/service/srv_mediaresource/cover_backfill.go @@ -0,0 +1,203 @@ +package srv_mediaresource + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + + goredis "github.com/go-redis/redis" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // PlayletCoverCompressCronSpec 历史空压缩封面扫描频率:每5分钟投递一批 + PlayletCoverCompressCronSpec = "0 */5 * * * ?" + + playletCoverCompressQueueKeyFmt = "playlet:cover:compress:queue:%d" + playletCoverCompressLockKeyFmt = "playlet:cover:compress:lock:%d:%d" + playletCoverCompressFailKeyFmt = "playlet:cover:compress:fail:v2:%d:%d" + playletCoverCompressLockTTL = 10 * time.Minute + playletCoverCompressFailTTL = 10 * time.Minute + playletCoverCompressPopWait = 5 * time.Second + playletCoverCompressRetryWait = time.Second + playletCoverCompressScanSize = int64(200) + playletCoverCompressBatchSize = 50 +) + +func playletCoverCompressQueueKey() string { + return fmt.Sprintf(playletCoverCompressQueueKeyFmt, pubdef.AppId) +} + +func playletCoverCompressLockKey(playletId int64) string { + return fmt.Sprintf(playletCoverCompressLockKeyFmt, pubdef.AppId, playletId) +} + +func playletCoverCompressFailKey(playletId int64) string { + return fmt.Sprintf(playletCoverCompressFailKeyFmt, pubdef.AppId, playletId) +} + +type playletCoverCompressJob struct { + PlayletId int64 `json:"playletId"` + CreateTime int64 `json:"createTime"` +} + +// EnqueuePlayletCoverCompressJobs 扫描压缩封面为空的短剧并投递后台任务 +func EnqueuePlayletCoverCompressJobs() { + ctx := context.Background() + enqueued := 0 + scanned := 0 + var lastId int64 + for enqueued < playletCoverCompressBatchSize { + filter := emptyCompressedCoverFilter() + if lastId > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$gt": lastId} + } + opts := options.Find(). + SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + }). + SetSort(bson.D{{Key: playletmod.FN.Playlet.Id, Value: 1}}). + SetLimit(playletCoverCompressScanSize) + list, err := playletmod.FindManyPlaylet(ctx, filter, opts) + if err != nil { + log.Error("扫描待压缩短剧封面失败", log.E(err)) + return + } + if len(list) == 0 { + break + } + scanned += len(list) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + lastId = item.Id + if enqueued >= playletCoverCompressBatchSize { + break + } + if has, _ := redis.Exists(playletCoverCompressFailKey(item.Id)); has { + continue + } + if err := pushPlayletCoverCompressJob(ctx, item.Id); err != nil { + log.Error("投递短剧封面压缩任务失败", log.Any("playletId", item.Id), log.E(err)) + continue + } + enqueued++ + } + if int64(len(list)) < playletCoverCompressScanSize { + break + } + } + log.Info("短剧压缩封面任务扫描完成", log.Any("scanCount", scanned), log.Any("enqueued", enqueued)) +} + +func emptyCompressedCoverFilter() bson.M { + return bson.M{ + playletmod.FN.Playlet.Cover: bson.M{"$nin": []interface{}{"", nil}}, + "$or": []bson.M{ + {playletmod.FN.Playlet.CompressedCover: bson.M{"$exists": false}}, + {playletmod.FN.Playlet.CompressedCover: ""}, + {playletmod.FN.Playlet.CompressedCover: nil}, + }, + } +} + +func pushPlayletCoverCompressJob(ctx context.Context, playletId int64) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + payload, err := json.Marshal(playletCoverCompressJob{ + PlayletId: playletId, + CreateTime: time.Now().Unix(), + }) + if err != nil { + return err + } + return redis.RPush(playletCoverCompressQueueKey(), string(payload)) +} + +func runPlayletCoverCompressWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + result, err := redis.Client().BLPop(playletCoverCompressPopWait, playletCoverCompressQueueKey()).Result() + if err != nil { + if err == goredis.Nil { + continue + } + log.Error("消费短剧封面压缩任务失败", log.E(err)) + time.Sleep(playletCoverCompressRetryWait) + continue + } + if len(result) < 2 { + log.Error("短剧封面压缩任务格式异常", log.Any("result", result)) + continue + } + handlePlayletCoverCompressPayload(ctx, result[1]) + } +} + +func handlePlayletCoverCompressPayload(ctx context.Context, payload string) { + defer func() { + if err := recover(); err != nil { + log.Error("处理短剧封面压缩任务异常", log.Any("err", err), log.Any("payload", payload)) + } + }() + var job playletCoverCompressJob + if err := json.Unmarshal([]byte(payload), &job); err != nil { + log.Error("解析短剧封面压缩任务失败", log.Any("payload", payload), log.E(err)) + return + } + if job.PlayletId <= 0 { + return + } + locked, err := redis.Setnx_NewOK(playletCoverCompressLockKey(job.PlayletId), true, playletCoverCompressLockTTL) + if err != nil { + log.Error("获取短剧封面压缩锁失败", log.Any("playletId", job.PlayletId), log.E(err)) + return + } + if !locked { + return + } + defer func() { + if err := redis.Del(playletCoverCompressLockKey(job.PlayletId)); err != nil { + log.Error("释放短剧封面压缩锁失败", log.Any("playletId", job.PlayletId), log.E(err)) + } + }() + playlet, serr := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: job.PlayletId}, options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + })) + if serr != nil || playlet == nil { + if serr != nil { + log.Warn("查询待压缩短剧失败", log.Any("playletId", job.PlayletId), log.E(serr)) + } + return + } + if FillPlayletCompressedCover(ctx, playlet) { + log.Info("历史短剧压缩封面完成", log.Any("playletId", playlet.Id)) + return + } + if strings.TrimSpace(playlet.CompressedCover) == "" && strings.TrimSpace(playlet.Cover) != "" { + if err := redis.Set(playletCoverCompressFailKey(playlet.Id), "1", playletCoverCompressFailTTL); err != nil { + log.Warn("记录短剧封面压缩失败冷却失败", log.Any("playletId", playlet.Id), log.E(err)) + } + } +} diff --git a/web/service/srv_mediaresource/cover_compress.go b/web/service/srv_mediaresource/cover_compress.go new file mode 100644 index 0000000..ab0ac85 --- /dev/null +++ b/web/service/srv_mediaresource/cover_compress.go @@ -0,0 +1,177 @@ +package srv_mediaresource + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "videoweb/pub/common/imgutil" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/models/cfg" + "videoweb/pub/models/playletmod" + "videoweb/pub/storage/proxy" + "videoweb/web/service/srv_search" + + "go.mongodb.org/mongo-driver/bson" +) + +// compressPlayletCoverOrEmpty 下载原封面并按 media Resize 规则处理。 +// 原图未超过 60KB,或原图是 GIF 时,直接返回原封面地址;其它格式超过阈值则压缩为 JPEG 后上传。 +// 失败返回空字符串,不阻断入库。 +func compressPlayletCoverOrEmpty(cover string) string { + cover = strings.TrimSpace(cover) + if cover == "" { + return "" + } + pathStr, err := uploadCompressedPlayletCover(cover) + if err != nil { + log.Warn("压缩短剧封面失败", log.Any("cover", cover), log.E(err)) + return "" + } + return pathStr +} + +// FillPlayletCompressedCover 为指定短剧补写压缩封面,已有值则跳过。 +func FillPlayletCompressedCover(ctx context.Context, playlet *playletmod.Playlet) bool { + if playlet == nil || playlet.Id <= 0 { + return false + } + if strings.TrimSpace(playlet.CompressedCover) != "" { + return false + } + cover := strings.TrimSpace(playlet.Cover) + if cover == "" { + return false + } + pathStr := compressPlayletCoverOrEmpty(cover) + if pathStr == "" { + return false + } + now := time.Now() + if err := playletmod.UpdateOnePlaylet(ctx, + bson.M{ + playletmod.FN.Playlet.Id: playlet.Id, + "$or": []bson.M{ + {playletmod.FN.Playlet.CompressedCover: bson.M{"$exists": false}}, + {playletmod.FN.Playlet.CompressedCover: ""}, + {playletmod.FN.Playlet.CompressedCover: nil}, + }, + }, + bson.M{"$set": bson.M{ + playletmod.FN.Playlet.CompressedCover: pathStr, + playletmod.FN.Playlet.UpdatedAt: now, + }}, + ); err != nil { + log.Warn("回写短剧压缩封面失败", log.Any("playletId", playlet.Id), log.E(err)) + return false + } + playlet.CompressedCover = pathStr + latest, serr := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playlet.Id}) + if serr == nil && latest != nil && latest.Status == playletmod.StatusUp { + if syncErr := srv_search.SyncPlayletStatusToES(ctx, []int64{playlet.Id}, latest.Status); syncErr != nil { + log.Warn("压缩封面后同步ES失败", log.Any("playletId", playlet.Id), log.E(syncErr)) + } + } + return true +} + +func uploadCompressedPlayletCover(cover string) (string, error) { + downloadURL, err := resolvePlayletCoverURL(cover) + if err != nil { + return "", err + } + downloadURL, err = encodeCoverDownloadURL(downloadURL) + if err != nil { + return "", err + } + data, err := downloadPlayletCover(downloadURL) + if err != nil { + return "", err + } + data = imgutil.DecryptImage(data) + if !imgutil.NeedCompress(data) { + return cover, nil + } + fileName := imgutil.SafeCoverFileName(downloadURL) + compressed, outName, err := imgutil.CompressCover(data, fileName) + if err != nil { + return "", err + } + pathStr, err := proxy.UploadImg(outName, compressed, "") + if err != nil { + return "", fmt.Errorf("上传压缩封面失败: %w", err) + } + if strings.TrimSpace(pathStr) == "" { + return "", fmt.Errorf("上传压缩封面返回空路径") + } + return pathStr, nil +} + +func resolvePlayletCoverURL(cover string) (string, error) { + if strings.HasPrefix(cover, "http://") || strings.HasPrefix(cover, "https://") { + return cover, nil + } + sysCfg, serr := cfg.FindOneSysCfg(context.Background(), bson.M{cfg.FN.SysCfg.Id: 1}) + if serr != nil { + return "", fmt.Errorf("查询图片CDN失败: %s", serr.Error()) + } + imageDomain := "" + if sysCfg != nil { + imageDomain = strings.TrimSpace(sysCfg.ImgWebCdn) + } + if imageDomain == "" { + return "", fmt.Errorf("图片CDN未配置,无法下载相对路径封面") + } + return netutil.MergeURL(imageDomain, cover), nil +} + +// encodeCoverDownloadURL 把路径里的中文等非 ASCII 段转成百分号编码,避免下载失败或二次拼接异常。 +func encodeCoverDownloadURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("解析封面地址失败: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("封面地址无效") + } + escaped := u.EscapedPath() + if escaped != "" { + if dec, err := url.PathUnescape(escaped); err == nil { + u.Path = dec + u.RawPath = "" + } + } + return u.String(), nil +} + +func downloadPlayletCover(rawURL string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("下载封面失败: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; xjsp-playlet-cover/1.0)") + req.Header.Set("Accept", "image/*") + + cli := &http.Client{Timeout: 90 * time.Second} + resp, err := cli.Do(req) + if err != nil { + return nil, fmt.Errorf("下载封面失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载封面失败,状态码 %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + if err != nil { + return nil, fmt.Errorf("下载封面失败: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("下载封面内容为空") + } + return data, nil +} diff --git a/web/service/srv_mediaresource/import_worker.go b/web/service/srv_mediaresource/import_worker.go new file mode 100644 index 0000000..d9adf81 --- /dev/null +++ b/web/service/srv_mediaresource/import_worker.go @@ -0,0 +1,169 @@ +package srv_mediaresource + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + + goredis "github.com/go-redis/redis" +) + +const ( + playletResourceImportQueueKeyFmt = "playlet:resource:import:queue:%d" + playletResourceImportLockKeyFmt = "playlet:resource:import:lock:%d:%d" + playletResourceImportLockTTL = 10 * time.Minute + playletResourceImportPopWait = 5 * time.Second + playletResourceImportRetryWait = time.Second +) + +type playletResourceImportJob struct { + Ids []uint `json:"ids"` + OperatorId int64 `json:"operatorId"` + OperatorName string `json:"operatorName"` + CreateTime int64 `json:"createTime"` +} + +var playletResourceImportWorker = struct { + mu sync.Mutex + cancel context.CancelFunc +}{} + +func playletResourceImportQueueKey() string { + return fmt.Sprintf(playletResourceImportQueueKeyFmt, pubdef.AppId) +} + +func playletResourceImportLockKey(mediaId uint) string { + return fmt.Sprintf(playletResourceImportLockKeyFmt, pubdef.AppId, mediaId) +} + +// Start 启动短剧媒资导入消费者 +func Start() { + playletResourceImportWorker.mu.Lock() + defer playletResourceImportWorker.mu.Unlock() + if playletResourceImportWorker.cancel != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + playletResourceImportWorker.cancel = cancel + go runPlayletResourceImportWorker(ctx) + go runPlayletCoverCompressWorker(ctx) + go runPlayletSerialSyncWorker(ctx) + go func() { + time.Sleep(10 * time.Second) + EnqueuePlayletCoverCompressJobs() + }() + log.Info("短剧媒资后台消费者已启动") +} + +// Stop 停止短剧媒资导入消费者 +func Stop() { + playletResourceImportWorker.mu.Lock() + defer playletResourceImportWorker.mu.Unlock() + if playletResourceImportWorker.cancel == nil { + return + } + playletResourceImportWorker.cancel() + playletResourceImportWorker.cancel = nil + log.Info("短剧媒资后台消费者已停止") +} + +// pushPlayletResourceImportJob 投递短剧媒资导入任务 +func pushPlayletResourceImportJob(ctx context.Context, ids []uint, operatorId int64, operatorName string) *srvdef.SrvErr { + select { + case <-ctx.Done(): + log.Error("投递短剧媒资导入任务取消", log.E(ctx.Err())) + return srvdef.New(srvdef.Failure, "提交同步任务失败", "提交同步任务失败") + default: + } + job := playletResourceImportJob{ + Ids: ids, + OperatorId: operatorId, + OperatorName: operatorName, + CreateTime: time.Now().Unix(), + } + payload, err := json.Marshal(job) + if err != nil { + log.Error("序列化短剧媒资导入任务失败", log.Any("ids", ids), log.E(err)) + return srvdef.New(srvdef.Failure, "提交同步任务失败", "提交同步任务失败") + } + if err = redis.RPush(playletResourceImportQueueKey(), string(payload)); err != nil { + log.Error("投递短剧媒资导入任务失败", log.Any("ids", ids), log.E(err)) + return srvdef.New(srvdef.Failure, "提交同步任务失败", "提交同步任务失败") + } + log.Info("投递短剧媒资导入任务成功", log.Any("ids", ids), log.Any("count", len(ids))) + return nil +} + +// runPlayletResourceImportWorker 循环消费短剧媒资导入队列 +func runPlayletResourceImportWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + result, err := redis.Client().BLPop(playletResourceImportPopWait, playletResourceImportQueueKey()).Result() + if err != nil { + if err == goredis.Nil { + continue + } + log.Error("消费短剧媒资导入任务失败", log.E(err)) + time.Sleep(playletResourceImportRetryWait) + continue + } + if len(result) < 2 { + log.Error("短剧媒资导入任务格式异常", log.Any("result", result)) + continue + } + handlePlayletResourceImportPayload(ctx, result[1]) + } +} + +// handlePlayletResourceImportPayload 处理单个短剧媒资导入任务 +func handlePlayletResourceImportPayload(ctx context.Context, payload string) { + defer func() { + if err := recover(); err != nil { + log.Error("处理短剧媒资导入任务异常", log.Any("err", err), log.Any("payload", payload)) + } + }() + var job playletResourceImportJob + if err := json.Unmarshal([]byte(payload), &job); err != nil { + log.Error("解析短剧媒资导入任务失败", log.Any("payload", payload), log.E(err)) + return + } + ids := dedupePlayletResourceIds(job.Ids) + if len(ids) == 0 { + log.Error("短剧媒资导入任务没有有效媒资ID", log.Any("payload", payload)) + return + } + log.Info("开始处理短剧媒资导入任务", + log.Any("ids", ids), + log.Any("count", len(ids)), + log.Any("createTime", job.CreateTime), + log.Any("operatorId", job.OperatorId), + log.Any("operatorName", job.OperatorName)) + resp := importPlayletResourceBatch(ctx, ids, job.OperatorId, job.OperatorName) + log.Info("短剧媒资导入任务处理完成", + log.Any("ids", ids), + log.Any("successCount", len(resp.Success)), + log.Any("failedCount", len(resp.Failed)), + log.Any("failed", resp.Failed)) +} + +// lockPlayletResourceImportMedia 锁定单条媒资导入 +func lockPlayletResourceImportMedia(mediaId uint) (bool, error) { + return redis.Setnx_NewOK(playletResourceImportLockKey(mediaId), true, playletResourceImportLockTTL) +} + +// unlockPlayletResourceImportMedia 释放单条媒资导入锁 +func unlockPlayletResourceImportMedia(mediaId uint) { + if err := redis.Del(playletResourceImportLockKey(mediaId)); err != nil { + log.Error("释放短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err)) + } +} diff --git a/web/service/srv_mediaresource/media_center.go b/web/service/srv_mediaresource/media_center.go new file mode 100644 index 0000000..a3b0fc4 --- /dev/null +++ b/web/service/srv_mediaresource/media_center.go @@ -0,0 +1,214 @@ +package srv_mediaresource + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "videoweb/pub/common" + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" + "videoweb/web/api/proto" +) + +const ( + playletMediaListErrMsg = "短剧媒资列表查询失败" + playletMediaSyncErrMsg = "短剧媒资同步失败" + playletMediaDeletedCode = 7003 +) + +// playletMediaCenterService 短剧媒资中心客户端 +type playletMediaCenterService struct { + url string // 媒资中心接口域名 + proId string // 媒资中心商户号 + secret string // 媒资中心签名密钥,当前接口按源项目逻辑暂不签名 +} + +// playletMediaCenterConfig 创建短剧媒资中心客户端所需配置 +type playletMediaCenterConfig struct { + Url string // 媒资中心接口域名 + ProId string // 媒资中心商户号 + Secret string // 媒资中心签名密钥 +} + +// playletMediaCenterMsg 媒资中心统一响应结构 +type playletMediaCenterMsg struct { + Code int `json:"code"` // 媒资中心业务码,0 表示成功 + Msg string `json:"msg"` // 媒资中心业务消息 + Data json.RawMessage `json:"data"` // 媒资中心业务数据,校验 code 成功后再解析 +} + +// playletMediaPagination 媒资中心列表分页信息 +type playletMediaPagination struct { + IsLastPage bool `json:"is_last_page"` // 是否最后一页 + Page uint `json:"page"` // 当前页码 + Size uint `json:"size"` // 每页数量 + Count uint `json:"count"` // 总数量 +} + +// playletMediaListReq 媒资中心 /services/media/list 查询参数 +type playletMediaListReq struct { + MediaType int `json:"media_type"` // 媒体类型:1-动漫 2-漫画 4-漫画短剧 5-短剧 6-普通短剧 + Id *uint `json:"id"` // 媒资中心资源ID + VloggerId *uint `json:"vlogger_id"` // UP主ID + IsUp *int `json:"is_up"` // 是否UP主资源:0-否 1-是 + Title *string `json:"title"` // 标题筛选 + Tag *string `json:"tag"` // 标签筛选 + SyncType int `json:"sync_type"` // 同步类型:1-全量 2-增量 + Page uint `json:"page"` // 页码 + Size uint `json:"size"` // 每页数量 +} + +// playletMediaListResp 媒资中心列表响应 data 节点 +type playletMediaListResp struct { + Data []*proto.PlayletResourceListItem `json:"data"` // 短剧媒资列表 + Pagination playletMediaPagination `json:"pagination"` // 分页信息 +} + +// playletMediaContent 媒资中心短剧章节内容 +type playletMediaContent struct { + ID uint `json:"id"` // 媒资内容ID + SaleCoin uint `json:"sale_coin"` // 单集售价 + VloggerId uint `json:"vlogger_id"` // UP主ID + Cover string `json:"cover"` // 封面 + MediaId uint `json:"video_id"` // 媒资中心资源ID + MediaType int `json:"media_type"` // 媒体类型 + EpisodeNumber int `json:"episode_number"` // 第几集 + Title string `json:"title"` // 章节名 + Text string `json:"text"` // 文本内容 + Urls string `json:"urls"` // 资源地址集 + Height uint `json:"height"` // 高 + Width uint `json:"width"` // 宽 + Ratio float64 `json:"ratio"` // 宽高比 + MediaSize uint `json:"media_size"` // 资源大小 + PlayTime uint `json:"play_time"` // 播放时长 + M3u8Url string `json:"m3u8_url"` // m3u8视频地址 + AudioUrl string `json:"audio_url"` // 音频地址 + FsResourceId string `json:"fs_resource_id"` // 文件服务资源ID + FsResourceStatus string `json:"fs_resource_status"` // 文件服务资源状态 + HashId string `json:"hash_id"` // 资源hash + DataVersion int8 `json:"data_version"` // 数据版本 + TerminalCdo string `json:"terminal_cdo"` // 上传者 + CreateTime int64 `json:"create_time"` // 创建时间 + UpdateTime int64 `json:"update_time"` // 更新时间 +} + +// playletMediaSyncReq 媒资中心 /services/media/sync 查询参数 +type playletMediaSyncReq struct { + Id uint `json:"id"` // 媒资中心资源ID + SyncTime int64 `json:"sync_time"` // 增量同步时间,0 表示拉全量详情 +} + +// playletMediaSyncResp 媒资中心同步响应 data 节点 +type playletMediaSyncResp struct { + IsDelete bool `json:"-"` + Media *proto.PlayletResourceListItem `json:"media"` + MediaContentList []playletMediaContent `json:"media_content_list"` +} + +// newPlayletMediaCenterService 创建短剧媒资中心客户端 +func newPlayletMediaCenterService(c playletMediaCenterConfig) *playletMediaCenterService { + return &playletMediaCenterService{ + url: c.Url, + proId: c.ProId, + secret: c.Secret, + } +} + +// mediaListParams 构造媒资中心列表查询参数 +func (m *playletMediaCenterService) mediaListParams(p *playletMediaListReq) map[string]string { + params := map[string]string{ + "media_type": strconv.Itoa(p.MediaType), + "sync_type": strconv.Itoa(p.SyncType), + "page": strconv.FormatUint(uint64(p.Page), 10), + "size": strconv.FormatUint(uint64(p.Size), 10), + "pro_id": m.proId, + "sign": m.sign(), + } + if p.Id != nil { + params["id"] = strconv.FormatUint(uint64(*p.Id), 10) + } + if p.VloggerId != nil { + params["vlogger_id"] = strconv.FormatUint(uint64(*p.VloggerId), 10) + } + if p.IsUp != nil { + params["is_up"] = strconv.Itoa(*p.IsUp) + } + if p.Title != nil { + params["title"] = *p.Title + } + if p.Tag != nil { + params["tag"] = *p.Tag + } + return params +} + +// mediaSyncParams 构造媒资中心同步查询参数 +func (m *playletMediaCenterService) mediaSyncParams(p *playletMediaSyncReq) map[string]string { + return map[string]string{ + "id": strconv.FormatUint(uint64(p.Id), 10), + "sync_time": strconv.FormatInt(p.SyncTime, 10), + "pro_id": m.proId, + "sign": m.sign(), + } +} + +// mediaList 查询短剧媒资列表 +func (m *playletMediaCenterService) mediaList(p *playletMediaListReq) (result playletMediaListResp, err error) { + var retMsg playletMediaCenterMsg + params := m.mediaListParams(p) + path := "/services/media/list" + log.Info("查询短剧媒资列表", log.Any("params", params)) + code, err := httputil.Client().GetWithJResp(&retMsg, common.BindUrl(m.url, path), nil, params) + if err != nil { + log.Error("查询短剧媒资列表请求失败", log.Any("code", code), log.E(err)) + return result, errors.New(playletMediaListErrMsg) + } + if code != http.StatusOK { + log.Error("查询短剧媒资列表状态码异常", log.Any("code", code)) + return result, errors.New(playletMediaListErrMsg) + } + if retMsg.Code != 0 { + log.Error("查询短剧媒资列表业务码异常", log.Any("retMsg", retMsg)) + return result, errors.New(playletMediaListErrMsg) + } + if err = json.Unmarshal(retMsg.Data, &result); err != nil { + log.Error("解析短剧媒资列表失败", log.E(err)) + return result, errors.New(playletMediaListErrMsg) + } + return result, nil +} + +// mediaSync 查询短剧媒资详情和章节内容 +func (m *playletMediaCenterService) mediaSync(p *playletMediaSyncReq) (result playletMediaSyncResp, err error) { + var retMsg playletMediaCenterMsg + params := m.mediaSyncParams(p) + path := "/services/media/sync" + code, err := httputil.Client().GetWithJResp(&retMsg, common.BindUrl(m.url, path), nil, params) + if err != nil { + log.Error("同步短剧媒资详情请求失败", log.Any("params", params), log.Any("code", code), log.E(err)) + return result, errors.New(playletMediaSyncErrMsg) + } + if code != http.StatusOK { + log.Error("同步短剧媒资详情状态码异常", log.Any("params", params), log.Any("code", code)) + return result, errors.New(playletMediaSyncErrMsg) + } + if retMsg.Code == playletMediaDeletedCode { + result.IsDelete = true + return result, nil + } + if retMsg.Code != 0 { + log.Error("同步短剧媒资详情业务码异常", log.Any("params", params), log.Any("retMsg", retMsg)) + return result, errors.New(playletMediaSyncErrMsg) + } + if err = json.Unmarshal(retMsg.Data, &result); err != nil { + log.Error("解析短剧媒资详情失败", log.Any("params", params), log.E(err)) + return result, errors.New(playletMediaSyncErrMsg) + } + return result, nil +} + +// sign 生成媒资中心签名 +func (m *playletMediaCenterService) sign() string { + return "" +} diff --git a/web/service/srv_mediaresource/original_backfill.go b/web/service/srv_mediaresource/original_backfill.go new file mode 100644 index 0000000..1fdfbca --- /dev/null +++ b/web/service/srv_mediaresource/original_backfill.go @@ -0,0 +1,270 @@ +package srv_mediaresource + +import ( + "context" + "sync" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // defaultOriginalPlayletBackfillPageSize 与媒资库列表默认分页一致。 + defaultOriginalPlayletBackfillPageSize = int64(20) + // defaultOriginalPlayletBackfillMaxPages 限制启动回填最多扫描 2000 条媒资。 + defaultOriginalPlayletBackfillMaxPages = int64(100) + // maxOriginalPlayletBackfillMaxPages 防止误配置导致启动任务长时间扫全量。 + maxOriginalPlayletBackfillMaxPages = int64(1000) +) + +// originalPlayletBackfillOnce 保证每个进程只触发一次启动回填。 +var originalPlayletBackfillOnce sync.Once + +// originalPlayletBackfillOptions 控制 CGDJ 原创短剧回填范围。 +type originalPlayletBackfillOptions struct { + DryRun bool // 只预览不写库 + PageSize int64 // 媒资库分页大小 + MaxPages int64 // 最多扫描页数 +} + +// originalPlayletBackfillItem 记录单部已导入短剧的回填结果。 +type originalPlayletBackfillItem struct { + MediaCenterId int64 // 媒资库主资源 ID + PlayletId int64 // 本地短剧 ID + Title string // 本地短剧标题 + MediaTitle string // 媒资库原始标题 + Source string // 本次识别出的媒资来源 + Before string // 回填前媒资来源 + Result string // updated / preview / already_marked / conflict / failed +} + +// originalPlayletBackfillResp 汇总一次 CGDJ 回填统计。 +type originalPlayletBackfillResp struct { + DryRun bool + PageSize int64 + MaxPages int64 + Pages int64 + SourceTotal int64 // 媒资库返回总数 + SourceScanned int64 // 已扫描媒资条数 + SourceMatched int64 // 匹配 CGDJ 前缀的媒资条数 + Imported int64 // 已导入本地短剧库的条数 + Matched int64 // 需要写入媒资来源的条数 + Updated int64 // 实际更新成功条数 + Skipped int64 // 已有相同来源或并发冲突跳过条数 + HasMore bool // 达到 MaxPages 后仍可能存在后续数据 + List []*originalPlayletBackfillItem +} + +// originalPlayletBackfillStore 抽象外部依赖,方便单测覆盖媒资库和 Mongo。 +type originalPlayletBackfillStore struct { + mediaList func(*playletMediaListReq) (playletMediaListResp, error) + findMany func(context.Context, bson.M, *options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) + updateOne func(context.Context, bson.M, bson.M) *srvdef.SrvErr + audit func(context.Context, *originalPlayletBackfillItem) +} + +// StartCGDJOriginalBackfillOnce 在 web 启动后异步执行一次 CGDJ 原创短剧标记回填。 +func StartCGDJOriginalBackfillOnce(ctx context.Context) { + originalPlayletBackfillOnce.Do(func() { + go runCGDJOriginalBackfillOnce(ctx) + }) +} + +// runCGDJOriginalBackfillOnce 执行启动回填,成功后顺手触发一次昨日数据回流。 +func runCGDJOriginalBackfillOnce(ctx context.Context) { + resp, serr := backfillCGDJOriginalPlaylets(ctx, originalPlayletBackfillOptions{DryRun: false}) + if serr != nil { + log.Error("CGDJ 原创短剧标记启动回填失败", log.Any("err", serr), log.Any("resp", resp)) + return + } + log.Info("CGDJ 原创短剧标记启动回填完成", + log.Any("pages", resp.Pages), + log.Any("sourceTotal", resp.SourceTotal), + log.Any("sourceScanned", resp.SourceScanned), + log.Any("sourceMatched", resp.SourceMatched), + log.Any("imported", resp.Imported), + log.Any("matched", resp.Matched), + log.Any("updated", resp.Updated), + log.Any("skipped", resp.Skipped), + log.Any("hasMore", resp.HasMore)) + ReportAIContentBackflowYesterday() +} + +// backfillCGDJOriginalPlaylets 从媒资库 CGDJ 短剧列表回填本地短剧 mediaBusiness。 +// 判断依据只使用源媒资标题前缀和 mediaCenterId,不依赖本地短剧标题,避免运营改名后漏判。 +func backfillCGDJOriginalPlaylets(ctx context.Context, opts originalPlayletBackfillOptions) (*originalPlayletBackfillResp, *srvdef.SrvErr) { + ms := newPlayletMediaCenterFromConfig() + return backfillCGDJOriginalPlayletsWithStore(ctx, opts, originalPlayletBackfillStore{ + mediaList: ms.mediaList, + findMany: func(ctx context.Context, filter bson.M, opts *options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) { + return playletmod.FindManyPlaylet(ctx, filter, opts) + }, + updateOne: playletmod.UpdateOnePlaylet, + audit: func(ctx context.Context, item *originalPlayletBackfillItem) { + log.Info("回填短剧媒资业务来源", log.Any("playletId", item.PlayletId), log.Any("mediaCenterId", item.MediaCenterId), log.Any("title", item.Title), log.Any("mediaTitle", item.MediaTitle), log.Any("mediaBusiness", item.Source)) + }, + }) +} + +// backfillCGDJOriginalPlayletsWithStore 按媒资库 CGDJ 搜索结果回填本地短剧媒资来源。 +func backfillCGDJOriginalPlayletsWithStore(ctx context.Context, opts originalPlayletBackfillOptions, store originalPlayletBackfillStore) (*originalPlayletBackfillResp, *srvdef.SrvErr) { + pageSize, maxPages, serr := normalizeOriginalPlayletBackfillOptions(opts) + if serr != nil { + return nil, serr + } + resp := &originalPlayletBackfillResp{ + DryRun: opts.DryRun, + PageSize: pageSize, + MaxPages: maxPages, + List: make([]*originalPlayletBackfillItem, 0), + } + for _, title := range playletMediaBusinessSearchTitles() { + searchTitle := title + for page := int64(1); page <= maxPages; page++ { + data, err := store.mediaList(&playletMediaListReq{ + MediaType: playletResourceMediaTypeShort, + Title: &searchTitle, + SyncType: defaultResourceSyncType, + Page: uint(page), + Size: uint(pageSize), + }) + if err != nil { + log.Error("查询 CGDJ 短剧媒资失败", log.Any("title", searchTitle), log.Any("page", page), log.E(err)) + return resp, srvdef.New(srvdef.Failure, "查询 CGDJ 短剧媒资失败", "查询 CGDJ 短剧媒资失败") + } + resp.Pages++ + if page == 1 { + resp.SourceTotal += int64(data.Pagination.Count) + } + if len(data.Data) == 0 { + break + } + if serr = backfillOriginalPlayletPage(ctx, data.Data, resp, store); serr != nil { + return resp, serr + } + if data.Pagination.IsLastPage || int64(len(data.Data)) < pageSize { + break + } + if page == maxPages { + resp.HasMore = true + } + } + } + return resp, nil +} + +// normalizeOriginalPlayletBackfillOptions 修正分页参数并限制启动扫描范围。 +func normalizeOriginalPlayletBackfillOptions(opts originalPlayletBackfillOptions) (int64, int64, *srvdef.SrvErr) { + pageSize := opts.PageSize + if pageSize <= 0 { + pageSize = defaultOriginalPlayletBackfillPageSize + } + maxPages := opts.MaxPages + if maxPages <= 0 { + maxPages = defaultOriginalPlayletBackfillMaxPages + } + if pageSize > int64(maxResourcePageSize) { + return 0, 0, srvdef.New(srvdef.ErrParamError, "pageSize最多100", "pageSize最多100") + } + if maxPages > maxOriginalPlayletBackfillMaxPages { + return 0, 0, srvdef.New(srvdef.ErrParamError, "maxPages最多1000", "maxPages最多1000") + } + return pageSize, maxPages, nil +} + +// backfillOriginalPlayletPage 只更新已导入本地短剧库的 CGDJ 媒资。 +func backfillOriginalPlayletPage(ctx context.Context, mediaList []*proto.PlayletResourceListItem, resp *originalPlayletBackfillResp, store originalPlayletBackfillStore) *srvdef.SrvErr { + mediaTitleByID := make(map[uint]string, len(mediaList)) + mediaBusinessByID := make(map[uint]string, len(mediaList)) + mediaIDs := make([]uint, 0, len(mediaList)) + for _, media := range mediaList { + resp.SourceScanned++ + business := playletMediaBusiness(media) + if business == "" { + continue + } + if _, exists := mediaTitleByID[media.ID]; exists { + continue + } + resp.SourceMatched++ + mediaTitleByID[media.ID] = media.Title + mediaBusinessByID[media.ID] = business + mediaIDs = append(mediaIDs, media.ID) + } + if len(mediaIDs) == 0 { + return nil + } + playlets, serr := store.findMany(ctx, bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIDs}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.Find().SetProjection(bson.M{ + playletmod.FN.Playlet.Id: 1, + playletmod.FN.Playlet.MediaCenterId: 1, + playletmod.FN.Playlet.Title: 1, + playletmod.FN.Playlet.MediaBusiness: 1, + })) + if serr != nil { + log.Error("查询已导入 CGDJ 短剧失败", log.Any("mediaIds", mediaIDs), log.Any("err", serr)) + return serr + } + for _, playlet := range playlets { + if playlet == nil || playlet.MediaCenterId == 0 { + continue + } + business := mediaBusinessByID[playlet.MediaCenterId] + if business == "" { + continue + } + resp.Imported++ + item := &originalPlayletBackfillItem{ + MediaCenterId: int64(playlet.MediaCenterId), + PlayletId: playlet.Id, + Title: playlet.Title, + MediaTitle: mediaTitleByID[playlet.MediaCenterId], + Source: business, + Before: playlet.MediaBusiness, + } + if playlet.MediaBusiness == business { + resp.Skipped++ + item.Result = "already_marked" + resp.List = append(resp.List, item) + continue + } + resp.Matched++ + if resp.DryRun { + item.Result = "preview" + resp.List = append(resp.List, item) + continue + } + serr = store.updateOne(ctx, bson.M{ + playletmod.FN.Playlet.Id: playlet.Id, + playletmod.FN.Playlet.MediaCenterId: playlet.MediaCenterId, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + playletmod.FN.Playlet.MediaBusiness: bson.M{"$ne": business}, + }, bson.M{"$set": bson.M{ + playletmod.FN.Playlet.MediaBusiness: business, + }}) + if serr != nil { + if serr.Code == srvdef.ErrNoData { + resp.Skipped++ + item.Result = "conflict" + resp.List = append(resp.List, item) + continue + } + item.Result = "failed" + resp.List = append(resp.List, item) + return serr + } + resp.Updated++ + item.Result = "updated" + resp.List = append(resp.List, item) + if store.audit != nil { + store.audit(ctx, item) + } + } + return nil +} diff --git a/web/service/srv_mediaresource/resource.go b/web/service/srv_mediaresource/resource.go new file mode 100644 index 0000000..902b8ba --- /dev/null +++ b/web/service/srv_mediaresource/resource.go @@ -0,0 +1,678 @@ +package srv_mediaresource + +import ( + "context" + "math/rand" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/web/api/proto" + "videoweb/web/middleware/auth" + "videoweb/web/webg" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultResourceSyncType = 1 + defaultResourcePageNum = 1 + defaultResourcePageSize = 20 + maxResourcePageSize = 100 + maxResourceImportIds = 100 + playletResourceImportMsg = "同步任务已提交,后台正在处理" + playletHeatMinValue = 1000 + playletHeatMaxValue = 5000 + + playletResourceMediaTypeAnime = 1 // 动漫 + playletResourceMediaTypeComic = 4 // 漫画短剧 + playletResourceMediaTypeShort = 5 // 短剧 + playletResourceMediaTypeNormal = 6 // 普通短剧 +) + +// ListPlayletResource 分页查询短剧媒资列表 +func ListPlayletResource(ctx context.Context, req *proto.PlayletResourceListReq) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + syncType := defaultResourceSyncType + if req.SyncType != nil { + syncType = *req.SyncType + } + pageNumber := req.PageNumber + if pageNumber <= 0 { + pageNumber = defaultResourcePageNum + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = defaultResourcePageSize + } + if pageSize > maxResourcePageSize { + pageSize = maxResourcePageSize + } + mediaType, err1 := resolvePlayletResourceMediaType(req.MediaType) + if err1 != nil { + return nil, err1 + } + ms := newPlayletMediaCenterFromConfig() + data, err := ms.mediaList(&playletMediaListReq{ + MediaType: mediaType, + Title: req.Title, + Tag: req.Tag, + SyncType: syncType, + Page: uint(pageNumber), + Size: uint(pageSize), + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil { + return nil, err + } + return &proto.PlayletResourceListResp{ + List: data.Data, + Total: data.Pagination.Count, + }, nil +} + +// ListPlayletResourceChapters 查询短剧媒资剧集信息 +func ListPlayletResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) { + if req == nil || req.Id == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") + } + ms := newPlayletMediaCenterFromConfig() + result, err := ms.mediaSync(&playletMediaSyncReq{ + Id: req.Id, + SyncTime: 0, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if result.IsDelete || result.Media == nil { + return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "") + } + list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList)) + for _, item := range result.MediaContentList { + videoUrl := strings.TrimSpace(item.M3u8Url) + if videoUrl == "" { + videoUrl = strings.TrimSpace(item.Urls) + } + list = append(list, &proto.PlayletResourceChapterItem{ + Id: item.ID, + EpisodeNumber: item.EpisodeNumber, + Title: strings.TrimSpace(item.Title), + Cover: item.Cover, + VideoUrl: videoUrl, + Height: item.Height, + Width: item.Width, + MediaSize: item.MediaSize, + PlayTime: item.PlayTime, + SaleCoin: item.SaleCoin, + HashId: item.HashId, + CreateTime: item.CreateTime, + UpdateTime: item.UpdateTime, + }) + } + return &proto.PlayletResourceChapterListResp{ + MediaId: req.Id, + Title: result.Media.Title, + List: list, + Total: len(list), + }, nil +} + +// resolvePlayletResourceMediaType 校验后台显式传入的媒资类型 +func resolvePlayletResourceMediaType(mediaType *int) (int, *srvdef.SrvErr) { + if mediaType == nil { + return 0, srvdef.New(srvdef.ErrParamError, "媒体类型不能为空", "媒体类型不能为空") + } + if !validPlayletResourceMediaType(*mediaType) { + return 0, srvdef.New(srvdef.ErrParamError, "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧", "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧") + } + return *mediaType, nil +} + +// validPlayletResourceMediaType 媒资列表当前允许的媒体类型 +func validPlayletResourceMediaType(mediaType int) bool { + switch mediaType { + case playletResourceMediaTypeAnime, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: + return true + default: + return false + } +} + +// validPlayletImportMediaType 允许入库的媒资类型 +func validPlayletImportMediaType(mediaType int) bool { + switch mediaType { + case playletResourceMediaTypeAnime, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: + return true + default: + return false + } +} + +// contentTypeFromMediaType 媒资中心 media_type 映射为本项目 contentType +func contentTypeFromMediaType(mediaType int) playletmod.ContentType { + switch mediaType { + case playletResourceMediaTypeAnime: + return playletmod.ContentTypeAnime + case playletResourceMediaTypeComic: + return playletmod.ContentTypeComic + case playletResourceMediaTypeNormal: + return playletmod.ContentTypeAlbum + default: + return playletmod.ContentTypeTheater + } +} + +// fillPlayletResourceAddStatus 回填媒资列表是否已加入后台短剧库 +func fillPlayletResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceListItem) *srvdef.SrvErr { + mediaIds := make([]uint, 0, len(list)) + seen := make(map[uint]struct{}, len(list)) + for _, item := range list { + if item == nil || item.ID == 0 { + continue + } + if _, ok := seen[item.ID]; ok { + continue + } + seen[item.ID] = struct{}{} + mediaIds = append(mediaIds, item.ID) + } + if len(mediaIds) == 0 { + return nil + } + + playletList, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIds}, + }) + if err != nil { + log.Error("查询短剧媒资加入状态失败", log.Any("mediaIds", mediaIds), log.Any("err", err)) + return err + } + addedMap := make(map[uint]bool, len(playletList)) + for _, item := range playletList { + if item == nil || item.MediaCenterId == 0 { + continue + } + addedMap[item.MediaCenterId] = true + } + for _, item := range list { + if item != nil { + item.IsAdd = addedMap[item.ID] + } + } + return nil +} + +// ImportPlayletResource 提交短剧媒资入库任务 +func ImportPlayletResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) { + ids, err := validatePlayletResourceImportReq(req) + if err != nil { + return "", err + } + operatorId, operatorName := resolveImportOperator(ctx) + if err := pushPlayletResourceImportJob(ctx, ids, operatorId, operatorName); err != nil { + return "", err + } + return playletResourceImportMsg, nil +} + +// importPlayletResourceBatch 批量处理短剧媒资入库任务 +func importPlayletResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp { + resp := &proto.PlayletResourceUseResp{ + Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)), + Failed: make([]*proto.PlayletResourceUseFailed, 0), + } + importedMap, err := findImportedPlayletResourceMap(ctx, ids) + if err != nil { + for _, id := range ids { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: "查询导入状态失败", + }) + } + return resp + } + ms := newPlayletMediaCenterFromConfig() + for _, id := range ids { + if importedMap[id] { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: "该媒资已导入", + }) + continue + } + success, reason := importSinglePlayletResource(ctx, ms, id, operatorId, operatorName) + if reason != "" { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: reason, + }) + continue + } + resp.Success = append(resp.Success, success) + } + return resp +} + +// findImportedPlayletResourceMap 批量查询已经导入的媒资ID +func findImportedPlayletResourceMap(ctx context.Context, ids []uint) (map[uint]bool, *srvdef.SrvErr) { + result := make(map[uint]bool, len(ids)) + if len(ids) == 0 { + return result, nil + } + playletList, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": ids}, + }) + if err != nil { + log.Error("批量查询短剧媒资导入状态失败", log.Any("mediaIds", ids), log.Any("err", err)) + return nil, err + } + for _, item := range playletList { + if item == nil || item.MediaCenterId == 0 { + continue + } + result[item.MediaCenterId] = true + } + return result, nil +} + +func newPlayletMediaCenterFromConfig() *playletMediaCenterService { + return newPlayletMediaCenterService(playletMediaCenterConfig{ + Url: webg.Conf.MediaResourceDBCfg.BaseUrl, + ProId: webg.Conf.MediaResourceDBCfg.Merchat, + Secret: webg.Conf.MediaResourceDBCfg.Secret, + }) +} + +// validatePlayletResourceImportReq 校验短剧媒资入库请求 +func validatePlayletResourceImportReq(req *proto.PlayletResourceUseReq) ([]uint, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePlayletResourceIds(req.Ids) + if len(ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") + } + if len(ids) > maxResourceImportIds { + return nil, srvdef.New(srvdef.ErrParamError, "单次最多导入100条媒资", "") + } + return ids, nil +} + +// dedupePlayletResourceIds 去重并过滤无效媒资ID +func dedupePlayletResourceIds(ids []uint) []uint { + result := make([]uint, 0, len(ids)) + seen := make(map[uint]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +// importSinglePlayletResource 导入单条短剧媒资 +func importSinglePlayletResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) { + locked, err := lockPlayletResourceImportMedia(mediaId) + if err != nil { + log.Error("获取短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err)) + return nil, "获取导入锁失败" + } + if !locked { + return nil, "该媒资正在导入" + } + defer unlockPlayletResourceImportMedia(mediaId) + + if reason := checkPlayletResourceImported(ctx, mediaId); reason != "" { + return nil, reason + } + result, err := ms.mediaSync(&playletMediaSyncReq{ + Id: mediaId, + SyncTime: 0, + }) + if err != nil { + log.Error("同步短剧媒资失败", log.Any("mediaId", mediaId), log.E(err)) + return nil, "同步媒资失败" + } + if result.IsDelete || result.Media == nil { + return nil, "媒资不存在或已删除" + } + if !validPlayletImportMediaType(result.Media.MediaType) { + return nil, "该媒资类型不支持导入短剧" + } + if len(result.MediaContentList) == 0 { + return nil, "媒资章节为空" + } + + playlet := buildPlayletFromMedia(result.Media, operatorId, operatorName) + playlet.MediaSyncTime = maxPlayletMediaContentUpdateTime(0, result.MediaContentList) + if playlet.Title == "" { + return nil, "媒资标题为空" + } + exists := func(s string) bool { + item, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, 0)) + return item != nil + } + if serr := slugx.Ensure(&playlet.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, playlet.Title), exists); serr != nil { + if serr.Tips != "" { + return nil, serr.Tips + } + return nil, "slug生成失败" + } + // 原图继续写入 Cover;压缩成功才写 CompressedCover,失败保持空。 + playlet.CompressedCover = compressPlayletCoverOrEmpty(playlet.Cover) + chapters := buildPlayletChaptersFromMedia(result.MediaContentList) + if len(chapters) == 0 { + return nil, "媒资章节为空" + } + if err1 := playletmod.InsertOnePlaylet(ctx, playlet); err1 != nil { + log.Error("短剧媒资主表入库失败", log.Any("mediaId", mediaId), log.Any("err", err1)) + return nil, "主资源入库失败" + } + for _, chapter := range chapters { + chapter.PlayletId = playlet.Id + } + if err1 := playletchaptermod.InsertManyPlayletChapter(ctx, chapters); err1 != nil { + log.Error("短剧媒资章节批量入库失败", log.Any("mediaId", mediaId), log.Any("playletId", playlet.Id), log.Any("chapterCount", len(chapters)), log.Any("err", err1)) + rollbackImportedPlayletResource(ctx, playlet.Id, collectPlayletChapterIds(chapters)) + return nil, "章节入库失败" + } + return &proto.PlayletResourceUseSuccess{ + MediaId: mediaId, + PlayletId: playlet.Id, + }, "" +} + +// collectPlayletChapterIds 收集章节ID +func collectPlayletChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 { + ids := make([]int64, 0, len(chapters)) + for _, chapter := range chapters { + if chapter == nil || chapter.Id == 0 { + continue + } + ids = append(ids, chapter.Id) + } + return ids +} + +// checkPlayletResourceImported 检查媒资是否已经导入到本项目短剧表 +func checkPlayletResourceImported(ctx context.Context, mediaId uint) string { + _, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.MediaCenterId: mediaId}) + if err == nil { + return "该媒资已导入" + } + if err.Code != srvdef.ErrNoData { + log.Error("查询短剧媒资导入状态失败", log.Any("mediaId", mediaId), log.Any("err", err)) + return "查询导入状态失败" + } + return "" +} + +// buildPlayletFromMedia 将媒资中心主资源转换为本项目 playlet 主表模型 +func buildPlayletFromMedia(media *proto.PlayletResourceListItem, operatorId int64, operatorName string) *playletmod.Playlet { + cover := strings.TrimSpace(media.VerticalCover) + if cover == "" { + cover = strings.TrimSpace(media.HorizontalCover) + } + return &playletmod.Playlet{ + MediaCenterId: uint(media.ID), + MediaCreatedAt: mediaResourceTime(media.CreateTime), + MediaUpdatedAt: mediaResourceTime(media.UpdateTime), + Title: strings.TrimSpace(media.Title), + Desc: strings.TrimSpace(media.Summary), + Anchor: strings.TrimSpace(media.Anchor), + Cover: cover, + OldTags: oldTagNamesFromPlayletTagsText(media.TagsText), + MediaBusiness: playletMediaBusiness(media), + ContentType: contentTypeFromMediaType(media.MediaType), + TotalEpisode: int64(media.TotalEpisode), + UpdateStatus: normalizePlayletUpdateStatus(media.UpdateStatus), + HeatValue: randomPlayletHeatValue(), + Status: playletmod.StatusDown, + OperatorId: operatorId, + OperatorName: operatorName, + } +} + +// playletMediaBusinessCGDJ 赤果短剧、CGDJ 标题前缀对应的媒资业务来源。 +const playletMediaBusinessCGDJ = "CGDJ" + +// playletMediaBusinessRule 把媒资标题前缀映射成要写入的业务来源。先匹配先生效。 +type playletMediaBusinessRule struct { + Prefix string + Business string +} + +// playletMediaBusinessRules 多个前缀可以对应同一个业务来源。 +// 新增来源时在这里追加;同一来源的不同叫法共用同一个 Business。 +var playletMediaBusinessRules = []playletMediaBusinessRule{ + //{Prefix: "赤果短剧", Business: playletMediaBusinessCGDJ}, + {Prefix: "CGDJ", Business: playletMediaBusinessCGDJ}, +} + +func playletMediaBusiness(media *proto.PlayletResourceListItem) string { + if media == nil || media.MediaType != playletResourceMediaTypeShort { + return "" + } + title := strings.ToUpper(strings.TrimSpace(media.Title)) + for _, rule := range playletMediaBusinessRules { + prefix := strings.ToUpper(strings.TrimSpace(rule.Prefix)) + business := strings.TrimSpace(rule.Business) + if prefix == "" || business == "" { + continue + } + if strings.HasPrefix(title, prefix) { + return business + } + } + return "" +} + +// playletMediaBusinessSearchTitles 返回回填时用来搜索媒资库的标题前缀。 +func playletMediaBusinessSearchTitles() []string { + seen := make(map[string]struct{}, len(playletMediaBusinessRules)) + titles := make([]string, 0, len(playletMediaBusinessRules)) + for _, rule := range playletMediaBusinessRules { + prefix := strings.TrimSpace(rule.Prefix) + if prefix == "" { + continue + } + key := strings.ToUpper(prefix) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + titles = append(titles, prefix) + } + return titles +} + +func oldTagNamesFromPlayletTagsText(tagsText string) []string { + fields := strings.FieldsFunc(strings.TrimSpace(tagsText), func(r rune) bool { + switch r { + case ',', ',', '、', ';', ';', '/', '|': + return true + default: + return false + } + }) + names := make([]string, 0, len(fields)) + seen := make(map[string]struct{}, len(fields)) + for _, field := range fields { + name := strings.TrimSpace(field) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + return names +} + +func randomPlayletHeatValue() int64 { + return int64(rand.Intn(playletHeatMaxValue-playletHeatMinValue+1) + playletHeatMinValue) +} + +// mediaResourceTime 转换媒资中心时间戳 +func mediaResourceTime(ts int64) time.Time { + if ts <= 0 { + return time.Time{} + } + if ts > 1e12 { + return time.UnixMilli(ts) + } + return time.Unix(ts, 0) +} + +// buildPlayletChaptersFromMedia 将媒资中心章节资源转换为本项目 playlet_chapter 模型 +func buildPlayletChaptersFromMedia(mediaContentList []playletMediaContent) playletchaptermod.PlayletChapterList { + chapters := make(playletchaptermod.PlayletChapterList, 0, len(mediaContentList)) + seenMediaContentIds := make(map[uint]struct{}, len(mediaContentList)) + seenEpisodes := make(map[int]struct{}, len(mediaContentList)) + seenHashIds := make(map[string]struct{}, len(mediaContentList)) + seenVideoUrls := make(map[string]struct{}, len(mediaContentList)) + for _, item := range mediaContentList { + videoUrl := strings.TrimSpace(item.M3u8Url) + if videoUrl == "" { + videoUrl = strings.TrimSpace(item.Urls) + } + if videoUrl == "" { + continue + } + if isDuplicatePlayletMediaChapter(item, videoUrl, seenMediaContentIds, seenEpisodes, seenHashIds, seenVideoUrls) { + continue + } + episodeNumber := int64(item.EpisodeNumber) + if episodeNumber <= 0 { + episodeNumber = int64(len(chapters) + 1) + } + height := int64(item.Height) + width := int64(item.Width) + ratio := item.Ratio + if ratio <= 0 && height > 0 { + ratio = float64(width) / float64(height) + } + chapters = append(chapters, &playletchaptermod.PlayletChapter{ + MediaContentId: uint(item.ID), + CurrentEpisode: episodeNumber, + Title: strings.TrimSpace(item.Title), + Hash: item.HashId, + Height: height, + Width: width, + Ratio: ratio, + VideoUrl: videoUrl, + MediaSize: int64(item.MediaSize), + MediaTime: int64(item.PlayTime), + PayType: playletchaptermod.PayTypeFree, + SingleCoins: int64(item.SaleCoin), + WatermarkStatus: playletchaptermod.WatermarkStatusPending, + }) + } + return chapters +} + +// isDuplicatePlayletMediaChapter 判断媒资中心章节是否重复 +func isDuplicatePlayletMediaChapter(item playletMediaContent, videoUrl string, seenMediaContentIds map[uint]struct{}, seenEpisodes map[int]struct{}, seenHashIds map[string]struct{}, seenVideoUrls map[string]struct{}) bool { + if item.ID > 0 { + if _, ok := seenMediaContentIds[item.ID]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("mediaContentId", item.ID), log.Any("episodeNumber", item.EpisodeNumber)) + return true + } + seenMediaContentIds[item.ID] = struct{}{} + return false + } + if item.EpisodeNumber > 0 { + if _, ok := seenEpisodes[item.EpisodeNumber]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("episodeNumber", item.EpisodeNumber)) + return true + } + seenEpisodes[item.EpisodeNumber] = struct{}{} + return false + } + hashId := strings.TrimSpace(item.HashId) + if hashId != "" { + if _, ok := seenHashIds[hashId]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("hashId", hashId)) + return true + } + seenHashIds[hashId] = struct{}{} + return false + } + if _, ok := seenVideoUrls[videoUrl]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("videoUrl", videoUrl)) + return true + } + seenVideoUrls[videoUrl] = struct{}{} + return false +} + +// rollbackImportedPlayletResource 回滚单条媒资入库产生的数据 +func rollbackImportedPlayletResource(ctx context.Context, playletId int64, chapterIds []int64) { + for _, chapterId := range chapterIds { + if _, err := playletchaptermod.DelOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}); err != nil { + log.Error("回滚短剧媒资章节失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.Any("err", err)) + } + } + if _, err := playletmod.DelOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}); err != nil { + log.Error("回滚短剧媒资主表失败", log.Any("playletId", playletId), log.Any("err", err)) + } +} + +// resolveImportOperator 从后台登录态解析当前操作人 +func resolveImportOperator(ctx context.Context) (int64, string) { + c, ok := ctx.(*gin.Context) + if !ok || c == nil { + return 0, "" + } + accId := auth.GetAccount(c) + if accId <= 0 { + return 0, "" + } + acc, err := mwacc.FindOneAccByFilter(c, bson.M{ + mwacc.FN.MwAcc.Id: accId, + }, options.FindOne().SetProjection(bson.D{ + {Key: mwacc.FN.MwAcc.Id, Value: 1}, + {Key: mwacc.FN.MwAcc.UserName, Value: 1}, + })) + if err != nil || acc == nil { + return accId, "" + } + return acc.Id, acc.UserName +} + +// maxPlayletMediaContentUpdateTime 记录本次同步返回章节中的最大更新时间 +func maxPlayletMediaContentUpdateTime(syncTime int64, mediaContentList []playletMediaContent) int64 { + result := syncTime + for _, item := range mediaContentList { + if item.UpdateTime > result { + result = item.UpdateTime + } + } + return result +} + +// normalizePlayletUpdateStatus 三方 update_status=0 在本项目按 2-已完结保存 +func normalizePlayletUpdateStatus(updateStatus int) int { + if updateStatus == 0 { + return 2 + } + return updateStatus +} diff --git a/web/service/srv_mediaresource/serial_sync.go b/web/service/srv_mediaresource/serial_sync.go new file mode 100644 index 0000000..96d6a1c --- /dev/null +++ b/web/service/srv_mediaresource/serial_sync.go @@ -0,0 +1,468 @@ +package srv_mediaresource + +import ( + "context" + "encoding/json" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + "videoweb/web/api/proto" + + goredis "github.com/go-redis/redis" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // PlayletSerialSyncCronSpec 连载中短剧同步扫描频率:每1小时扫描一次,扫描阶段只投递 Redis 任务 + PlayletSerialSyncCronSpec = "0 0 * * * ?" + + // playletSerialSyncQueueKeyFmt 解耦定时扫描和三方同步调用,避免 cron 扫描过程直接阻塞在外部接口上 + playletSerialSyncQueueKeyFmt = "playlet:serial:sync:queue:%d" + playletSerialSyncLockKeyFmt = "playlet:serial:sync:lock:%d:%d" + playletSerialSyncLockTTL = 55 * time.Minute + playletSerialSyncPopWait = 5 * time.Second + playletSerialSyncRetryWait = time.Second + playletSerialSyncBatchSize = int64(100) + playletManualSyncMaxCount = 100 +) + +func playletSerialSyncQueueKey() string { + return fmt.Sprintf(playletSerialSyncQueueKeyFmt, pubdef.AppId) +} + +func playletSerialSyncLockKey(mediaId uint) string { + return fmt.Sprintf(playletSerialSyncLockKeyFmt, pubdef.AppId, mediaId) +} + +type playletSerialSyncJob struct { + PlayletId int64 `json:"playletId"` + MediaCenterId uint `json:"mediaCenterId"` + MediaSyncTime int64 `json:"mediaSyncTime"` + CreateTime int64 `json:"createTime"` +} + +// EnqueueSerialPlayletSyncJobs 扫描连载中短剧并投递 Redis 同步任务 +// +// 这里不直接请求三方媒资中心:扫描只负责发现任务,实际同步由后台消费者逐条处理 +// 可以降低定时任务执行时间,也方便 Redis 队列在三方接口短暂异常时继续缓冲任务 +func EnqueueSerialPlayletSyncJobs() { + ctx := context.Background() + filter := bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$gt": 0}, + playletmod.FN.Playlet.UpdateStatus: 1, + // 原创短剧由后台维护剧集,自动同步只处理媒资导入剧 + playletmod.FN.Playlet.IsOriginal: bson.M{"$ne": true}, + } + opts := options.Find(). + SetSort(bson.D{{Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, {Key: playletmod.FN.Playlet.Id, Value: 1}}). + SetLimit(playletSerialSyncBatchSize) + list, err := playletmod.FindManyPlaylet(ctx, filter, opts) + if err != nil { + log.Error("扫描连载中短剧失败", log.Any("err", err)) + return + } + enqueued := 0 + for _, item := range list { + if item == nil || item.Id == 0 || item.MediaCenterId == 0 || item.IsOriginal { + continue + } + if err := pushPlayletSerialSyncJob(ctx, item); err != nil { + log.Error("投递连载短剧同步任务失败", log.Any("playletId", item.Id), log.Any("mediaCenterId", item.MediaCenterId), log.E(err)) + continue + } + enqueued++ + } + log.Info("连载中短剧同步任务扫描完成", log.Any("scanCount", len(list)), log.Any("enqueued", enqueued)) +} + +// pushPlayletSerialSyncJob 将单部连载短剧投递到 Redis 队列 +func pushPlayletSerialSyncJob(ctx context.Context, playlet *playletmod.Playlet) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + job := playletSerialSyncJob{ + PlayletId: playlet.Id, + MediaCenterId: playlet.MediaCenterId, + MediaSyncTime: playlet.MediaSyncTime, + CreateTime: time.Now().Unix(), + } + payload, err := json.Marshal(job) + if err != nil { + return err + } + return redis.RPush(playletSerialSyncQueueKey(), string(payload)) +} + +// runPlayletSerialSyncWorker 常驻消费连载短剧同步队列 +func runPlayletSerialSyncWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + result, err := redis.Client().BLPop(playletSerialSyncPopWait, playletSerialSyncQueueKey()).Result() + if err != nil { + if err == goredis.Nil { + continue + } + log.Error("消费连载短剧同步任务失败", log.E(err)) + time.Sleep(playletSerialSyncRetryWait) + continue + } + if len(result) < 2 { + log.Error("连载短剧同步任务格式异常", log.Any("result", result)) + continue + } + handlePlayletSerialSyncPayload(ctx, result[1]) + } +} + +// handlePlayletSerialSyncPayload 解析单个 Redis 任务并执行同步,panic 会被捕获避免 worker 退出 +func handlePlayletSerialSyncPayload(ctx context.Context, payload string) { + defer func() { + if err := recover(); err != nil { + log.Error("处理连载短剧同步任务异常", log.Any("err", err), log.Any("payload", payload)) + } + }() + var job playletSerialSyncJob + if err := json.Unmarshal([]byte(payload), &job); err != nil { + log.Error("解析连载短剧同步任务失败", log.Any("payload", payload), log.E(err)) + return + } + if job.PlayletId <= 0 || job.MediaCenterId == 0 { + log.Error("连载短剧同步任务参数异常", log.Any("job", job)) + return + } + if _, reason := syncSerialPlaylet(ctx, job, false, false); reason != "" { + log.Error("连载短剧同步任务失败", log.Any("job", job), log.Any("reason", reason)) + return + } + log.Info("连载短剧同步任务完成", log.Any("job", job)) +} + +// SyncPlayletChapters 手动同步指定短剧的媒资剧集,支持单/批量,允许已完结短剧补集或修正状态。 +func SyncPlayletChapters(ctx context.Context, req *proto.PlayletChapterSyncReq) (*proto.PlayletChapterSyncResp, *srvdef.SrvErr) { + ids := normalizePlayletChapterSyncIds(req) + if len(ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + if len(ids) > playletManualSyncMaxCount { + return nil, srvdef.New(srvdef.ErrParamError, "单次最多同步100部短剧", "") + } + resp := &proto.PlayletChapterSyncResp{ + Total: len(ids), + List: make([]*proto.PlayletChapterSyncResult, 0, len(ids)), + } + for _, playletId := range ids { + item := syncOnePlayletChapters(ctx, playletId) + if item.Success { + resp.SuccessCount++ + } else { + resp.FailedCount++ + } + resp.List = append(resp.List, item) + } + return resp, nil +} + +// normalizePlayletChapterSyncIds 过滤重复或无效短剧ID。 +func normalizePlayletChapterSyncIds(req *proto.PlayletChapterSyncReq) []int64 { + if req == nil { + return nil + } + result := make([]int64, 0, len(req.PlayletIds)) + seen := make(map[int64]struct{}, len(req.PlayletIds)) + for _, id := range req.PlayletIds { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +// syncOnePlayletChapters 同步单部短剧的媒资剧集,单部失败不影响批量里其他短剧。 +func syncOnePlayletChapters(ctx context.Context, playletId int64) *proto.PlayletChapterSyncResult { + result := &proto.PlayletChapterSyncResult{ + PlayletId: playletId, + } + playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: playletId, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}, + {Key: playletmod.FN.Playlet.MediaSyncTime, Value: 1}, + })) + if err != nil { + result.Reason = playletChapterSyncErrReason(err) + return result + } + if playlet.MediaCenterId == 0 { + result.Reason = "非媒资导入短剧不能同步剧集" + return result + } + newChapterCount, reason := syncSerialPlaylet(ctx, playletSerialSyncJob{ + PlayletId: playlet.Id, + MediaCenterId: playlet.MediaCenterId, + MediaSyncTime: playlet.MediaSyncTime, + CreateTime: time.Now().Unix(), + }, true, true) + if reason != "" { + result.Reason = reason + return result + } + result.Success = true + result.NewChapterCount = newChapterCount + return result +} + +// playletChapterSyncErrReason 转换单部短剧同步失败原因,避免批量结果里 reason 为空。 +func playletChapterSyncErrReason(err *srvdef.SrvErr) string { + if err == nil { + return "" + } + if err.Code == srvdef.ErrNoData { + return "短剧不存在" + } + if reason := err.GetTip(); reason != "" { + return reason + } + if reason := err.GetMsg(); reason != "" { + return reason + } + return err.Error() +} + +// syncSerialPlaylet 更新主表集数/状态,只追加新增章节,不覆盖已有章节。 +// 手动同步使用 forceFullSync=true 全量拉取,便于恢复本地误删的旧章节;定时任务继续使用增量同步。 +func syncSerialPlaylet(ctx context.Context, job playletSerialSyncJob, allowCompleted bool, forceFullSync bool) (int, string) { + locked, err := lockPlayletSerialSync(job.MediaCenterId) + if err != nil { + log.Error("获取连载短剧同步锁失败", log.Any("job", job), log.E(err)) + return 0, "获取同步锁失败" + } + if !locked { + return 0, "该媒资正在同步" + } + defer unlockPlayletSerialSync(job.MediaCenterId) + + playlet, serr := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: job.PlayletId, + playletmod.FN.Playlet.MediaCenterId: job.MediaCenterId, + }) + if serr != nil { + if serr.Code == srvdef.ErrNoData { + return 0, "短剧不存在" + } + log.Error("查询连载短剧失败", log.Any("job", job), log.Any("err", serr)) + return 0, "查询短剧失败" + } + if !allowCompleted && playlet.IsOriginal { + return 0, "" + } + if !allowCompleted && playlet.UpdateStatus != 1 { + return 0, "" + } + + ms := newPlayletMediaCenterFromConfig() + syncTime := playlet.MediaSyncTime + if forceFullSync { + syncTime = 0 + } + result, err := ms.mediaSync(&playletMediaSyncReq{ + Id: playlet.MediaCenterId, + SyncTime: syncTime, + }) + if err != nil { + log.Error("同步连载短剧媒资失败", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.E(err)) + return 0, "同步媒资失败" + } + if result.IsDelete { + return 0, "媒资不存在或已删除" + } + if result.Media == nil { + return 0, "媒资主资源为空" + } + + newSyncTime := maxPlayletMediaContentUpdateTime(playlet.MediaSyncTime, result.MediaContentList) + if reason := updateSerialPlayletInfo(ctx, playlet, result.Media, newSyncTime); reason != "" { + return 0, reason + } + if len(result.MediaContentList) == 0 { + return 0, "" + } + chapters, reason := buildNewSerialPlayletChapters(ctx, playlet.Id, result.MediaContentList) + if reason != "" { + return 0, reason + } + if len(chapters) == 0 { + return 0, "" + } + // 上架且连载中时,新增剧集默认支付方式取剧级设置。 + applyPlayletDefaultPayToChapters(playlet, chapters) + if serr = playletchaptermod.InsertManyPlayletChapter(ctx, chapters); serr != nil { + log.Error("连载短剧新增章节入库失败", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.Any("count", len(chapters)), log.Any("err", serr)) + return 0, "新增章节入库失败" + } + log.Info("连载短剧新增章节成功", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.Any("count", len(chapters))) + return len(chapters), "" +} + +// updateSerialPlayletInfo 更新三方主资源里会随连载变化的字段 +func updateSerialPlayletInfo(ctx context.Context, playlet *playletmod.Playlet, media *proto.PlayletResourceListItem, mediaSyncTime int64) string { + updateStatus := normalizePlayletUpdateStatus(media.UpdateStatus) + setDoc := bson.M{ + playletmod.FN.Playlet.TotalEpisode: int64(media.TotalEpisode), + playletmod.FN.Playlet.UpdateStatus: updateStatus, + playletmod.FN.Playlet.MediaUpdatedAt: mediaResourceTime(media.UpdateTime), + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if mediaSyncTime > playlet.MediaSyncTime { + setDoc[playletmod.FN.Playlet.MediaSyncTime] = mediaSyncTime + } + if business := playletMediaBusiness(media); business != "" && playlet.MediaBusiness != business { + setDoc[playletmod.FN.Playlet.MediaBusiness] = business + } + if err := playletmod.UpdateOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: playlet.Id, + }, bson.M{"$set": setDoc}); err != nil { + log.Error("更新连载短剧主表失败", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.Any("err", err)) + return "更新主表失败" + } + return "" +} + +// buildNewSerialPlayletChapters 根据本地已存在章节做幂等过滤,只返回需要追加的新章节 +func buildNewSerialPlayletChapters(ctx context.Context, playletId int64, mediaContentList []playletMediaContent) (playletchaptermod.PlayletChapterList, string) { + exists, err := loadExistingPlayletChapterKeys(ctx, playletId) + if err != nil { + return nil, "查询已有章节失败" + } + chapters := buildPlayletChaptersFromMedia(mediaContentList) + result := make(playletchaptermod.PlayletChapterList, 0, len(chapters)) + for _, chapter := range chapters { + if chapter == nil || chapterDuplicateByKeys(chapter, exists) { + continue + } + chapter.PlayletId = playletId + markExistingPlayletChapterKeys(chapter, exists) + result = append(result, chapter) + } + return result, "" +} + +type existingPlayletChapterKeys struct { + mediaContentIds map[uint]struct{} + episodes map[int64]struct{} + hashes map[string]struct{} + videoUrls map[string]struct{} +} + +// loadExistingPlayletChapterKeys 加载当前短剧已入库章节的去重键,避免连载同步重复插入章节 +func loadExistingPlayletChapterKeys(ctx context.Context, playletId int64) (existingPlayletChapterKeys, error) { + keys := existingPlayletChapterKeys{ + mediaContentIds: make(map[uint]struct{}), + episodes: make(map[int64]struct{}), + hashes: make(map[string]struct{}), + videoUrls: make(map[string]struct{}), + } + list, serr := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: playletId}) + if serr != nil { + return keys, fmt.Errorf("query playlet chapters failed: %+v", serr) + } + for _, chapter := range list { + markExistingPlayletChapterKeys(chapter, keys) + } + return keys, nil +} + +// chapterDuplicateByKeys 章节跨批次去重,优先用媒资章节ID,其次用集数、hash、播放地址兜底 +func chapterDuplicateByKeys(chapter *playletchaptermod.PlayletChapter, keys existingPlayletChapterKeys) bool { + if chapter.MediaContentId > 0 { + if _, ok := keys.mediaContentIds[chapter.MediaContentId]; ok { + return true + } + } + if chapter.CurrentEpisode > 0 { + if _, ok := keys.episodes[chapter.CurrentEpisode]; ok { + return true + } + } + if chapter.Hash != "" { + if _, ok := keys.hashes[chapter.Hash]; ok { + return true + } + } + if chapter.VideoUrl != "" { + if _, ok := keys.videoUrls[chapter.VideoUrl]; ok { + return true + } + } + return false +} + +// markExistingPlayletChapterKeys 把已存在或本批次准备插入的章节登记到去重集合 +func markExistingPlayletChapterKeys(chapter *playletchaptermod.PlayletChapter, keys existingPlayletChapterKeys) { + if chapter == nil { + return + } + if chapter.MediaContentId > 0 { + keys.mediaContentIds[chapter.MediaContentId] = struct{}{} + } + if chapter.CurrentEpisode > 0 { + keys.episodes[chapter.CurrentEpisode] = struct{}{} + } + if chapter.Hash != "" { + keys.hashes[chapter.Hash] = struct{}{} + } + if chapter.VideoUrl != "" { + keys.videoUrls[chapter.VideoUrl] = struct{}{} + } +} + +// applyPlayletDefaultPayToChapters 上架且连载中时,新增章节默认沿用剧级支付设置。 +func applyPlayletDefaultPayToChapters(playlet *playletmod.Playlet, chapters playletchaptermod.PlayletChapterList) { + if playlet == nil || len(chapters) == 0 { + return + } + if playlet.Status != playletmod.StatusUp || playlet.UpdateStatus != 1 { + return + } + payType := playletchaptermod.PayType(playlet.PayType) + singleCoins := playlet.SingleCoins + if payType != playletchaptermod.PayTypeCoin { + singleCoins = 0 + } + for _, chapter := range chapters { + if chapter == nil { + continue + } + chapter.PayType = payType + chapter.SingleCoins = singleCoins + } +} + +// lockPlayletSerialSync 锁定单个媒资ID,避免同一部连载短剧被多个 worker 同时同步 +func lockPlayletSerialSync(mediaId uint) (bool, error) { + return redis.Setnx_NewOK(playletSerialSyncLockKey(mediaId), true, playletSerialSyncLockTTL) +} + +// unlockPlayletSerialSync 释放单个媒资ID的同步锁 +func unlockPlayletSerialSync(mediaId uint) { + if err := redis.Del(playletSerialSyncLockKey(mediaId)); err != nil { + log.Error("释放连载短剧同步锁失败", log.Any("mediaId", mediaId), log.E(err)) + } +} diff --git a/web/service/srv_mwacc/acc.go b/web/service/srv_mwacc/acc.go new file mode 100644 index 0000000..d472bef --- /dev/null +++ b/web/service/srv_mwacc/acc.go @@ -0,0 +1,74 @@ +package srv_mwacc + +import ( + "context" + "time" + "videoweb/pub/common/crypt" + "videoweb/pub/common/errors" + "videoweb/pub/models/mwacc" + + "go.mongodb.org/mongo-driver/bson" +) + +// // 删除角色 +// func AccDel(ctx context.Context, roleId int64) (ok bool, err error){ +// +// } + +// 更改 +func AccUp(ctx context.Context, upDoc *AccUpDoc) (ok bool, err error) { + + c, err := mwacc.AccCountByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Acc: upDoc.Acc, + mwacc.FN.MwAcc.Id: bson.M{ + "$ne": upDoc.Id, + }, + }) + + if c > 0 { + err = errors.MakeWithMsg(errors.DBInsert, "账户名已存在") + return false, err + } + + set := bson.M{} + + set = bson.M{ + mwacc.FN.MwAcc.UserName: upDoc.UserName, + mwacc.FN.MwAcc.Acc: upDoc.Acc, + mwacc.FN.MwAcc.Email: upDoc.Email, + mwacc.FN.MwAcc.RoleId: upDoc.RoleId, + mwacc.FN.MwAcc.IsEnb: upDoc.IsEnb, + mwacc.FN.MwAcc.UpdatedAt: time.Now(), + mwacc.FN.MwAcc.LimitIP: upDoc.LimitIP, + } + + err = mwacc.UpOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: upDoc.Id, + }, set) + if err != nil { + err = errors.MakeWithErr(errors.DBUpdate, err) + return false, err + } + return true, err +} + +// 更改账号密码 +func AccUpPwd(ctx context.Context, upDoc *AccUpPwdDoc) (ok bool, err error) { + + set := bson.M{} + + set = bson.M{ + mwacc.FN.MwAcc.Pwd: crypt.StrToMd5(upDoc.Pwd), + mwacc.FN.MwAcc.UpdatedAt: time.Now(), + } + + err = mwacc.UpOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: upDoc.Id, + }, set) + if err != nil { + err = errors.MakeWithErr(errors.DBUpdate, err) + return false, err + } + mwacc.DeleteOneToken(ctx, bson.M{mwacc.FN.MwAccToken.AccId: upDoc.Id}) + return true, err +} diff --git a/web/service/srv_mwacc/define.go b/web/service/srv_mwacc/define.go new file mode 100644 index 0000000..3a86440 --- /dev/null +++ b/web/service/srv_mwacc/define.go @@ -0,0 +1,32 @@ +package srv_mwacc + +type AccUpDoc struct { + // 账号Id + Id int64 `json:"id,omitempty"` + // 用户名 + UserName string `json:"user_name,omitempty"` + // 账号 + Acc string `json:"acc,omitempty"` + // // 密码 + // Pwd string `json:"pwd,omitempty"` + // 邮箱 + Email string `json:"email,omitempty"` + // 角色Id + RoleId int64 `json:"role_id,omitempty"` + // 使能 + IsEnb bool `json:"is_enb,omitempty"` + // 允许访问的IP + LimitIP []string `json:"limitIP,omitempty"` +} + +type ProductAccountInfo struct { + Id int64 `json:"id"` + Name string `json:"name"` +} + +type AccUpPwdDoc struct { + // 账号Id + Id int64 `json:"id,omitempty"` + // 密码 + Pwd string `json:"pwd,omitempty"` +} diff --git a/web/service/srv_mwacc/login.go b/web/service/srv_mwacc/login.go new file mode 100644 index 0000000..322c24a --- /dev/null +++ b/web/service/srv_mwacc/login.go @@ -0,0 +1,179 @@ +package srv_mwacc + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/crypt" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/google" + "videoweb/pub/models/mwacc" + "videoweb/web/webg" + + "github.com/skip2/go-qrcode" + "go.mongodb.org/mongo-driver/bson" +) + +func generateToken(userID int64) (string, error) { + return utils.GenerateUserToken(utils.UserClaims{UserID: userID, Expire: consts.AdminJwtTokenExpire}, webg.Conf.JwtKey) +} + +// AccSLogin 账号登录 +func AccSLogin(ctx context.Context, acc string, pwd string) (*mwacc.MwAcc, *srvdef.SrvErr) { + var data *mwacc.MwAcc + res, dataErr := mwacc.FindOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Acc: acc, + }) + + if dataErr != nil || res.Id == 0 { + return data, srvdef.New(srvdef.AccountIsNotExists, srvdef.AccountIsNotExists.Msg(), "") + } + + if !res.IsEnb { + return data, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "") + } + + // 通过验证,再次验证密码是否正确 + md5pwd := crypt.StrToMd5(pwd) + if res.Pwd != md5pwd { + return data, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "") + } + + // slogin 仅允许角色名含“客服”的账号登录 + if !isCustomerServiceRole(ctx, res) { + return data, srvdef.New(srvdef.ErrUserPermission, "仅客服角色可登录", "") + } + + getToken, err := generateToken(res.Id) + if err != nil { + return data, srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "") + } + _ = mwacc.UpOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: res.Id, + }, bson.M{ + mwacc.FN.MwAcc.LastTime: time.Now(), + }) + // 更新最近登录时间 + data = res + data.Token = getToken + + return data, nil +} + +// isCustomerServiceRole 判断账号角色名是否包含“客服” +func isCustomerServiceRole(ctx context.Context, acc *mwacc.MwAcc) bool { + if acc == nil { + return false + } + roleName := acc.RoleName + if roleName == "" && acc.RoleId > 0 { + role, serr := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: acc.RoleId}) + if serr == nil && role != nil { + roleName = role.Name + } + } + return strings.Contains(roleName, "客服") +} + +// 账号登录 +func AccLogin(ctx context.Context, acc string, pwd string, code string) (*mwacc.MwAcc, *srvdef.SrvErr) { + var data *mwacc.MwAcc + res, dataErr := mwacc.FindOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Acc: acc, + }) + + if dataErr != nil || res.Id == 0 { + return data, srvdef.New(srvdef.AccountIsNotExists, srvdef.AccountIsNotExists.Msg(), "") + } + + if !res.IsEnb { + return data, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "") + } + + // 通过验证,再次验证密码是否正确 + md5pwd := crypt.StrToMd5(pwd) + if res.Pwd != md5pwd { + return data, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "") + } + + if !res.IsBase32 { + // 谷歌绑定二维码 + googleUrl := google.NewGoogleAuth().GetQrcode(acc, res.Base32) + qrCodePNG, _ := qrcode.Encode(googleUrl, qrcode.Medium, 256) + + bq := &mwacc.MwAcc{} + bq.Qurl = qrCodePNG + data = bq + } else { + // hf屏蔽 + if webg.Conf.RunMod == "prod" && (res.RoleId != 46761 && res.RoleId != 29601) { + if code == "" { + return data, srvdef.New(srvdef.ErrCaptcha, srvdef.ErrCaptcha.Msg(), "") + } + result, Verr := google.NewGoogleAuth().VerifyCode(res.Base32, code) + if Verr != nil || !result { + return data, srvdef.New(srvdef.ErrCaptchaInvalid, srvdef.ErrCaptchaInvalid.Msg(), "") + } + } + // 通过验证,再次验证密码是否正确 + pwd := crypt.StrToMd5(pwd) + if res.Pwd != pwd { + return data, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "") + } + getToken, err := generateToken(res.Id) + if err != nil { + return data, srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "") + } + _ = mwacc.UpOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: res.Id, + }, bson.M{ + mwacc.FN.MwAcc.LastTime: time.Now(), + }) + // 更新最近登录时间 + data = res + data.Token = getToken + } + + return data, nil +} + +// 账号登录 +func ProductAccountList(ctx context.Context, accId int64) ([]ProductAccountInfo, *srvdef.SrvErr) { + resp := make([]ProductAccountInfo, 0) + accInfo, serr := mwacc.FindOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: accId, + }) + if serr != nil { + log.Error("ProductAccountList FindOneAccByFilter", log.Any("serr", serr)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + + if accInfo.RoleId == 1000 { + resp = append(resp, ProductAccountInfo{ + Id: accInfo.Id, + Name: accInfo.UserName, + }) + return resp, nil + } + + accList, lerr := mwacc.FindManyAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.RoleId: 1000, + }) + + if lerr != nil { + log.Error("ProductAccountList FindManyAccByFilter", log.Any("serr", serr)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + + for _, v := range accList { + resp = append(resp, ProductAccountInfo{ + Id: v.Id, + Name: v.UserName, + }) + } + + return resp, nil +} diff --git a/web/service/srv_mwacc/role.go b/web/service/srv_mwacc/role.go new file mode 100644 index 0000000..21ee448 --- /dev/null +++ b/web/service/srv_mwacc/role.go @@ -0,0 +1,221 @@ +package srv_mwacc + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/errors" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/mwacc" + + "go.mongodb.org/mongo-driver/bson" +) + +// 删除角色 +func RoleDel(ctx context.Context, roleId int64) (bool, *srvdef.SrvErr) { + + res, err := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: roleId}) + + if err != nil || res.Id == 0 { + log.ZapLog.Error(fmt.Sprintf("search error %v", err)) + return false, err + } + + // 查询记录是否有账户 + roles, err1 := mwacc.RoleAggregate(ctx, + []bson.M{ + {"$match": bson.M{mwacc.FN.MwRole.Id: roleId}}, + {"$limit": 1}, + {"$lookup": bson.M{ + "localField": mwacc.FN.MwRole.Id, + "from": mwacc.TN.MwAcc, + "foreignField": mwacc.FN.MwAcc.RoleId, + "as": "onlyRole", + }}, + {"$unwind": "$onlyRole"}, + {"$project": bson.M{ + mwacc.FN.MwRole.Id: 1, + "onlyRole.roleId": 1, + }}, + }) + + if err1 != nil { + log.Error("角色删除失败", log.Any("roleId", roleId)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + + if len(roles) > 0 { + log.Error("角色删除失败", log.Any("roleId", roleId)) + return false, srvdef.New(srvdef.ErrDbDeleteError, "删除失败,此角色有帐户", "") + } + + serr := mwacc.DelRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: roleId}) + if serr != nil { + return false, serr + } + return true, nil +} + +// 获取信息 +func RoleGetInfo(ctx context.Context, roleId int64) (*mwacc.MwRole, error) { + + rest, err := mwacc.FindOneRoleByFilter(ctx, bson.M{ + mwacc.FN.MwRole.Id: roleId, + }) + + if err != nil { + return nil, err + } + + return rest, nil +} + +// 角色id + +// 角色更新 +func RoleUp(ctx context.Context, id int64, name string, desc string, isEnb bool) (ok bool, err error) { + + roleCount, err := mwacc.RoleCountByFilter(ctx, bson.M{ + mwacc.FN.MwRole.Name: name, + mwacc.FN.MwRole.Id: bson.M{ + "$ne": id, + }, + }) + + if roleCount > 0 { + err = errors.MakeWithMsg(errors.DBInsert, "角色名称已存在") + return false, err + } + + err = mwacc.UpOneRoleByFilter(ctx, bson.M{ + mwacc.FN.MwRole.Id: id, + }, bson.M{ + "$set": bson.M{ + mwacc.FN.MwRole.Name: name, + mwacc.FN.MwRole.Desc: desc, + mwacc.FN.MwRole.IsEnb: isEnb, + mwacc.FN.MwRole.UpdatedAt: time.Now(), + }, + }) + + if err != nil { + log.ZapLog.Error(fmt.Sprintf("RoleUp error %v", err)) + err = errors.MakeWithErr(errors.DBUpdate, err) + return + } + + return true, nil +} + +// 设置角色状态 +func RoleEnable(ctx context.Context, roleId int64, IsEnb bool) (bool, *srvdef.SrvErr) { + + rest, err := mwacc.FindOneRoleByFilter(ctx, bson.M{ + mwacc.FN.MwRole.Id: roleId, + }) + + if err != nil || rest.Id == 0 { + return false, err + } + // count, err := mwacc.RoleAggregate(ctx, []bson.M{ + // {"$match": bson.M{ + // mwacc.FN.MwRole.Id: roleId, + // }}, + // {"$limit": 1}, + // {"$lookup": bson.M{ + // "localField": mwacc.FN.MwRole.Id, + // "from": mwacc.TN.MwAcc, + // "foreignField": mwacc.FN.MwAcc.RoleId, + // "as": "onlyRole", + // }}, + // {"$unwind": "$onlyRole"}, + // {"$project": bson.M{ + // "roleId": 1, + // "onlyRole.roleId": 1, + // }}, + // }) + data, err1 := mwacc.FindManyAccByFilter(ctx, bson.M{mwacc.FN.MwAcc.RoleId: roleId}) + if err1 != nil { + return false, err1 + } + + if len(data) > 0 && IsEnb == false { + return false, srvdef.New(srvdef.ErrDbUpdateError, "更新失败,此角色下拥有管理员", "") + } + + StatusErr := mwacc.UpOneRoleByFilter(ctx, bson.M{ + mwacc.FN.MwRole.Id: roleId, + }, bson.M{ + "$set": bson.M{ + mwacc.FN.MwRole.IsEnb: IsEnb, + }, + }) + + if StatusErr != nil { + return false, StatusErr + } + + return true, nil +} + +// //////////////////////////////////////////////////////////////////////////// +// 数据库Router转网站Router + +// // 角色权限列表 +// func RoleAuthList(ctx context.Context, roleId int64) (*mwacc.MwRole, error) { +// opt := &options.FindOneOptions{} +// opt.SetProjection(bson.M{ +// mwacc.FN.MwRole.Id: 1, +// mwacc.FN.MwRole.Router: 1, +// }) + +// res, err := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: roleId}) +// if err != nil { +// return &mwacc.MwRole{}, err +// } +// if res.Id != 0 { +// return res, nil +// } +// return nil, nil +// } + +func handleMenu(ctx context.Context, reqRouter []*mwacc.Router, apiMap map[string]int64) map[string]int64 { + + if apiMap == nil { + apiMap = make(map[string]int64) + } + + for _, v := range reqRouter { + if v.Children != nil && len(v.Children) != 0 { + childs := make([]*mwacc.Router, 0) + for _, v := range v.Children { + childs = append(childs, &v) + } + apiMap = handleMenu(ctx, childs, apiMap) + } + if v.ApiUrl != nil && len(v.ApiUrl) != 0 { + for _, v := range v.ApiUrl { + if v.Status == true { + for _, value := range v.Url { + apiMap[value] = 1 + } + } + } + } + } + return apiMap +} + +// 更新权限 +func UpAuth(ctx context.Context, id int64, dataRouter []*mwacc.Router) (ok bool, err error) { + + apiMap := handleMenu(ctx, dataRouter, nil) + + keys := make([]string, 0) + for k := range apiMap { + keys = append(keys, k) + } + + return mwacc.UpRoleAuth(ctx, id, keys, dataRouter) +} diff --git a/web/service/srv_playlet/category.go b/web/service/srv_playlet/category.go new file mode 100644 index 0000000..65b4833 --- /dev/null +++ b/web/service/srv_playlet/category.go @@ -0,0 +1,219 @@ +package srv_playlet + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/playletcategorymod" + "videoweb/pub/models/playletmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var playletCategoryWebProjection = bson.D{ + {Key: playletcategorymod.FN.PlayletCategory.Id, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Name, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Slug, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Icon, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Status, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Rank, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Remark, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.SeoInfo.Title, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.SeoInfo.Description, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.SeoInfo.Keyworld, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.CreatedAt, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.UpdatedAt, Value: 1}, +} + +// ListPlayletCategory 分页查询短剧分类列表 +func ListPlayletCategory(ctx context.Context, req *proto.PlayletCategoryListReq) (*proto.PlayletCategoryListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[playletcategorymod.FN.PlayletCategory.Id] = *req.Id + } + if req.Status != nil { + filter[playletcategorymod.FN.PlayletCategory.Status] = *req.Status + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[playletcategorymod.FN.PlayletCategory.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + totalCount, err := playletcategorymod.CountPlayletCategory(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletCategoryListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletCategoryListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := playletcategorymod.FindManyPlayletCategory(ctx, filter, options.Find(). + SetProjection(playletCategoryWebProjection). + SetSort(bson.D{ + {Key: playletcategorymod.FN.PlayletCategory.Rank, Value: -1}, + {Key: playletcategorymod.FN.PlayletCategory.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletCategoryListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.PlayletCategoryListResp{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Icon: item.Icon, + Status: item.Status, + Rank: item.Rank, + Remark: item.Remark, + SeoInfo: normalizeSeoValue(item.SeoInfo), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// AddPlayletCategory 新增短剧分类 +func AddPlayletCategory(ctx context.Context, req *proto.PlayletCategoryAddReq) (*proto.PlayletCategoryAddResp, *srvdef.SrvErr) { + if req == nil || strings.TrimSpace(req.Name) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "分类名称不能为空", "") + } + doc := &playletcategorymod.PlayletCategory{ + Name: strings.TrimSpace(req.Name), + Slug: req.Slug, + Icon: strings.TrimSpace(req.Icon), + Status: req.Status, + Rank: req.Rank, + Remark: strings.TrimSpace(req.Remark), + SeoInfo: normalizeSeoValue(req.SeoInfo), + } + exists := func(s string) bool { + item, _ := playletcategorymod.FindOnePlayletCategory(ctx, slugx.Filter( + playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.Id, s, 0)) + return item != nil + } + if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, doc.Name), exists); serr != nil { + return nil, serr + } + if err := playletcategorymod.InsertOnePlayletCategory(ctx, doc); err != nil { + return nil, err + } + clearAppPlayletHomeCache() + return &proto.PlayletCategoryAddResp{Id: doc.Id}, nil +} + +// UpdatePlayletCategory 更新短剧分类 +func UpdatePlayletCategory(ctx context.Context, req *proto.PlayletCategoryUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "分类ID无效", "") + } + current, err := playletcategorymod.FindOnePlayletCategory(ctx, bson.M{ + playletcategorymod.FN.PlayletCategory.Id: req.Id, + }) + if err != nil { + return err + } + setDoc := bson.M{playletcategorymod.FN.PlayletCategory.UpdatedAt: time.Now()} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "分类名称不能为空", "") + } + setDoc[playletcategorymod.FN.PlayletCategory.Name] = name + } + if req.Icon != nil { + setDoc[playletcategorymod.FN.PlayletCategory.Icon] = strings.TrimSpace(*req.Icon) + } + if req.Status != nil { + setDoc[playletcategorymod.FN.PlayletCategory.Status] = *req.Status + } + if req.Rank != nil { + setDoc[playletcategorymod.FN.PlayletCategory.Rank] = *req.Rank + } + if req.Remark != nil { + setDoc[playletcategorymod.FN.PlayletCategory.Remark] = strings.TrimSpace(*req.Remark) + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo) + } + name := current.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + keywords := current.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeSeoValue(*req.SeoInfo).Keyworld + } + exists := func(s string) bool { + item, _ := playletcategorymod.FindOnePlayletCategory(ctx, slugx.Filter( + playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.Id, s, req.Id)) + return item != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists) + if serr != nil { + return serr + } + if changed { + setDoc[playletcategorymod.FN.PlayletCategory.Slug] = slug + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + if err := playletcategorymod.UpdateOnePlayletCategory(ctx, + bson.M{playletcategorymod.FN.PlayletCategory.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + return nil +} + +// DelPlayletCategory 删除短剧分类,被短剧引用时禁止删除 +func DelPlayletCategory(ctx context.Context, id int64) *srvdef.SrvErr { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "分类ID无效", "") + } + usedCount, err := playletmod.CountPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.CategoryIds: bson.M{"$in": []int64{id}}, + }) + if err != nil { + return err + } + if usedCount > 0 { + return srvdef.New(srvdef.ErrParamError, "分类已被短剧引用,无法删除", "") + } + count, err := playletcategorymod.DelOnePlayletCategory(ctx, bson.M{playletcategorymod.FN.PlayletCategory.Id: id}) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + clearAppPlayletHomeCache() + return nil +} diff --git a/web/service/srv_playlet/helper.go b/web/service/srv_playlet/helper.go new file mode 100644 index 0000000..54edb55 --- /dev/null +++ b/web/service/srv_playlet/helper.go @@ -0,0 +1,49 @@ +package srv_playlet + +import ( + "strings" + "videoweb/pub/common/playletcache" + "videoweb/pub/pubdef" +) + +const ( + defaultPageSize int64 = 10 + maxPageSize int64 = 100 +) + +func normalizePage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// normalizeSeoValue 规范化 SEO 字段,避免 keywords 为 null +func normalizeSeoValue(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 == "" { + continue + } + words = append(words, word) + } + seo.Keyworld = words + return seo +} + +func clearAppPlayletHomeCache() { + playletcache.ClearHomeCache() +} diff --git a/web/service/srv_playlet/home_config.go b/web/service/srv_playlet/home_config.go new file mode 100644 index 0000000..7d292b6 --- /dev/null +++ b/web/service/srv_playlet/home_config.go @@ -0,0 +1,651 @@ +package srv_playlet + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/playlethomeconfigmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/videocategorymod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// ListPlayletHomeConfig 分页查询首页配置列表 +func ListPlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigListReq) (*proto.PlayletHomeConfigListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter, err := buildPlayletHomeConfigFilter(req) + if err != nil { + return nil, err + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + totalCount, err := playlethomeconfigmod.CountPlayletHomeConfig(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletHomeConfigListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletHomeConfigListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, filter, options.Find(). + SetSort(bson.D{ + {Key: playlethomeconfigmod.FN.PlayletHomeConfig.Rank, Value: -1}, + {Key: playlethomeconfigmod.FN.PlayletHomeConfig.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletHomeConfigListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, playletHomeConfigToResp(item)) + } + resp.List = items + return resp, nil +} + +// ListPlayletHomeTopicOptions 分页查询未被其他启用模块占用的题材或视频分类 +func ListPlayletHomeTopicOptions(ctx context.Context, req *proto.PlayletHomeTopicOptionsReq) (*proto.PlayletHomeTopicOptionsResp, *srvdef.SrvErr) { + if req == nil || (req.DataType != playlethomeconfigmod.DataTypeAll && req.DataType != playlethomeconfigmod.DataTypeVideo) { + return nil, srvdef.New(srvdef.ErrParamError, "数据类型仅支持1短剧题材、2视频分类", "") + } + if req.ConfigId < 0 { + return nil, srvdef.New(srvdef.ErrParamError, "配置ID无效", "") + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + resp := &proto.PlayletHomeTopicOptionsResp{ + List: make([]*proto.PlayletHomeTopicOption, 0), + SelectedList: make([]*proto.PlayletHomeTopicOption, 0), + PageNum: pageNum, + PageSize: pageSize, + } + var selectedIDs []int64 + if req.ConfigId > 0 { + current, err := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Id: req.ConfigId}) + if err != nil { + return nil, err + } + if current.ModuleType != playlethomeconfigmod.ModuleTypeTopic || current.DataType != req.DataType { + return nil, srvdef.New(srvdef.ErrParamError, "当前配置不是指定数据类型的专题模块", "") + } + selectedIDs = current.TopicIds + } + // 已启用的其他专题模块占用的ID不再出现在可选列表中 + configFilter := bson.M{ + playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true, + playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType: playlethomeconfigmod.ModuleTypeTopic, + playlethomeconfigmod.FN.PlayletHomeConfig.DataType: req.DataType, + } + if req.ConfigId > 0 { + configFilter[playlethomeconfigmod.FN.PlayletHomeConfig.Id] = bson.M{"$ne": req.ConfigId} + } + configs, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, configFilter, + options.Find().SetProjection(bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.TopicIds: 1})) + if err != nil { + return nil, err + } + occupied := make(map[int64]struct{}) + for _, config := range configs { + if config == nil { + continue + } + for _, id := range config.TopicIds { + occupied[id] = struct{}{} + } + } + skip := (pageNum - 1) * pageSize + if req.DataType == playlethomeconfigmod.DataTypeVideo { + filter := bson.M{videocategorymod.FN.VideoCategory.Status: true} + if len(occupied) > 0 { + ids := make([]int64, 0, len(occupied)) + for id := range occupied { + ids = append(ids, id) + } + filter[videocategorymod.FN.VideoCategory.Id] = bson.M{"$nin": ids} + } + resp.TotalCount, err = videocategorymod.CountVideoCategorys(ctx, filter) + if err != nil { + return nil, err + } + list, err := videocategorymod.FindManyVideoCategorys(ctx, filter, options.Find(). + SetProjection(bson.M{videocategorymod.FN.VideoCategory.Id: 1, videocategorymod.FN.VideoCategory.Name: 1, videocategorymod.FN.VideoCategory.Status: 1}). + SetSort(bson.D{{Key: videocategorymod.FN.VideoCategory.Rank, Value: -1}, {Key: videocategorymod.FN.VideoCategory.Id, Value: -1}}). + SetSkip(skip).SetLimit(pageSize)) + if err != nil { + return nil, err + } + for _, item := range list { + if item != nil { + resp.List = append(resp.List, &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status}) + } + } + if len(selectedIDs) > 0 { + selected, err := videocategorymod.FindManyVideoCategorys(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: bson.M{"$in": selectedIDs}}, + options.Find().SetProjection(bson.M{videocategorymod.FN.VideoCategory.Id: 1, videocategorymod.FN.VideoCategory.Name: 1, videocategorymod.FN.VideoCategory.Status: 1})) + if err != nil { + return nil, err + } + byID := make(map[int64]*proto.PlayletHomeTopicOption, len(selected)) + for _, item := range selected { + if item != nil { + byID[item.Id] = &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status} + } + } + for _, id := range selectedIDs { + if item := byID[id]; item != nil { + resp.SelectedList = append(resp.SelectedList, item) + } else { + resp.SelectedList = append(resp.SelectedList, &proto.PlayletHomeTopicOption{Id: id}) + } + } + } + return resp, nil + } + filter := bson.M{playletthememod.FN.PlayletTheme.Status: true} + if len(occupied) > 0 { + ids := make([]int64, 0, len(occupied)) + for id := range occupied { + ids = append(ids, id) + } + filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$nin": ids} + } + resp.TotalCount, err = playletthememod.CountPlayletTheme(ctx, filter) + if err != nil { + return nil, err + } + list, err := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find(). + SetProjection(bson.M{playletthememod.FN.PlayletTheme.Id: 1, playletthememod.FN.PlayletTheme.Name: 1, playletthememod.FN.PlayletTheme.Status: 1}). + SetSort(bson.D{{Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}}). + SetSkip(skip).SetLimit(pageSize)) + if err != nil { + return nil, err + } + for _, item := range list { + if item != nil { + resp.List = append(resp.List, &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status}) + } + } + if len(selectedIDs) > 0 { + selected, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": selectedIDs}}, + options.Find().SetProjection(bson.M{playletthememod.FN.PlayletTheme.Id: 1, playletthememod.FN.PlayletTheme.Name: 1, playletthememod.FN.PlayletTheme.Status: 1})) + if err != nil { + return nil, err + } + byID := make(map[int64]*proto.PlayletHomeTopicOption, len(selected)) + for _, item := range selected { + if item != nil { + byID[item.Id] = &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status} + } + } + for _, id := range selectedIDs { + if item := byID[id]; item != nil { + resp.SelectedList = append(resp.SelectedList, item) + } else { + resp.SelectedList = append(resp.SelectedList, &proto.PlayletHomeTopicOption{Id: id}) + } + } + } + return resp, nil +} + +// AddPlayletHomeConfig 新增首页配置 +func AddPlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigAddReq) (*proto.PlayletHomeConfigAddResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + title, style, moduleType, dataType, recommendType, limit, err := normalizePlayletHomeConfig(req.Title, req.Style, req.ModuleType, req.DataType, req.RecommendType, req.Limit) + if err != nil { + return nil, err + } + doc := &playlethomeconfigmod.PlayletHomeConfig{ + Title: title, + Slug: req.Slug, + Desc: strings.TrimSpace(req.Desc), + Label: strings.TrimSpace(req.Label), + Style: style, + CardBackgroundImage: strings.TrimSpace(req.CardBackgroundImage), + ModuleType: moduleType, + DataType: dataType, + RecommendType: recommendType, + CategoryId: req.CategoryId, + TopicIds: req.TopicIds, + Rank: req.Rank, + Enabled: req.Enabled, + Limit: limit, + } + if err := validateHomeConfigTopics(ctx, doc); err != nil { + return nil, err + } + exists := func(s string) bool { + item, _ := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, slugx.Filter( + playlethomeconfigmod.FN.PlayletHomeConfig.Slug, playlethomeconfigmod.FN.PlayletHomeConfig.Id, s, 0)) + return item != nil + } + if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, title), exists); serr != nil { + return nil, serr + } + if err := playlethomeconfigmod.InsertOnePlayletHomeConfig(ctx, doc); err != nil { + return nil, err + } + clearAppPlayletHomeCache() + return &proto.PlayletHomeConfigAddResp{Id: doc.Id}, nil +} + +// UpdatePlayletHomeConfig 更新首页配置 +func UpdatePlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "id无效", "") + } + setDoc := bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.UpdatedAt: time.Now()} + current, err := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Id: req.Id}) + if err != nil { + return err + } + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + return srvdef.New(srvdef.ErrParamError, "模块标题不能为空", "") + } + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Title] = title + } + if req.Desc != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Desc] = strings.TrimSpace(*req.Desc) + } + if req.Label != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Label] = strings.TrimSpace(*req.Label) + } + if req.Style != nil { + style, err := normalizePlayletHomeConfigStyle(*req.Style) + if err != nil { + return err + } + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Style] = style + } + if req.CardBackgroundImage != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.CardBackgroundImage] = strings.TrimSpace(*req.CardBackgroundImage) + } + if req.ModuleType != nil { + moduleType, err := normalizePlayletHomeModuleType(*req.ModuleType) + if err != nil { + return err + } + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType] = moduleType + } + if req.DataType != nil { + dataType, err := normalizePlayletHomeDataType(*req.DataType) + if err != nil { + return err + } + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.DataType] = dataType + } + if req.RecommendType != nil { + if err := validatePlayletHomeRecommendType(*req.RecommendType); err != nil { + return err + } + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.RecommendType] = *req.RecommendType + } + if req.CategoryId != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.CategoryId] = *req.CategoryId + } + if req.TopicIds != nil { + current.TopicIds = *req.TopicIds + } + if req.ModuleType != nil { + current.ModuleType = *req.ModuleType + } + if req.DataType != nil { + current.DataType = *req.DataType + } + if req.Enabled != nil { + current.Enabled = *req.Enabled + } + if req.TopicIds != nil || req.ModuleType != nil || req.DataType != nil || req.Enabled != nil { + if err := validateHomeConfigTopics(ctx, current); err != nil { + return err + } + if req.TopicIds != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.TopicIds] = current.TopicIds + } + } + if req.Rank != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Rank] = *req.Rank + } + if req.Enabled != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Enabled] = *req.Enabled + } + if req.Limit != nil { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Limit] = normalizePlayletHomeConfigLimit(*req.Limit) + } + title := current.Title + if req.Title != nil { + title = strings.TrimSpace(*req.Title) + } + exists := func(s string) bool { + item, _ := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, slugx.Filter( + playlethomeconfigmod.FN.PlayletHomeConfig.Slug, playlethomeconfigmod.FN.PlayletHomeConfig.Id, s, req.Id)) + return item != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug, + utils.SlugSourceFromKeywordsOrTitle(nil, title), exists) + if serr != nil { + return serr + } + if changed { + setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Slug] = slug + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + if err := playlethomeconfigmod.UpdateOnePlayletHomeConfig(ctx, + bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + return nil +} + +// DelPlayletHomeConfig 删除首页配置 +func DelPlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigDelReq) *srvdef.SrvErr { + if req == nil || len(req.Ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "配置ID不能为空", "") + } + ids := make([]int64, 0, len(req.Ids)) + seen := make(map[int64]struct{}, len(req.Ids)) + for _, id := range req.Ids { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "配置ID无效", "") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + count, err := playlethomeconfigmod.DelManyPlayletHomeConfig(ctx, bson.M{ + playlethomeconfigmod.FN.PlayletHomeConfig.Id: bson.M{"$in": ids}, + }) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + clearAppPlayletHomeCache() + return nil +} + +func buildPlayletHomeConfigFilter(req *proto.PlayletHomeConfigListReq) (bson.M, *srvdef.SrvErr) { + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Id] = *req.Id + } + if slug := strings.TrimSpace(req.Slug); slug != "" { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Slug] = slug + } + if title := strings.TrimSpace(req.Title); title != "" { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} + } + if desc := strings.TrimSpace(req.Desc); desc != "" { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Desc] = bson.M{"$regex": regexp.QuoteMeta(desc)} + } + if label := strings.TrimSpace(req.Label); label != "" { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Label] = bson.M{"$regex": regexp.QuoteMeta(label)} + } + if style := strings.TrimSpace(req.Style); style != "" { + normalizedStyle, err := normalizePlayletHomeConfigStyle(style) + if err != nil { + return nil, err + } + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Style] = normalizedStyle + } + if req.ModuleType != nil { + moduleType, err := normalizePlayletHomeModuleType(*req.ModuleType) + if err != nil { + return nil, err + } + if moduleType == playlethomeconfigmod.ModuleTypePlaylet { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType] = bson.M{"$in": []interface{}{nil, int64(0), playlethomeconfigmod.ModuleTypePlaylet}} + } else { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType] = moduleType + } + } + if req.DataType != nil { + dataType, err := normalizePlayletHomeDataType(*req.DataType) + if err != nil { + return nil, err + } + if dataType == playlethomeconfigmod.DataTypeAll { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.DataType] = bson.M{"$in": []interface{}{nil, int64(0), playlethomeconfigmod.DataTypeAll}} + } else { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.DataType] = dataType + } + } + if req.RecommendType != nil { + if err := validatePlayletHomeRecommendType(*req.RecommendType); err != nil { + return nil, err + } + filter[playlethomeconfigmod.FN.PlayletHomeConfig.RecommendType] = *req.RecommendType + } + if req.CategoryId != nil && *req.CategoryId > 0 { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.CategoryId] = *req.CategoryId + } + if req.Enabled != nil { + filter[playlethomeconfigmod.FN.PlayletHomeConfig.Enabled] = *req.Enabled + } + return filter, nil +} + +func playletHomeConfigToResp(item *playlethomeconfigmod.PlayletHomeConfig) *proto.PlayletHomeConfigListResp { + if item == nil { + return nil + } + return &proto.PlayletHomeConfigListResp{ + Id: item.Id, + Slug: item.Slug, + Title: item.Title, + Desc: item.Desc, + Label: item.Label, + Style: item.Style, + CardBackgroundImage: item.CardBackgroundImage, + ModuleType: normalizePlayletHomeModuleTypeForResp(item.ModuleType), + DataType: normalizePlayletHomeDataTypeForResp(item.DataType), + RecommendType: item.RecommendType, + CategoryId: item.CategoryId, + TopicIds: item.TopicIds, + Rank: item.Rank, + Enabled: item.Enabled, + Limit: item.Limit, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// validateHomeConfigTopics 校验专题归属和已启用模块间的专题唯一性 +func validateHomeConfigTopics(ctx context.Context, doc *playlethomeconfigmod.PlayletHomeConfig) *srvdef.SrvErr { + if doc.ModuleType != playlethomeconfigmod.ModuleTypeTopic { + if len(doc.TopicIds) > 0 { + return srvdef.New(srvdef.ErrParamError, "列表模块不能配置专题ID", "") + } + return nil + } + seen := make(map[int64]struct{}, len(doc.TopicIds)) + ids := make([]int64, 0, len(doc.TopicIds)) + for _, id := range doc.TopicIds { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "专题ID必须大于0", "") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + doc.TopicIds = ids + if !doc.Enabled { + return nil + } + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "启用的专题模块必须配置专题ID", "") + } + if doc.DataType == playlethomeconfigmod.DataTypeVideo { + categories, err := videocategorymod.FindManyVideoCategorys(ctx, bson.M{ + videocategorymod.FN.VideoCategory.Id: bson.M{"$in": ids}, + videocategorymod.FN.VideoCategory.Status: true, + }) + if err != nil { + return err + } + if len(categories) != len(ids) { + return srvdef.New(srvdef.ErrParamError, "视频分类不存在或未上架", "") + } + } else { + themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids}, + playletthememod.FN.PlayletTheme.Status: true, + }) + if err != nil { + return err + } + if len(themes) != len(ids) { + return srvdef.New(srvdef.ErrParamError, "短剧题材不存在或未上架", "") + } + } + configs, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, bson.M{ + playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true, + playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType: playlethomeconfigmod.ModuleTypeTopic, + playlethomeconfigmod.FN.PlayletHomeConfig.DataType: doc.DataType, + playlethomeconfigmod.FN.PlayletHomeConfig.TopicIds: bson.M{"$in": ids}, + }) + if err != nil { + return err + } + for _, other := range configs { + if other != nil && other.Id != doc.Id { + return srvdef.New(srvdef.ErrParamError, "专题已分配给其他启用的首页模块", "") + } + } + return nil +} + +func normalizePlayletHomeConfig(title string, style string, moduleType int64, dataType int64, recommendType int64, limit int64) (string, string, int64, int64, int64, int64, *srvdef.SrvErr) { + title = strings.TrimSpace(title) + if title == "" { + return "", "", 0, 0, 0, 0, srvdef.New(srvdef.ErrParamError, "模块标题不能为空", "") + } + normalizedStyle, err := normalizePlayletHomeConfigStyle(style) + if err != nil { + return "", "", 0, 0, 0, 0, err + } + normalizedModuleType, err := normalizePlayletHomeModuleType(moduleType) + if err != nil { + return "", "", 0, 0, 0, 0, err + } + normalizedDataType, err := normalizePlayletHomeDataType(dataType) + if err != nil { + return "", "", 0, 0, 0, 0, err + } + if err := validatePlayletHomeRecommendType(recommendType); err != nil { + return "", "", 0, 0, 0, 0, err + } + return title, normalizedStyle, normalizedModuleType, normalizedDataType, recommendType, normalizePlayletHomeConfigLimit(limit), nil +} + +func normalizePlayletHomeConfigStyle(style string) (string, *srvdef.SrvErr) { + style = strings.ToUpper(strings.TrimSpace(style)) + switch style { + case playlethomeconfigmod.StyleA, + playlethomeconfigmod.StyleB, + playlethomeconfigmod.StyleC, + playlethomeconfigmod.StyleD, + playlethomeconfigmod.StyleE, + playlethomeconfigmod.StyleF, + playlethomeconfigmod.StyleG, + playlethomeconfigmod.StyleH: + return style, nil + default: + return "", srvdef.New(srvdef.ErrParamError, "样式无效,仅支持A-H", "") + } +} + +func validatePlayletHomeRecommendType(recommendType int64) *srvdef.SrvErr { + switch recommendType { + case playlethomeconfigmod.RecommendTypeUpdated, + playlethomeconfigmod.RecommendTypeLike, + playlethomeconfigmod.RecommendTypeBrowse, + playlethomeconfigmod.RecommendTypeHeat, + playlethomeconfigmod.RecommendTypeCategory: + return nil + default: + return srvdef.New(srvdef.ErrParamError, "推荐类型无效,仅支持1按更新、2按点赞、3按观看、4按热度、5按分类", "") + } +} + +func normalizePlayletHomeModuleType(moduleType int64) (int64, *srvdef.SrvErr) { + if moduleType == 0 { + return playlethomeconfigmod.ModuleTypePlaylet, nil + } + switch moduleType { + case playlethomeconfigmod.ModuleTypePlaylet, playlethomeconfigmod.ModuleTypeTopic: + return moduleType, nil + default: + return 0, srvdef.New(srvdef.ErrParamError, "模块类型无效,仅支持1短剧列表、2专题", "") + } +} + +func normalizePlayletHomeModuleTypeForResp(moduleType int64) int64 { + if moduleType == playlethomeconfigmod.ModuleTypeTopic { + return playlethomeconfigmod.ModuleTypeTopic + } + return playlethomeconfigmod.ModuleTypePlaylet +} + +func normalizePlayletHomeDataType(dataType int64) (int64, *srvdef.SrvErr) { + if dataType == 0 { + return playlethomeconfigmod.DataTypeAll, nil + } + switch dataType { + case playlethomeconfigmod.DataTypeAll, playlethomeconfigmod.DataTypeVideo: + return dataType, nil + default: + return 0, srvdef.New(srvdef.ErrParamError, "数据类型无效,仅支持1全部上架短剧、2全部上架视频", "") + } +} + +func normalizePlayletHomeDataTypeForResp(dataType int64) int64 { + if dataType == playlethomeconfigmod.DataTypeVideo { + return playlethomeconfigmod.DataTypeVideo + } + return playlethomeconfigmod.DataTypeAll +} + +func normalizePlayletHomeConfigLimit(limit int64) int64 { + if limit <= 0 { + return playlethomeconfigmod.DefaultLimit + } + if limit > playlethomeconfigmod.MaxLimit { + return playlethomeconfigmod.MaxLimit + } + return limit +} diff --git a/web/service/srv_playlet/playlet.go b/web/service/srv_playlet/playlet.go new file mode 100644 index 0000000..7b5df0b --- /dev/null +++ b/web/service/srv_playlet/playlet.go @@ -0,0 +1,1813 @@ +package srv_playlet + +import ( + "context" + "math/rand" + "regexp" + "strings" + "time" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/response" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models" + "videoweb/pub/models/commentlikemod" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/playletcategorymod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletfavoritemod" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + "videoweb/web/api/proto" + "videoweb/web/service/srv_contenttag" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/service/srv_search" + "videoweb/web/service/srv_seopush" + "videoweb/web/skd/playletwatermark" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var playletWebProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + {Key: playletmod.FN.Playlet.IsFeatured, Value: 1}, + {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.IsRecommend, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1}, + {Key: playletmod.FN.Playlet.Banner, Value: 1}, + {Key: playletmod.FN.Playlet.ReviewAt, Value: 1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.Slug, Value: 1}, + {Key: playletmod.FN.Playlet.Desc, Value: 1}, + {Key: playletmod.FN.Playlet.Anchor, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.Preview, Value: 1}, + {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, + {Key: playletmod.FN.Playlet.CategoryBinds, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.TagIds, Value: 1}, + {Key: playletmod.FN.Playlet.ContentType, Value: 1}, + {Key: playletmod.FN.Playlet.PayType, Value: 1}, + {Key: playletmod.FN.Playlet.SingleCoins, Value: 1}, + {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, + {Key: playletmod.FN.Playlet.FreeEpisodeCount, Value: 1}, + {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, + {Key: playletmod.FN.Playlet.TopRank, Value: 1}, + {Key: playletmod.FN.Playlet.RecommendRank, Value: 1}, + {Key: playletmod.FN.Playlet.CountComment, Value: 1}, + {Key: playletmod.FN.Playlet.CountBuy, Value: 1}, + {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualHeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, + {Key: playletmod.FN.Playlet.CountLike, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, + {Key: playletmod.FN.Playlet.CountDisLike, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Title, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Description, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Keyworld, Value: 1}, + {Key: playletmod.FN.Playlet.OperatorId, Value: 1}, + {Key: playletmod.FN.Playlet.OperatorName, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, +} + +// ListPlaylet 分页查询短剧列表 +func ListPlaylet(ctx context.Context, req *proto.PlayletListReq) (*proto.PlayletListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := buildPlayletListFilter(req) + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + var list playletmod.PlayletList + if req.CategoryId != nil && *req.CategoryId > 0 { + list, err = playletmod.FindManyPlayletByCategoryRank(ctx, filter, *req.CategoryId, (pageNum-1)*pageSize, pageSize, playletmod.CategoryRankAdminSort(), playletWebProjection) + } else { + list, err = playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetProjection(playletWebProjection). + SetSort(bson.D{ + {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + } + if err != nil { + return nil, err + } + categoryNameMap, err := buildCategoryNameMap(ctx, list) + if err != nil { + return nil, err + } + themeNameMap, err := buildThemeNameMap(ctx, list) + if err != nil { + return nil, err + } + tagNameMap, err := buildTagNameMap(ctx, list) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + webItem := playletToWebResp(item, categoryNameMap, themeNameMap, tagNameMap) + if req.CategoryId != nil { + webItem.CategoryRank = playletmod.CategoryRankOf(playletmod.EffectiveCategoryBinds(item), *req.CategoryId) + } + items = append(items, webItem) + } + resp.List = items + return resp, nil +} + +// AddPlaylet 新增短剧 +func AddPlaylet(ctx context.Context, req *proto.PlayletAddReq, operatorId int64) (*proto.PlayletAddResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, srvdef.New(srvdef.ErrParamError, "短剧标题不能为空", "") + } + categoryBinds, err := resolveAddCategoryBinds(req.CategoryBinds, req.CategoryIds) + if err != nil { + return nil, err + } + categoryIds := playletmod.CategoryIdsFromBinds(categoryBinds) + if err := validatePlayletCategoryIds(ctx, categoryIds); err != nil { + return nil, err + } + themeIds := dedupePositiveInt64Ids(req.ThemeIds) + if len(themeIds) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") + } + if err := validatePlayletThemeIds(ctx, themeIds); err != nil { + return nil, err + } + tagIds, err := srv_contenttag.NormalizeContentTagIds(req.TagIds) + if err != nil { + return nil, err + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypePlaylet); err != nil { + return nil, err + } + if !isValidPlayletStatus(req.Status) { + return nil, srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") + } + if !isValidPlayletContentType(req.ContentType) { + return nil, srvdef.New(srvdef.ErrParamError, "内容类型无效", "") + } + payType, singleCoins, err := normalizePlayletPaySetting(req.PayType, &req.SingleCoins, true) + if err != nil { + return nil, err + } + freeEpisodeCount := int64(2) + if req.FreeEpisodeCount != nil { + if !validOfficialFreeEpisodeCount(*req.FreeEpisodeCount) { + return nil, srvdef.New(srvdef.ErrParamError, "官网开放集数无效", "") + } + freeEpisodeCount = *req.FreeEpisodeCount + } + banner := strings.TrimSpace(req.Banner) + if req.ShowOnBanner && banner == "" { + return nil, srvdef.New(srvdef.ErrParamError, "显示首页banner时banner图不能为空", "") + } + now := time.Now() + operatorId, operatorName := resolvePlayletOperator(ctx, operatorId) + doc := &playletmod.Playlet{ + Status: req.Status, + IsFeatured: req.IsFeatured, + IsOriginal: true, // 管理后台新增的短剧都是原创 + ShowOriginal: req.ShowOriginal, + IsRecommend: req.IsRecommend, + ShowOnBanner: req.ShowOnBanner, + Banner: banner, + ReviewAt: now, + OnlineAt: playletOnlineAt(req.Status, now), + CategoryIds: categoryIds, + CategoryBinds: categoryBinds, + ThemeIds: themeIds, + Title: title, + Slug: req.Slug, + Desc: strings.TrimSpace(req.Desc), + Anchor: strings.TrimSpace(req.Anchor), + Cover: strings.TrimSpace(req.Cover), + Preview: strings.TrimSpace(req.Preview), + TagIds: tagIds, + ContentType: req.ContentType, + PayType: payType, + SingleCoins: singleCoins, + TotalEpisode: req.TotalEpisode, + FreeEpisodeCount: freeEpisodeCount, + TopRank: req.TopRank, + RecommendRank: req.RecommendRank, + SeoInfo: normalizeSeoValue(req.SeoInfo), + OperatorId: operatorId, + OperatorName: operatorName, + } + exists := func(s string) bool { + item, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, 0)) + return item != nil + } + if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, title), exists); serr != nil { + return nil, serr + } + if err := playletmod.InsertOnePlaylet(ctx, doc); err != nil { + return nil, err + } + if err := srv_contenttag.SyncContentTagBindCount(ctx, nil, tagIds, contenttagmod.ContentTagTypePlaylet); err != nil { + return nil, err + } + go srv_mediaresource.FillPlayletCompressedCover(context.Background(), doc) + clearAppPlayletHomeCache() + if req.ShowOriginal { + playletcache.ClearRankCache() + } + srv_seopush.NotifyPlayletAfterWrite(ctx, doc.Id, nil, doc.Status, doc.OnlineAt) + if doc.Status == playletmod.StatusUp { + asyncSyncPlayletESByStatus([]int64{doc.Id}, doc.Status, "短剧新增") + } + return &proto.PlayletAddResp{Id: doc.Id}, nil +} + +// UpdatePlaylet 更新短剧 +func UpdatePlaylet(ctx context.Context, req *proto.PlayletUpdateReq, operatorId int64) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID无效", "") + } + current, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: req.Id, + }) + if err != nil { + return err + } + now := time.Now() + setDoc := bson.M{playletmod.FN.Playlet.UpdatedAt: now} + if req.Status != nil { + if !isValidPlayletStatus(*req.Status) { + return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") + } + setDoc[playletmod.FN.Playlet.Status] = *req.Status + setDoc[playletmod.FN.Playlet.ReviewAt] = now + if *req.Status == playletmod.StatusUp { + setDoc[playletmod.FN.Playlet.OnlineAt] = now + setDoc[playletmod.FN.Playlet.IndexNow] = int64(0) + } + } + if req.FreeEpisodeCount != nil { + if !validOfficialFreeEpisodeCount(*req.FreeEpisodeCount) { + return srvdef.New(srvdef.ErrParamError, "官网开放集数无效", "") + } + setDoc[playletmod.FN.Playlet.FreeEpisodeCount] = *req.FreeEpisodeCount + } + if req.IsFeatured != nil { + setDoc[playletmod.FN.Playlet.IsFeatured] = *req.IsFeatured + } + if req.ShowOriginal != nil { + setDoc[playletmod.FN.Playlet.ShowOriginal] = *req.ShowOriginal + } + if req.IsRecommend != nil { + setDoc[playletmod.FN.Playlet.IsRecommend] = *req.IsRecommend + } + if req.ShowOnBanner != nil { + setDoc[playletmod.FN.Playlet.ShowOnBanner] = *req.ShowOnBanner + } + if req.Banner != nil { + setDoc[playletmod.FN.Playlet.Banner] = strings.TrimSpace(*req.Banner) + } + if req.CategoryBinds != nil || req.CategoryIds != nil { + categoryBinds, err := resolveCategoryBinds(req.CategoryBinds, req.CategoryIds, playletmod.EffectiveCategoryBinds(current)) + if err != nil { + return err + } + categoryIds := playletmod.CategoryIdsFromBinds(categoryBinds) + if err := validatePlayletCategoryIds(ctx, categoryIds); err != nil { + return err + } + setDoc[playletmod.FN.Playlet.CategoryIds] = categoryIds + setDoc[playletmod.FN.Playlet.CategoryBinds] = categoryBinds + } + if req.ThemeIds != nil { + themeIds := dedupePositiveInt64Ids(*req.ThemeIds) + if len(themeIds) == 0 { + return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") + } + if err := validatePlayletThemeIds(ctx, themeIds); err != nil { + return err + } + setDoc[playletmod.FN.Playlet.ThemeIds] = themeIds + } + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + return srvdef.New(srvdef.ErrParamError, "短剧标题不能为空", "") + } + setDoc[playletmod.FN.Playlet.Title] = title + } + if req.Desc != nil { + setDoc[playletmod.FN.Playlet.Desc] = strings.TrimSpace(*req.Desc) + } + if req.Anchor != nil { + setDoc[playletmod.FN.Playlet.Anchor] = strings.TrimSpace(*req.Anchor) + } + coverChanged := false + if req.Cover != nil { + newCover := strings.TrimSpace(*req.Cover) + setDoc[playletmod.FN.Playlet.Cover] = newCover + if strings.TrimSpace(current.Cover) != newCover { + setDoc[playletmod.FN.Playlet.CompressedCover] = "" + coverChanged = true + } + } + if req.Preview != nil { + setDoc[playletmod.FN.Playlet.Preview] = strings.TrimSpace(*req.Preview) + } + var oldTagIds []int64 + var newTagIds []int64 + if req.TagIds != nil { + tagIds, err := srv_contenttag.NormalizeContentTagIds(*req.TagIds) + if err != nil { + return err + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypePlaylet); err != nil { + return err + } + oldTagIds = append([]int64{}, current.TagIds...) + newTagIds = tagIds + setDoc[playletmod.FN.Playlet.TagIds] = tagIds + } + if req.ContentType != nil { + if !isValidPlayletContentType(*req.ContentType) { + return srvdef.New(srvdef.ErrParamError, "内容类型无效", "") + } + setDoc[playletmod.FN.Playlet.ContentType] = *req.ContentType + } + if req.PayType != nil { + payType, singleCoins, err := normalizePlayletPaySetting(*req.PayType, req.SingleCoins, true) + if err != nil { + return err + } + setDoc[playletmod.FN.Playlet.PayType] = payType + setDoc[playletmod.FN.Playlet.SingleCoins] = singleCoins + } else if req.SingleCoins != nil { + if *req.SingleCoins < 0 { + return srvdef.New(srvdef.ErrParamError, "单集金币不能小于0", "") + } + setDoc[playletmod.FN.Playlet.SingleCoins] = *req.SingleCoins + } + if req.TotalEpisode != nil { + setDoc[playletmod.FN.Playlet.TotalEpisode] = *req.TotalEpisode + } + if req.TopRank != nil { + setDoc[playletmod.FN.Playlet.TopRank] = *req.TopRank + } + if req.RecommendRank != nil { + setDoc[playletmod.FN.Playlet.RecommendRank] = *req.RecommendRank + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo) + } + title := current.Title + if req.Title != nil { + title = strings.TrimSpace(*req.Title) + } + keywords := current.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeSeoValue(*req.SeoInfo).Keyworld + } + exists := func(s string) bool { + item, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, req.Id)) + return item != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, title), exists) + if serr != nil { + return serr + } + if changed { + setDoc[playletmod.FN.Playlet.Slug] = slug + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + operatorId, operatorName := resolvePlayletOperator(ctx, operatorId) + setDoc[playletmod.FN.Playlet.OperatorId] = operatorId + setDoc[playletmod.FN.Playlet.OperatorName] = operatorName + if err := playletmod.UpdateOnePlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + if req.TagIds != nil { + if err := srv_contenttag.SyncContentTagBindCount(ctx, oldTagIds, newTagIds, contenttagmod.ContentTagTypePlaylet); err != nil { + return err + } + } + if coverChanged { + go func(playletId int64, cover string) { + srv_mediaresource.FillPlayletCompressedCover(context.Background(), &playletmod.Playlet{ + Id: playletId, + Cover: cover, + }) + }(req.Id, strings.TrimSpace(*req.Cover)) + } + clearAppPlayletHomeCache() + if req.ShowOriginal != nil { + playletcache.ClearRankCache() + } + status := current.Status + onlineAt := current.OnlineAt + if req.Status != nil { + status = *req.Status + if status == playletmod.StatusUp { + onlineAt = now + } + } + prevStatus := current.Status + srv_seopush.NotifyPlayletAfterWrite(ctx, req.Id, &prevStatus, status, onlineAt) + asyncSyncPlayletESByLatestStatus([]int64{req.Id}, "短剧更新") + return nil +} + +// DelPlaylet 删除短剧、剧集及关联的用户数据 +func DelPlaylet(ctx context.Context, req *proto.PlayletDelReq) *srvdef.SrvErr { + if req == nil || len(req.Ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + for _, id := range req.Ids { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID无效", "") + } + } + ids := dedupePositiveInt64Ids(req.Ids) + playletFilter := bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}} + chapterFilter := bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": ids}} + var serviceErr *srvdef.SrvErr + var chapterIDs []int64 + txnErr := mongos.Shop.Transaction(ctx, func(sctx mongo.SessionContext) error { + serviceErr = nil + playlets, err := playletmod.FindManyPlaylet(sctx, playletFilter, options.Find().SetProjection(bson.M{ + playletmod.FN.Playlet.Id: 1, + playletmod.FN.Playlet.TagIds: 1, + })) + if err != nil { + serviceErr = err + return err + } + changes := make([]srv_contenttag.ContentTagBindChange, 0, len(playlets)) + for _, playlet := range playlets { + if playlet != nil && len(playlet.TagIds) > 0 { + changes = append(changes, srv_contenttag.ContentTagBindChange{OldTagIds: playlet.TagIds}) + } + } + if len(changes) > 0 { + if err := srv_contenttag.SyncContentTagBindCountBatch(sctx, changes, contenttagmod.ContentTagTypePlaylet); err != nil { + serviceErr = err + return err + } + } + chapters, err := playletchaptermod.FindManyPlayletChapter(sctx, chapterFilter, options.Find().SetProjection(bson.M{ + playletchaptermod.FN.PlayletChapter.Id: 1, + })) + if err != nil { + serviceErr = err + return err + } + commentFilters := bson.A{bson.M{ + commentmod.FN.Comment.ObjectType: pubdef.CommentObjectTypePlaylet, + commentmod.FN.Comment.ObjectId: bson.M{"$in": ids}, + }} + chapterIDs = make([]int64, 0, len(chapters)) + for _, chapter := range chapters { + if chapter != nil { + chapterIDs = append(chapterIDs, chapter.Id) + } + } + if len(chapterIDs) > 0 { + commentFilters = append(commentFilters, bson.M{ + commentmod.FN.Comment.ObjectType: pubdef.CommentObjectTypePlayletChapter, + commentmod.FN.Comment.ObjectId: bson.M{"$in": chapterIDs}, + }) + } + commentFilter := bson.M{"$or": commentFilters} + comments, err := commentmod.FindManyComment(sctx, commentFilter, options.Find().SetProjection(bson.M{ + commentmod.FN.Comment.Id: 1, + })) + if err != nil { + serviceErr = err + return err + } + commentIDs := make([]int64, 0, len(comments)) + for _, comment := range comments { + if comment != nil { + commentIDs = append(commentIDs, comment.Id) + } + } + if err := commentlikemod.DeleteByCommentIds(sctx, commentIDs); err != nil { + serviceErr = err + return err + } + deletions := []struct { + collection string + filter bson.M + }{ + {commentmod.TN.Comment, commentFilter}, + {playletlikemod.TN.PlayletLike, bson.M{playletlikemod.FN.PlayletLike.PlayletID: bson.M{"$in": ids}}}, + {playletfavoritemod.CollectionName, bson.M{playletfavoritemod.FieldPlayletID: bson.M{"$in": ids}}}, + {models.WatchHistoryCollectionName, bson.M{models.WatchHistoryDataType: models.WatchHistoryTypePlaylet, models.WatchHistoryContentID: bson.M{"$in": ids}}}, + {playletchaptermod.TN.PlayletChapter, chapterFilter}, + {playletmod.TN.Playlet, playletFilter}, + } + for _, deletion := range deletions { + if _, err := mongos.Shop.Coll(deletion.collection).DeleteMany(sctx, deletion.filter); err != nil { + log.Warn("删除短剧关联数据失败", log.Any("collection", deletion.collection), log.E(err)) + serviceErr = srvdef.New(srvdef.ErrDbDeleteError, "", "") + return err + } + } + return nil + }) + if txnErr != nil { + if serviceErr != nil { + return serviceErr + } + log.Warn("删除短剧事务失败", log.E(txnErr)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + clearAppPlayletHomeCache() + playletcache.ClearRankCache() + if redis.Client() != nil { + for _, id := range ids { + if err := redis.Del(redisconst.CommentListCacheKey(id, pubdef.CommentObjectTypePlaylet)); err != nil { + log.Warn("清理短剧评论缓存失败", log.Any("playletId", id), log.E(err)) + } + } + for _, id := range chapterIDs { + if err := redis.Del(redisconst.CommentListCacheKey(id, pubdef.CommentObjectTypePlayletChapter)); err != nil { + log.Warn("清理剧集评论缓存失败", log.Any("chapterId", id), log.E(err)) + } + } + } + srv_seopush.MarkSitemapDirty(ctx) + for _, id := range ids { + srv_seopush.EnqueueDeleted(ctx, srv_seopush.KindPlaylet, id) + } + if err := srv_search.SyncPlayletStatusToES(ctx, ids, playletmod.StatusDown); err != nil { + return err + } + return nil +} + +// SetPlayletStatus 单/批量设置短剧上下架 +func SetPlayletStatus(ctx context.Context, req *proto.PlayletStatusSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + if !isValidPlayletStatus(req.Status) { + return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") + } + now := time.Now() + setDoc := bson.M{ + playletmod.FN.Playlet.Status: req.Status, + playletmod.FN.Playlet.ReviewAt: now, + playletmod.FN.Playlet.UpdatedAt: now, + } + if req.Status == playletmod.StatusUp { + setDoc[playletmod.FN.Playlet.OnlineAt] = now + setDoc[playletmod.FN.Playlet.IndexNow] = int64(0) + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + srv_seopush.NotifyPlayletAfterStatus(ctx, ids, req.Status, now) + asyncSyncPlayletESByStatus(ids, req.Status, "短剧上下架") + return nil +} + +// SetPlayletRecommend 单/批量设置短剧是否推荐 +func SetPlayletRecommend(ctx context.Context, req *proto.PlayletRecommendSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + setDoc := bson.M{ + playletmod.FN.Playlet.IsRecommend: req.IsRecommend, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧推荐状态变更") + return nil +} + +// SetPlayletFeatured 单/批量设置短剧是否爆剧 +func SetPlayletFeatured(ctx context.Context, req *proto.PlayletFeaturedSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + setDoc := bson.M{ + playletmod.FN.Playlet.IsFeatured: req.IsFeatured, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧爆剧状态变更") + return nil +} + +// SetPlayletBanner 单/批量设置短剧是否显示在首页banner +func SetPlayletBanner(ctx context.Context, req *proto.PlayletBannerSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + setDoc := bson.M{ + playletmod.FN.Playlet.ShowOnBanner: req.ShowOnBanner, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧banner状态变更") + return nil +} + +// SetPlayletTag 单/批量设置短剧标签 +func SetPlayletTag(ctx context.Context, req *proto.PlayletTagSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + if req.TagIds == nil { + return srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") + } + tagIds, err := srv_contenttag.NormalizeContentTagIds(*req.TagIds) + if err != nil { + return err + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypePlaylet); err != nil { + return err + } + list, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.TagIds, Value: 1}, + })) + if err != nil { + return err + } + setDoc := bson.M{ + playletmod.FN.Playlet.TagIds: tagIds, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + changes := make([]srv_contenttag.ContentTagBindChange, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + changes = append(changes, srv_contenttag.ContentTagBindChange{ + OldTagIds: append([]int64{}, item.TagIds...), + NewTagIds: append([]int64{}, tagIds...), + }) + } + if err := srv_contenttag.SyncContentTagBindCountBatch(ctx, changes, contenttagmod.ContentTagTypePlaylet); err != nil { + return err + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧标签变更") + return nil +} + +// SetPlayletCategory 单/批量设置短剧分类 +func SetPlayletCategory(ctx context.Context, req *proto.PlayletCategorySetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + if req.CategoryIds == nil { + return srvdef.New(srvdef.ErrParamError, "分类ID不能为空", "") + } + categoryIds, err := normalizePlayletCategoryIds(*req.CategoryIds) + if err != nil { + return err + } + if err := validatePlayletCategoryIds(ctx, categoryIds); err != nil { + return err + } + list, err := playletmod.FindManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}) + if err != nil { + return err + } + for _, item := range list { + binds := playletmod.MergeCategoryBindsByIds(playletmod.EffectiveCategoryBinds(item), categoryIds) + if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: item.Id}, bson.M{"$set": bson.M{ + playletmod.FN.Playlet.CategoryIds: categoryIds, + playletmod.FN.Playlet.CategoryBinds: binds, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + }}); err != nil { + return err + } + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧分类变更") + return nil +} + +// SetPlayletTheme 单/批量设置短剧题材 +func SetPlayletTheme(ctx context.Context, req *proto.PlayletThemeSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + themeIds := dedupePositiveInt64Ids(req.ThemeIds) + if len(themeIds) == 0 { + return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") + } + if err := validatePlayletThemeIds(ctx, themeIds); err != nil { + return err + } + setDoc := bson.M{ + playletmod.FN.Playlet.ThemeIds: themeIds, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧题材变更") + return nil +} + +// SetPlayletVirtualLike 单/批量设置短剧虚拟点赞数 +func SetPlayletVirtualLike(ctx context.Context, req *proto.PlayletVirtualLikeSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + if req.MinVirtualCountLike < 0 || req.MaxVirtualCountLike < req.MinVirtualCountLike { + return srvdef.New(srvdef.ErrParamError, "虚拟点赞数范围无效", "") + } + if req.MaxVirtualCountLike-req.MinVirtualCountLike < int64(len(ids))-1 { + return srvdef.New(srvdef.ErrParamError, "虚拟点赞数范围不足,无法保证每条短剧数值不同", "") + } + values := randomUniqueVirtualLikeValues(req.MinVirtualCountLike, req.MaxVirtualCountLike, len(ids)) + now := time.Now() + docs := make([]mongo.WriteModel, 0, len(ids)) + for index, id := range ids { + setDoc := bson.M{ + playletmod.FN.Playlet.VirtualCountLike: values[index], + playletmod.FN.Playlet.UpdatedAt: now, + } + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{playletmod.FN.Playlet.Id: id}). + SetUpdate(bson.M{"$set": setDoc})) + } + if err := playletmod.BulkWritePlaylet(ctx, docs); err != nil { + return err + } + clearAppPlayletHomeCache() + asyncSyncPlayletESByLatestStatus(ids, "短剧虚拟点赞数变更") + return nil +} + +// SetPlayletVirtualBrowse 单/批量设置短剧虚拟观看数 +func SetPlayletVirtualBrowse(ctx context.Context, req *proto.PlayletVirtualBrowseSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + return setPlayletVirtualMetric(ctx, req.Ids, req.MinVirtualCountBrowse, req.MaxVirtualCountBrowse, + playletmod.FN.Playlet.VirtualCountBrowse, "短剧虚拟观看数变更") +} + +// SetPlayletVirtualHeat 单/批量设置短剧虚拟热度 +func SetPlayletVirtualHeat(ctx context.Context, req *proto.PlayletVirtualHeatSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + return setPlayletVirtualMetric(ctx, req.Ids, req.MinVirtualHeatValue, req.MaxVirtualHeatValue, + playletmod.FN.Playlet.VirtualHeatValue, "短剧虚拟热度变更") +} + +// setPlayletVirtualMetric 按区间为每部短剧设置不同虚拟数值 +func setPlayletVirtualMetric(ctx context.Context, rawIds []int64, min, max int64, field, reason string) *srvdef.SrvErr { + ids := dedupePositiveInt64Ids(rawIds) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + if min < 0 || max < min || max-min < int64(len(ids))-1 { + return srvdef.New(srvdef.ErrParamError, "虚拟数值区间无效或不足", "") + } + values := randomUniqueVirtualLikeValues(min, max, len(ids)) + now := time.Now() + docs := make([]mongo.WriteModel, 0, len(ids)) + for index, id := range ids { + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{playletmod.FN.Playlet.Id: id}). + SetUpdate(bson.M{"$set": bson.M{field: values[index], playletmod.FN.Playlet.UpdatedAt: now}})) + } + if err := playletmod.BulkWritePlaylet(ctx, docs); err != nil { + return err + } + clearAppPlayletHomeCache() + playletcache.ClearRankCache() + asyncSyncPlayletESByLatestStatus(ids, reason) + return nil +} + +// SetPlayletPayType 单/批量设置短剧默认支付方式 +func SetPlayletPayType(ctx context.Context, req *proto.PlayletPayTypeSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + payType, singleCoins, err := normalizePlayletPaySetting(req.PayType, req.SingleCoins, true) + if err != nil { + return err + } + setDoc := bson.M{ + playletmod.FN.Playlet.PayType: payType, + playletmod.FN.Playlet.SingleCoins: singleCoins, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + return nil +} + +// ResetPlayletWatermark 按短剧ID批量重置其下全部剧集水印,等待定时任务重新加水印。 +// 已有水印地址不清理,新水印完成回写时直接覆盖。 +// 原创短剧保留 mediaSourceId / videoUrl / mediaTaskId,直接进入已登记后按原片重跑水印。 +// 非原创清空 media 关联并回到未处理,由 SyncMediaFile 重新登记。 +func ResetPlayletWatermark(ctx context.Context, req *proto.PlayletWatermarkResetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + originalIds, otherIds, err := splitPlayletIdsByOriginal(ctx, ids) + if err != nil { + return err + } + now := time.Now() + if resetErr := resetOriginalChaptersWatermark(ctx, originalIds, now); resetErr != nil { + return resetErr + } + return resetChaptersWatermark(ctx, otherIds, playletchaptermod.WatermarkStatusPending, now) +} + +func splitPlayletIdsByOriginal(ctx context.Context, ids []int64) (originalIds, otherIds []int64, err *srvdef.SrvErr) { + originalList, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + playletmod.FN.Playlet.IsOriginal: true, + }, options.Find().SetProjection(bson.M{playletmod.FN.Playlet.Id: 1})) + if err != nil { + return nil, nil, err + } + originalSet := make(map[int64]struct{}, len(originalList)) + for _, item := range originalList { + if item == nil || item.Id <= 0 { + continue + } + originalSet[item.Id] = struct{}{} + originalIds = append(originalIds, item.Id) + } + for _, id := range ids { + if _, ok := originalSet[id]; !ok { + otherIds = append(otherIds, id) + } + } + return originalIds, otherIds, nil +} + +func resetOriginalChaptersWatermark(ctx context.Context, playletIds []int64, now time.Time) *srvdef.SrvErr { + if len(playletIds) == 0 { + return nil + } + // 原创剧集保留 mediaSourceId / videoUrl / mediaTaskId,以及已有水印地址。 + // 新水印完成回写时会直接覆盖 WatermarkVideoUrl。 + err := playletchaptermod.UpdateManyPlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": playletIds}}, + bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusRegistered, + playletchaptermod.FN.PlayletChapter.UpdatedAt: now, + }}, + ) + if err != nil && err.Code == srvdef.ErrNoData { + return nil + } + return err +} + +func resetChaptersWatermark(ctx context.Context, playletIds []int64, status playletchaptermod.WatermarkStatus, now time.Time) *srvdef.SrvErr { + if len(playletIds) == 0 { + return nil + } + err := playletchaptermod.UpdateManyPlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": playletIds}}, + bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: status, + playletchaptermod.FN.PlayletChapter.MediaSourceId: "", + playletchaptermod.FN.PlayletChapter.MediaTaskId: "", + playletchaptermod.FN.PlayletChapter.UpdatedAt: now, + }}, + ) + if err != nil && err.Code == srvdef.ErrNoData { + return nil + } + return err +} + +// ListPlayletChapter 分页查询短剧剧集列表 +func ListPlayletChapter(ctx context.Context, req *proto.PlayletChapterListReq) (*proto.PlayletChapterListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[playletchaptermod.FN.PlayletChapter.Id] = *req.Id + } + if req.PlayletId != nil && *req.PlayletId > 0 { + filter[playletchaptermod.FN.PlayletChapter.PlayletId] = *req.PlayletId + } + if req.WatermarkStatus != nil { + filter[playletchaptermod.FN.PlayletChapter.WatermarkStatus] = *req.WatermarkStatus + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + totalCount, err := playletchaptermod.CountPlayletChapter(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletChapterListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletChapterListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find(). + SetSort(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletChapterListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, chapterToWebResp(item)) + } + resp.List = items + return resp, nil +} + +// AddPlayletChapter 新增短剧剧集 +func AddPlayletChapter(ctx context.Context, req *proto.PlayletChapterAddReq) (*proto.PlayletChapterAddResp, *srvdef.SrvErr) { + if req == nil || req.PlayletId <= 0 || req.CurrentEpisode <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "playletId/currentEpisode无效", "") + } + if !isValidPlayletChapterPayType(req.PayType) { + return nil, srvdef.New(srvdef.ErrParamError, "支付方式无效", "") + } + if req.PayType == playletchaptermod.PayTypeCoin && req.SingleCoins <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "金币支付时单集金币必须大于0", "") + } + singleCoins := req.SingleCoins + if req.PayType != playletchaptermod.PayTypeCoin { + singleCoins = 0 + } + playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.PlayletId}) + if err != nil { + return nil, err + } + if playlet == nil || !playlet.IsOriginal { + return nil, srvdef.New(srvdef.ErrParamError, "只有原创短剧支持新增剧集", "") + } + doc := &playletchaptermod.PlayletChapter{ + PlayletId: req.PlayletId, + CurrentEpisode: req.CurrentEpisode, + Title: strings.TrimSpace(req.Title), + Md5: strings.TrimSpace(req.Md5), + Hash: strings.TrimSpace(req.Hash), + Height: req.Height, + Width: req.Width, + Ratio: req.Ratio, + VideoUrl: strings.TrimSpace(req.VideoUrl), + MediaSize: req.MediaSize, + MediaTime: req.MediaTime, + PayType: req.PayType, + SingleCoins: singleCoins, + } + mediaSourceId := strings.TrimSpace(req.MediaSourceId) + if playlet.IsOriginal && (doc.VideoUrl != "" || mediaSourceId != "") { + doc.WatermarkStatus = playletchaptermod.WatermarkStatusRegistered + if mediaSourceId != "" { + // 后台上传正片已在 media 创建原片,直接沿用该 ID,避免再 SyncMediaFile 重复处理。 + doc.MediaSourceId = mediaSourceId + } else { + sourceID, regErr := playletwatermark.RegisterChapterSource(doc) + if regErr != nil { + log.Error("AddPlayletChapter RegisterChapterSource 失败", + log.Any("playletId", req.PlayletId), + log.Any("videoUrl", doc.VideoUrl), + log.E(regErr)) + } else { + doc.MediaSourceId = sourceID + } + } + } + if err := playletchaptermod.InsertOnePlayletChapter(ctx, doc); err != nil { + return nil, err + } + return &proto.PlayletChapterAddResp{Id: doc.Id}, nil +} + +// DelPlayletChapter 单/批量删除原创短剧剧集。 +func DelPlayletChapter(ctx context.Context, req *proto.PlayletChapterDelReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "剧集ID不能为空", "") + } + filter := bson.M{playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}} + chapters, findErr := playletchaptermod.FindManyPlayletChapter(ctx, filter) + if findErr != nil { + return findErr + } + if len(chapters) == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + playletIds := make([]int64, 0, len(chapters)) + seenPlaylet := make(map[int64]struct{}, len(chapters)) + for _, item := range chapters { + if item == nil || item.PlayletId <= 0 { + continue + } + if _, ok := seenPlaylet[item.PlayletId]; ok { + continue + } + seenPlaylet[item.PlayletId] = struct{}{} + playletIds = append(playletIds, item.PlayletId) + } + if len(playletIds) > 0 { + playlets, playletErr := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, + }, options.Find().SetProjection(bson.M{ + playletmod.FN.Playlet.Id: 1, + playletmod.FN.Playlet.IsOriginal: 1, + })) + if playletErr != nil { + return playletErr + } + for _, item := range playlets { + if item == nil || !item.IsOriginal { + return srvdef.New(srvdef.ErrParamError, "只有原创短剧支持删除剧集", "") + } + } + } + count, err := playletchaptermod.DelManyPlayletChapter(ctx, filter) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdatePlayletChapter 编辑短剧剧集 +func UpdatePlayletChapter(ctx context.Context, req *proto.PlayletChapterUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "id无效", "") + } + setDoc := bson.M{playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now()} + if req.CurrentEpisode != nil { + if *req.CurrentEpisode <= 0 { + return srvdef.New(srvdef.ErrParamError, "集数无效", "") + } + setDoc[playletchaptermod.FN.PlayletChapter.CurrentEpisode] = *req.CurrentEpisode + } + if req.Title != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Title] = strings.TrimSpace(*req.Title) + } + if req.Md5 != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Md5] = strings.TrimSpace(*req.Md5) + } + if req.Hash != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Hash] = strings.TrimSpace(*req.Hash) + } + if req.Height != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Height] = *req.Height + } + if req.Width != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Width] = *req.Width + } + if req.Ratio != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Ratio] = *req.Ratio + } + if req.VideoUrl != nil { + setDoc[playletchaptermod.FN.PlayletChapter.VideoUrl] = strings.TrimSpace(*req.VideoUrl) + } + if req.MediaSize != nil { + setDoc[playletchaptermod.FN.PlayletChapter.MediaSize] = *req.MediaSize + } + if req.MediaTime != nil { + setDoc[playletchaptermod.FN.PlayletChapter.MediaTime] = *req.MediaTime + } + if req.PayType != nil { + if !isValidPlayletChapterPayType(*req.PayType) { + return srvdef.New(srvdef.ErrParamError, "支付方式无效", "") + } + setDoc[playletchaptermod.FN.PlayletChapter.PayType] = *req.PayType + if *req.PayType != playletchaptermod.PayTypeCoin && req.SingleCoins == nil { + setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = int64(0) + } + } + if req.SingleCoins != nil { + if *req.SingleCoins < 0 { + return srvdef.New(srvdef.ErrParamError, "单集金币不能小于0", "") + } + setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = *req.SingleCoins + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + return playletchaptermod.UpdateOnePlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.Id: req.Id}, + bson.M{"$set": setDoc}, + ) +} + +// SetPlayletChapterPayType 单/批量设置短剧剧集支付方式 +func SetPlayletChapterPayType(ctx context.Context, req *proto.PlayletChapterPayTypeSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "剧集ID不能为空", "") + } + if !isValidPlayletChapterPayType(req.PayType) { + return srvdef.New(srvdef.ErrParamError, "支付方式无效", "") + } + setDoc := bson.M{ + playletchaptermod.FN.PlayletChapter.PayType: req.PayType, + playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now(), + } + if req.PayType == playletchaptermod.PayTypeCoin { + if req.SingleCoins == nil || *req.SingleCoins <= 0 { + return srvdef.New(srvdef.ErrParamError, "金币支付时单集金币必须大于0", "") + } + setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = *req.SingleCoins + } else { + setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = int64(0) + } + return playletchaptermod.UpdateManyPlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ) +} + +func buildPlayletListFilter(req *proto.PlayletListReq) bson.M { + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[playletmod.FN.Playlet.Id] = *req.Id + } + if req.CategoryId != nil && *req.CategoryId > 0 { + filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{*req.CategoryId}} + } + if req.TagId != nil && *req.TagId > 0 { + filter[playletmod.FN.Playlet.TagIds] = bson.M{"$in": []int64{*req.TagId}} + } + if req.ThemeId != nil && *req.ThemeId > 0 { + filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{*req.ThemeId}} + } + if req.ContentType != nil { + filter[playletmod.FN.Playlet.ContentType] = *req.ContentType + } + if req.PayType != nil { + filter[playletmod.FN.Playlet.PayType] = *req.PayType + } + if req.Status != nil { + filter[playletmod.FN.Playlet.Status] = *req.Status + } + if req.IsFeatured != nil { + filter[playletmod.FN.Playlet.IsFeatured] = *req.IsFeatured + } + if req.IsOriginal != nil { + if *req.IsOriginal { + filter[playletmod.FN.Playlet.IsOriginal] = true + } else { + // 历史入库数据可能没有该字段,按非原创处理 + filter[playletmod.FN.Playlet.IsOriginal] = bson.M{"$ne": true} + } + } + if req.ShowOriginal != nil { + if *req.ShowOriginal { + filter[playletmod.FN.Playlet.ShowOriginal] = true + } else { + filter[playletmod.FN.Playlet.ShowOriginal] = bson.M{"$ne": true} + } + } + if req.IsRecommend != nil { + filter[playletmod.FN.Playlet.IsRecommend] = *req.IsRecommend + } + if req.ShowOnBanner != nil { + filter[playletmod.FN.Playlet.ShowOnBanner] = *req.ShowOnBanner + } + if title := strings.TrimSpace(req.Title); title != "" { + filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} + } + if operatorName := strings.TrimSpace(req.OperatorName); operatorName != "" { + filter[playletmod.FN.Playlet.OperatorName] = bson.M{"$regex": regexp.QuoteMeta(operatorName)} + } + return filter +} + +func playletToWebResp(item *playletmod.Playlet, categoryNameMap map[int64]string, themeNameMap map[int64]string, tagNameMap map[int64]string) *proto.PlayletListResp { + return &proto.PlayletListResp{ + Id: item.Id, + Status: item.Status, + IsFeatured: item.IsFeatured, + IsOriginal: item.IsOriginal, + ShowOriginal: item.ShowOriginal, + MediaBusiness: item.MediaBusiness, + IsRecommend: item.IsRecommend, + ShowOnBanner: item.ShowOnBanner, + Banner: item.Banner, + ReviewAt: item.ReviewAt, + OnlineAt: item.OnlineAt, + Title: item.Title, + Slug: item.Slug, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: item.Cover, + CompressedCover: item.CompressedCover, + Preview: item.Preview, + CategoryIds: item.CategoryIds, + CategoryBinds: toProtoCategoryBinds(playletmod.EffectiveCategoryBinds(item)), + CategoryNames: categoryNamesFromMap(item.CategoryIds, categoryNameMap), + ThemeIds: item.ThemeIds, + ThemeNames: themeNamesFromMap(item.ThemeIds, themeNameMap), + TagIds: item.TagIds, + TagNames: tagNamesFromMap(item.TagIds, tagNameMap), + OldTags: normalizePlayletOldTags(item.OldTags), + ContentType: item.ContentType, + PayType: item.PayType, + SingleCoins: item.SingleCoins, + TotalEpisode: item.TotalEpisode, + FreeEpisodeCount: item.EffectiveFreeEpisodeCount(), + UpdateStatus: item.UpdateStatus, + TopRank: item.TopRank, + RecommendRank: item.RecommendRank, + CountComment: item.CountComment, + CountBuy: item.CountBuy, + CountBrowse: item.CountBrowse, + VirtualCountBrowse: item.VirtualCountBrowse, + DisplayCountBrowse: item.CountBrowse + item.VirtualCountBrowse, + HeatValue: item.HeatValue, + VirtualHeatValue: item.VirtualHeatValue, + DisplayHeatValue: item.HeatValue + item.VirtualHeatValue, + CountCollect: item.CountCollect, + CountLike: item.CountLike, + VirtualCountLike: item.VirtualCountLike, + DisplayCountLike: playletDisplayCountLike(item), + CountDisLike: item.CountDisLike, + SeoInfo: normalizeSeoValue(item.SeoInfo), + OperatorId: item.OperatorId, + OperatorName: item.OperatorName, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func validOfficialFreeEpisodeCount(count int64) bool { + return count == -1 || count >= 1 && count <= 1000 +} + +func playletDisplayCountLike(item *playletmod.Playlet) int64 { + if item == nil { + return 0 + } + return item.CountLike + item.VirtualCountLike +} + +func chapterToWebResp(item *playletchaptermod.PlayletChapter) *proto.PlayletChapterListResp { + if item == nil { + return nil + } + return &proto.PlayletChapterListResp{ + Id: item.Id, + PlayletId: item.PlayletId, + MediaContentId: item.MediaContentId, + CurrentEpisode: item.CurrentEpisode, + Title: item.Title, + Md5: item.Md5, + Hash: item.Hash, + Height: item.Height, + Width: item.Width, + Ratio: item.Ratio, + VideoUrl: item.VideoUrl, + WatermarkVideoUrl: item.WatermarkVideoUrl, + MediaSize: item.MediaSize, + MediaTime: item.MediaTime, + CountComment: item.CountComment, + CountBuy: item.CountBuy, + CountBrowse: item.CountBrowse, + CountCollect: item.CountCollect, + CountLike: item.CountLike, + CountDisLike: item.CountDisLike, + PayType: item.PayType, + SingleCoins: item.SingleCoins, + WatermarkStatus: item.WatermarkStatus, + MediaSourceId: item.MediaSourceId, + MediaTaskId: item.MediaTaskId, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func buildCategoryNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + categoryIds := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, item := range list { + if item == nil { + continue + } + for _, categoryId := range item.CategoryIds { + if categoryId <= 0 { + continue + } + if _, ok := seen[categoryId]; ok { + continue + } + seen[categoryId] = struct{}{} + categoryIds = append(categoryIds, categoryId) + } + } + result := make(map[int64]string, len(categoryIds)) + if len(categoryIds) == 0 { + return result, nil + } + categories, err := playletcategorymod.FindManyPlayletCategory(ctx, bson.M{ + playletcategorymod.FN.PlayletCategory.Id: bson.M{"$in": categoryIds}, + }, options.Find().SetProjection(bson.M{ + playletcategorymod.FN.PlayletCategory.Id: 1, + playletcategorymod.FN.PlayletCategory.Name: 1, + })) + if err != nil { + return nil, err + } + for _, category := range categories { + if category != nil { + result[category.Id] = category.Name + } + } + return result, nil +} + +func categoryNamesFromMap(categoryIds []int64, categoryNameMap map[int64]string) []string { + result := make([]string, 0, len(categoryIds)) + for _, categoryId := range categoryIds { + if name := categoryNameMap[categoryId]; name != "" { + result = append(result, name) + } + } + return result +} + +func buildThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + themeIds := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, item := range list { + if item == nil { + continue + } + for _, themeId := range item.ThemeIds { + if themeId <= 0 { + continue + } + if _, ok := seen[themeId]; ok { + continue + } + seen[themeId] = struct{}{} + themeIds = append(themeIds, themeId) + } + } + result := make(map[int64]string, len(themeIds)) + if len(themeIds) == 0 { + return result, nil + } + 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 { + return nil, err + } + for _, theme := range themes { + if theme != nil { + result[theme.Id] = theme.Name + } + } + return result, nil +} + +func themeNamesFromMap(themeIds []int64, themeNameMap map[int64]string) []string { + result := make([]string, 0, len(themeIds)) + for _, themeId := range themeIds { + if name := themeNameMap[themeId]; name != "" { + result = append(result, name) + } + } + return result +} + +func buildTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + tagIds := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, item := range list { + if item == nil { + continue + } + for _, tagId := range item.TagIds { + if tagId <= 0 { + continue + } + if _, ok := seen[tagId]; ok { + continue + } + seen[tagId] = struct{}{} + tagIds = append(tagIds, tagId) + } + } + result := make(map[int64]string, len(tagIds)) + if len(tagIds) == 0 { + return result, nil + } + tags, err := contenttagmod.GetMapTags(ctx, tagIds) + if err != nil { + return nil, err + } + for _, tag := range tags { + if tag != nil { + result[tag.Id] = tag.Name + } + } + return result, nil +} + +func tagNamesFromMap(tagIds []int64, tagNameMap map[int64]string) []string { + result := make([]string, 0, len(tagIds)) + for _, tagId := range tagIds { + if name := tagNameMap[tagId]; name != "" { + result = append(result, name) + } + } + return result +} + +func normalizePlayletOldTags(oldTags []string) []string { + if oldTags == nil { + return make([]string, 0) + } + return oldTags +} + +func dedupePositiveInt64Ids(ids []int64) []int64 { + result := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, 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 +} + +func isValidPlayletStatus(status playletmod.Status) bool { + return status == playletmod.StatusDown || status == playletmod.StatusUp +} + +func isValidPlayletPayType(payType playletmod.PayType) bool { + return payType == playletmod.PayTypeFree || + payType == playletmod.PayTypeCoin || + payType == playletmod.PayTypeVip +} + +func isValidPlayletChapterPayType(payType playletchaptermod.PayType) bool { + return payType == playletchaptermod.PayTypeFree || + payType == playletchaptermod.PayTypeCoin || + payType == playletchaptermod.PayTypeVip +} + +func isValidPlayletContentType(contentType playletmod.ContentType) bool { + return contentType == playletmod.ContentTypeTheater || + contentType == playletmod.ContentTypeAlbum || + contentType == playletmod.ContentTypeComic || + contentType == playletmod.ContentTypeAnime || + contentType == playletmod.ContentTypeManga +} + +func normalizePlayletPaySetting(payType playletmod.PayType, singleCoins *int64, requireCoinAmount bool) (playletmod.PayType, int64, *srvdef.SrvErr) { + if !isValidPlayletPayType(payType) { + return 0, 0, srvdef.New(srvdef.ErrParamError, "支付方式无效", "") + } + if payType == playletmod.PayTypeCoin { + if requireCoinAmount && (singleCoins == nil || *singleCoins <= 0) { + return 0, 0, srvdef.New(srvdef.ErrParamError, "金币支付时单集金币必须大于0", "") + } + if singleCoins == nil { + return payType, 0, nil + } + return payType, *singleCoins, nil + } + return payType, 0, nil +} + +func normalizePlayletTagIds(tagIds []int64) ([]int64, *srvdef.SrvErr) { + result := make([]int64, 0, len(tagIds)) + seen := make(map[int64]struct{}, len(tagIds)) + for _, tagId := range tagIds { + if tagId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "标签ID无效", "") + } + if _, ok := seen[tagId]; ok { + continue + } + seen[tagId] = struct{}{} + result = append(result, tagId) + } + return result, nil +} + +func validatePlayletTagIds(ctx context.Context, tagIds []int64) *srvdef.SrvErr { + if len(tagIds) == 0 { + return nil + } + return srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypePlaylet) +} + +// resolveAddCategoryBinds 新增短剧时根据分类绑定或分类ID生成排序配置 +func resolveAddCategoryBinds(binds []proto.PlayletCategoryBind, ids []int64) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { + if len(binds) > 0 { + return resolveCategoryBinds(&binds, nil, nil) + } + return resolveCategoryBinds(nil, &ids, nil) +} + +// resolveCategoryBinds 保留旧分类排序,新增分类默认排序为0 +func resolveCategoryBinds(binds *[]proto.PlayletCategoryBind, ids *[]int64, old []playletmod.PlayletCategoryBind) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { + if binds != nil { + result := make([]playletmod.PlayletCategoryBind, 0, len(*binds)) + seen := make(map[int64]bool, len(*binds)) + for _, bind := range *binds { + if bind.Id <= 0 || seen[bind.Id] { + return nil, srvdef.New(srvdef.ErrParamError, "分类ID无效或重复", "") + } + seen[bind.Id] = true + result = append(result, playletmod.PlayletCategoryBind{Id: bind.Id, Rank: bind.Rank}) + } + return result, nil + } + if ids != nil { + categoryIds, err := normalizePlayletCategoryIds(*ids) + if err != nil { + return nil, err + } + return playletmod.MergeCategoryBindsByIds(old, categoryIds), nil + } + return nil, nil +} + +func toProtoCategoryBinds(binds []playletmod.PlayletCategoryBind) []proto.PlayletCategoryBind { + result := make([]proto.PlayletCategoryBind, 0, len(binds)) + for _, bind := range binds { + result = append(result, proto.PlayletCategoryBind{Id: bind.Id, Rank: bind.Rank}) + } + return result +} + +func normalizePlayletCategoryIds(categoryIds []int64) ([]int64, *srvdef.SrvErr) { + result := make([]int64, 0, len(categoryIds)) + seen := make(map[int64]struct{}, len(categoryIds)) + for _, categoryId := range categoryIds { + if categoryId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "分类ID无效", "") + } + if _, ok := seen[categoryId]; ok { + continue + } + seen[categoryId] = struct{}{} + result = append(result, categoryId) + } + return result, nil +} + +func validatePlayletCategoryIds(ctx context.Context, categoryIds []int64) *srvdef.SrvErr { + if len(categoryIds) == 0 { + return nil + } + count, err := playletcategorymod.CountPlayletCategory(ctx, bson.M{ + playletcategorymod.FN.PlayletCategory.Id: bson.M{"$in": categoryIds}, + }) + if err != nil { + return err + } + if count != int64(len(categoryIds)) { + return srvdef.New(srvdef.ErrParamError, "分类不存在", "") + } + return nil +} + +func validatePlayletThemeIds(ctx context.Context, themeIds []int64) *srvdef.SrvErr { + if len(themeIds) == 0 { + return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") + } + count, err := playletthememod.CountPlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Id: bson.M{"$in": themeIds}, + }) + if err != nil { + return err + } + if count != int64(len(themeIds)) { + return srvdef.New(srvdef.ErrParamError, "题材不存在", "") + } + return nil +} + +func playletOnlineAt(status playletmod.Status, now time.Time) time.Time { + if status == playletmod.StatusUp { + return now + } + return time.Time{} +} + +func resolvePlayletOperator(ctx context.Context, operatorId int64) (int64, string) { + if operatorId <= 0 { + return 0, "" + } + acc, err := mwacc.FindOneAccByFilter(ctx, bson.M{ + mwacc.FN.MwAcc.Id: operatorId, + }, options.FindOne().SetProjection(bson.D{ + {Key: mwacc.FN.MwAcc.Id, Value: 1}, + {Key: mwacc.FN.MwAcc.UserName, Value: 1}, + })) + if err != nil || acc == nil { + return operatorId, "" + } + return acc.Id, acc.UserName +} + +func asyncSyncPlayletESByStatus(ids []int64, status playletmod.Status, action string) { + syncIds := append([]int64(nil), ids...) + go func() { + if err1 := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, status); err1 != nil { + log.Error(action+"同步ES失败", log.Any("短剧ID", syncIds), log.Any("状态", status), log.E(err1)) + } + }() +} + +func asyncSyncPlayletESByLatestStatus(ids []int64, action string) { + syncIds := append([]int64(nil), ids...) + go func() { + if err1 := srv_search.SyncPlayletsToESByLatestStatus(context.Background(), syncIds); err1 != nil { + log.Error(action+"同步ES失败", log.Any("短剧ID", syncIds), log.E(err1)) + } + }() +} + +func randomUniqueVirtualLikeValues(min int64, max int64, count int) []int64 { + if count <= 0 { + return nil + } + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + result := make([]int64, 0, count) + selected := make(map[int64]struct{}, count) + start := max - int64(count) + 1 + for offset := int64(0); offset < int64(count); offset++ { + current := start + offset + picked := min + rng.Int63n(current-min+1) + if _, ok := selected[picked]; ok { + picked = current + } + selected[picked] = struct{}{} + result = append(result, picked) + } + return result +} diff --git a/web/service/srv_playlet/tag.go b/web/service/srv_playlet/tag.go new file mode 100644 index 0000000..8794986 --- /dev/null +++ b/web/service/srv_playlet/tag.go @@ -0,0 +1,152 @@ +package srv_playlet + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// ListPlayletTag 分页查询标签列表 +func ListPlayletTag(ctx context.Context, req *proto.PlayletTagListReq) (*proto.PlayletTagListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[playlettagmod.FN.PlayletTag.Id] = *req.Id + } + if req.Status != nil { + filter[playlettagmod.FN.PlayletTag.Status] = *req.Status + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[playlettagmod.FN.PlayletTag.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + totalCount, err := playlettagmod.CountPlayletTag(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletTagListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletTagListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := playlettagmod.FindManyPlayletTag(ctx, filter, options.Find(). + SetSort(bson.D{ + {Key: playlettagmod.FN.PlayletTag.Rank, Value: -1}, + {Key: playlettagmod.FN.PlayletTag.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletTagListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.PlayletTagListResp{ + Id: item.Id, + Name: item.Name, + Status: item.Status, + Rank: item.Rank, + SeoInfo: normalizeSeoValue(item.SeoInfo), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// AddPlayletTag 新增标签 +func AddPlayletTag(ctx context.Context, req *proto.PlayletTagAddReq) (*proto.PlayletTagAddResp, *srvdef.SrvErr) { + if req == nil || strings.TrimSpace(req.Name) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "") + } + doc := &playlettagmod.PlayletTag{ + Name: strings.TrimSpace(req.Name), + Status: req.Status, + Rank: req.Rank, + SeoInfo: normalizeSeoValue(req.SeoInfo), + } + if err := playlettagmod.InsertOnePlayletTag(ctx, doc); err != nil { + return nil, err + } + clearAppPlayletHomeCache() + return &proto.PlayletTagAddResp{Id: doc.Id}, nil +} + +// UpdatePlayletTag 更新标签 +func UpdatePlayletTag(ctx context.Context, req *proto.PlayletTagUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "id无效", "") + } + setDoc := bson.M{playlettagmod.FN.PlayletTag.UpdatedAt: time.Now()} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "") + } + setDoc[playlettagmod.FN.PlayletTag.Name] = name + } + if req.Status != nil { + setDoc[playlettagmod.FN.PlayletTag.Status] = *req.Status + } + if req.Rank != nil { + setDoc[playlettagmod.FN.PlayletTag.Rank] = *req.Rank + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo) + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + if err := playlettagmod.UpdateOnePlayletTag(ctx, + bson.M{playlettagmod.FN.PlayletTag.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + return nil +} + +// DelPlayletTag 删除标签(被短剧引用时禁止删除) +func DelPlayletTag(ctx context.Context, id int64) *srvdef.SrvErr { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "id无效", "") + } + usedCount, err := playletmod.CountPlaylet(ctx, bson.M{playletmod.FN.Playlet.TagIds: id}) + if err != nil { + return err + } + if usedCount > 0 { + return srvdef.New(srvdef.ErrParamError, "标签已被短剧引用,无法删除", "") + } + count, err := playlettagmod.DelOnePlayletTag(ctx, bson.M{playlettagmod.FN.PlayletTag.Id: id}) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + clearAppPlayletHomeCache() + return nil +} diff --git a/web/service/srv_playlet/theme.go b/web/service/srv_playlet/theme.go new file mode 100644 index 0000000..24f767c --- /dev/null +++ b/web/service/srv_playlet/theme.go @@ -0,0 +1,213 @@ +package srv_playlet + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletthememod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var playletThemeWebProjection = bson.D{ + {Key: playletthememod.FN.PlayletTheme.Id, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Name, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Slug, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Icon, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Status, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.Rank, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.SeoInfo.Title, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.SeoInfo.Description, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.SeoInfo.Keyworld, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.CreatedAt, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.UpdatedAt, Value: 1}, +} + +// ListPlayletTheme 分页查询题材列表 +func ListPlayletTheme(ctx context.Context, req *proto.PlayletThemeListReq) (*proto.PlayletThemeListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[playletthememod.FN.PlayletTheme.Id] = *req.Id + } + if req.Status != nil { + filter[playletthememod.FN.PlayletTheme.Status] = *req.Status + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[playletthememod.FN.PlayletTheme.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + totalCount, err := playletthememod.CountPlayletTheme(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletThemeListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletThemeListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find(). + SetProjection(playletThemeWebProjection). + SetSort(bson.D{ + {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, + {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.PlayletThemeListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.PlayletThemeListResp{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Icon: item.Icon, + Status: item.Status, + Rank: item.Rank, + SeoInfo: normalizeSeoValue(item.SeoInfo), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// AddPlayletTheme 新增题材 +func AddPlayletTheme(ctx context.Context, req *proto.PlayletThemeAddReq) (*proto.PlayletThemeAddResp, *srvdef.SrvErr) { + if req == nil || strings.TrimSpace(req.Name) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "题材名称不能为空", "") + } + doc := &playletthememod.PlayletTheme{ + Name: strings.TrimSpace(req.Name), + Slug: req.Slug, + Icon: strings.TrimSpace(req.Icon), + Status: req.Status, + Rank: req.Rank, + SeoInfo: normalizeSeoValue(req.SeoInfo), + } + exists := func(s string) bool { + item, _ := playletthememod.FindOnePlayletTheme(ctx, slugx.Filter( + playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.Id, s, 0)) + return item != nil + } + if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, doc.Name), exists); serr != nil { + return nil, serr + } + if err := playletthememod.InsertOnePlayletTheme(ctx, doc); err != nil { + return nil, err + } + clearAppPlayletHomeCache() + return &proto.PlayletThemeAddResp{Id: doc.Id}, nil +} + +// UpdatePlayletTheme 更新题材 +func UpdatePlayletTheme(ctx context.Context, req *proto.PlayletThemeUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "题材ID无效", "") + } + current, err := playletthememod.FindOnePlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Id: req.Id, + }) + if err != nil { + return err + } + setDoc := bson.M{playletthememod.FN.PlayletTheme.UpdatedAt: time.Now()} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "题材名称不能为空", "") + } + setDoc[playletthememod.FN.PlayletTheme.Name] = name + } + if req.Icon != nil { + setDoc[playletthememod.FN.PlayletTheme.Icon] = strings.TrimSpace(*req.Icon) + } + if req.Status != nil { + setDoc[playletthememod.FN.PlayletTheme.Status] = *req.Status + } + if req.Rank != nil { + setDoc[playletthememod.FN.PlayletTheme.Rank] = *req.Rank + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo) + } + name := current.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + keywords := current.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeSeoValue(*req.SeoInfo).Keyworld + } + exists := func(s string) bool { + item, _ := playletthememod.FindOnePlayletTheme(ctx, slugx.Filter( + playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.Id, s, req.Id)) + return item != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists) + if serr != nil { + return serr + } + if changed { + setDoc[playletthememod.FN.PlayletTheme.Slug] = slug + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + if err := playletthememod.UpdateOnePlayletTheme(ctx, + bson.M{playletthememod.FN.PlayletTheme.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + return nil +} + +// DelPlayletTheme 删除题材,被短剧引用时禁止删除 +func DelPlayletTheme(ctx context.Context, id int64) *srvdef.SrvErr { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "题材ID无效", "") + } + usedCount, err := playletmod.CountPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.ThemeIds: bson.M{"$in": []int64{id}}, + }) + if err != nil { + return err + } + if usedCount > 0 { + return srvdef.New(srvdef.ErrParamError, "题材已被短剧引用,无法删除", "") + } + count, err := playletthememod.DelOnePlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: id}) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + clearAppPlayletHomeCache() + return nil +} diff --git a/web/service/srv_questionnaire/define.go b/web/service/srv_questionnaire/define.go new file mode 100644 index 0000000..d4b82a4 --- /dev/null +++ b/web/service/srv_questionnaire/define.go @@ -0,0 +1,28 @@ +package srv_questionnaire + +import ( + "videoweb/pub/models/questionnairemod" + "videoweb/pub/pubdef" +) + +// ListReq 查询问卷列表请求 +type ListReq struct { + pubdef.Page + QuestionType *string `json:"questionType"` // 模糊匹配:问题类型(选填) + Status *string `json:"status"` // 精确匹配:处理状态(选填) + StartTime *int64 `json:"startTime"` // 创建时间范围-开始,Unix 时间戳(秒,选填) + EndTime *int64 `json:"endTime"` // 创建时间范围-结束,Unix 时间戳(秒,选填) +} + +// ListResp 查询问卷列表响应 +type ListResp struct { + Total int64 `json:"total"` // 满足条件的总条数 + List []*questionnairemod.QuestionnaireModel `json:"list"` // 当前页数据 +} + +// HandleReq 处理问卷请求 +type HandleReq struct { + Id string `json:"id" binding:"required"` // 问卷ID(ObjectID 字符串) + Status string `json:"status" binding:"required"` // 目标状态:processed 已处理 / reject 拒绝 + Remark string `json:"remark"` // 备注(选填) +} diff --git a/web/service/srv_questionnaire/questionnaire.go b/web/service/srv_questionnaire/questionnaire.go new file mode 100644 index 0000000..ddbc18b --- /dev/null +++ b/web/service/srv_questionnaire/questionnaire.go @@ -0,0 +1,97 @@ +package srv_questionnaire + +import ( + "context" + "time" + + "videoweb/pub/common/srvdef" + "videoweb/pub/models/questionnairemod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// List 查询问卷列表(分页 + 条件筛选) +func List(ctx context.Context, req *ListReq) (*ListResp, *srvdef.SrvErr) { + if req.PageNum < 1 { + req.PageNum = 1 + } + if req.PageSize < 1 { + req.PageSize = 20 + } + + filter := bson.M{} + if req.QuestionType != nil && *req.QuestionType != "" { + filter[questionnairemod.FN.Questionnaire.QuestionType] = bson.M{ + "$regex": *req.QuestionType, + } + } + if req.Status != nil && *req.Status != "" { + filter[questionnairemod.FN.Questionnaire.Status] = *req.Status + } + if req.StartTime != nil || req.EndTime != nil { + timeRange := bson.M{} + if req.StartTime != nil { + timeRange["$gte"] = time.Unix(*req.StartTime, 0) + } + if req.EndTime != nil { + timeRange["$lte"] = time.Unix(*req.EndTime, 0) + } + filter[questionnairemod.FN.Questionnaire.CreatedAt] = timeRange + } + + total, serr := questionnairemod.Count(ctx, filter) + if serr != nil { + return nil, serr + } + if total == 0 { + return &ListResp{Total: 0, List: make([]*questionnairemod.QuestionnaireModel, 0)}, nil + } + + opt := options.Find(). + SetSort(bson.D{{Key: questionnairemod.FN.Questionnaire.CreatedAt, Value: -1}}). + SetSkip((req.PageNum - 1) * req.PageSize). + SetLimit(req.PageSize) + + list, serr := questionnairemod.FindMany(ctx, filter, opt) + if serr != nil { + return nil, serr + } + + return &ListResp{Total: total, List: list}, nil +} + +// Handle 处理问卷(仅允许"待处理"状态的问卷被更新) +func Handle(ctx context.Context, req *HandleReq) *srvdef.SrvErr { + objId, err := primitive.ObjectIDFromHex(req.Id) + if err != nil { + return srvdef.New(srvdef.ErrParamError, "问卷ID格式错误", "") + } + + filter := bson.M{ + questionnairemod.FN.Questionnaire.Id: objId, + questionnairemod.FN.Questionnaire.Status: questionnairemod.StatusPending, + } + update := bson.M{ + "$set": bson.M{ + questionnairemod.FN.Questionnaire.Status: req.Status, + questionnairemod.FN.Questionnaire.Remark: req.Remark, + questionnairemod.FN.Questionnaire.UpdatedAt: time.Now(), + }, + } + + matched, serr := questionnairemod.UpdateOne(ctx, filter, update) + if serr != nil { + return serr + } + if matched == 0 { + // 区分"不存在"与"状态不是待处理" + _, serr2 := questionnairemod.FindOne(ctx, bson.M{questionnairemod.FN.Questionnaire.Id: objId}) + if serr2 != nil { + return srvdef.New(srvdef.ErrNoData, "问卷不存在", "") + } + return srvdef.New(srvdef.Err, "该问卷已处理,不可重复操作", "该问卷已处理,不可重复操作") + } + return nil +} diff --git a/web/service/srv_report/report.go b/web/service/srv_report/report.go new file mode 100644 index 0000000..e53813c --- /dev/null +++ b/web/service/srv_report/report.go @@ -0,0 +1,75 @@ +package srv_report + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + "videoweb/pub/dataReport" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +// ReportVideoStatusChangeEvent 视频状态变更事件上报 +func ReportVideoStatusChangeEvent(media *videoinformationmod.VideoInformation, flag, userId int64) { + if media == nil || media.Id == 0 || flag == 0 { + return + } + + var lsjId 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 + } + } + uaInfo := useragent.UA{SysType: pubdef.ST_PC} + event := dataReport.NewCommonField(dataReport.EventTypeVideoStatusChange, "", uaInfo, strconv.FormatInt(userId, 10), userId) + event.Payload = dataReport.JsonRawMessage(&dataReport.VideoStatusChangeEvent{ + MediaId: lsjId, + VideoID: fmt.Sprintf("%v", media.Id), + VideoTitle: media.Title, + VideoTypeID: videoTypeID, + VideoTypeName: videoTypeName, + Flag: dataReport.JsonNumber(flag), + }) + b, _ := json.Marshal(event) + if err := eventmod.InsertOneEvent(context.Background(), &eventmod.Event{ + EventId: event.EventID, + EventType: event.Event, + Data: string(b), + Status: eventmod.Default, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }); err != nil { + log.Error("ReportVideoStatusChangeEvent fail", log.Any("mediaId", media.Id), log.E(err)) + } +} + +// VideoStatusFlag 将媒体状态映射为上报flag +func VideoStatusFlag(status pubdef.InformationStatus) int64 { + switch status { + case pubdef.InformationStatusVerifyOK: + return 1 // 上架 + case pubdef.InformationStatusVerifyCancel: + return 2 // 未通过 + default: + return 0 + } +} diff --git a/web/service/srv_richtextcontent/define.go b/web/service/srv_richtextcontent/define.go new file mode 100644 index 0000000..04cc8f8 --- /dev/null +++ b/web/service/srv_richtextcontent/define.go @@ -0,0 +1,38 @@ +package srv_richtextcontent + +import ( + "time" + "videoweb/pub/models/richtextcontentmod" + "videoweb/pub/pubdef" +) + +type GetRichTextContentListReq struct { + Title *string `json:"title" bson:"title"` // 标题 + Status *bool `json:"status" bson:"status"` // 是否启用 + pubdef.Page +} + +type GetRichTextContentResp struct { + Total int64 `json:"total" bson:"total"` + List richtextcontentmod.RichTextContentModelList `json:"list" bson:"list"` +} + +type DelRichTextContentReq struct { + Id int64 `bson:"_id,omitempty"` +} + +type UpdateRichTextContentData struct { + Id int64 `bson:"_id,omitempty" json:"_id,omitempty"` + Title *string `json:"title" bson:"title,omitempty"` // 标题 + Content *string `json:"content" bson:"content,omitempty"` // 内容 + Location *[]string `json:"location" bson:"location,omitempty"` // 位置 + Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序值 + Status *bool `json:"status" bson:"status,omitempty"` // 是否启用 + Desc *string `json:"desc" bson:"desc,omitempty"` // 标题(app) + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` +} +type UpdateRichTextContentReq struct { + Id int64 `json:"id" binding:"required"` + Param UpdateRichTextContentData `json:"param" bson:"param"` // appId,唯一id +} diff --git a/web/service/srv_richtextcontent/richtextcontent.go b/web/service/srv_richtextcontent/richtextcontent.go new file mode 100644 index 0000000..4658a53 --- /dev/null +++ b/web/service/srv_richtextcontent/richtextcontent.go @@ -0,0 +1,71 @@ +package srv_richtextcontent + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/richtextcontentmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func GetRichTextContentList(ctx context.Context, req *GetRichTextContentListReq) (*GetRichTextContentResp, *srvdef.SrvErr) { + filter := bson.M{} + if req.Title != nil { + filter[richtextcontentmod.FN.RichTextContentModel.Title] = bson.M{"$regex": *req.Title, "$options": "i"} + } + if req.Status != nil { + filter[richtextcontentmod.FN.RichTextContentModel.Status] = *req.Status + } + total, err := richtextcontentmod.CountRichTextContentModelModels(ctx, filter) + if err != nil { + return nil, err + } + resp := &GetRichTextContentResp{} + if total == 0 { + return resp, nil + } + opts := &options.FindOptions{} + opts.SetSort(bson.M{richtextcontentmod.FN.RichTextContentModel.UpdatedAt: -1}) + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + list, err := richtextcontentmod.FindManyRichTextContentModelModel(ctx, filter, opts) + if err != nil { + return resp, err + } + resp.Total = total + resp.List = list + return resp, nil +} + +func AddRichTextContent(ctx context.Context, ac *richtextcontentmod.RichTextContentModel) *srvdef.SrvErr { + ac.Id = mongos.Lid.HtmlText.Get() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + return richtextcontentmod.InsertOneRichTextContentModelModel(ctx, ac) +} + +func UpdateRichTextContent(ctx context.Context, id int64, param *UpdateRichTextContentData) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + adFilter := bson.M{richtextcontentmod.FN.RichTextContentModel.Id: id} + updateDoc := bson.M{"$set": param} + serr := richtextcontentmod.UpdateOneRichTextContentModelModel(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + + return nil +} + +func DelRichTextContent(ctx context.Context, req *DelRichTextContentReq) *srvdef.SrvErr { + _, err := richtextcontentmod.DelOneRichTextContentModelModel(ctx, bson.M{ + richtextcontentmod.FN.RichTextContentModel.Id: req.Id, + }) + return err +} diff --git a/web/service/srv_search/content_tag.go b/web/service/srv_search/content_tag.go new file mode 100644 index 0000000..6e95011 --- /dev/null +++ b/web/service/srv_search/content_tag.go @@ -0,0 +1,13 @@ +package srv_search + +import "strings" + +func contentTagNamesString(tagIds []int64, tagMap map[int64]string) string { + names := make([]string, 0, len(tagIds)) + for _, tagId := range tagIds { + if name := tagMap[tagId]; name != "" { + names = append(names, name) + } + } + return strings.Join(names, ",") +} diff --git a/web/service/srv_search/define.go b/web/service/srv_search/define.go new file mode 100644 index 0000000..2f24b25 --- /dev/null +++ b/web/service/srv_search/define.go @@ -0,0 +1,19 @@ +package srv_search + +import ( + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" +) + +// Param 引擎参数 +type Param struct { + //数据库 + Coll *mongos.Collection + //过滤器 + CollFilter bson.M + //跟新字段 + CollFields []string + //es索引 + EsIndex string +} \ No newline at end of file diff --git a/web/service/srv_search/es.go b/web/service/srv_search/es.go new file mode 100644 index 0000000..41b1e67 --- /dev/null +++ b/web/service/srv_search/es.go @@ -0,0 +1,187 @@ +package srv_search + +import ( + "context" + "errors" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/esmanager" + "videoweb/pub/models/esmod" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +var syncIsRuining = false + +func StartES() { + log.Info("srv_search:Start...") + defer log.Info("srv_search:Start!!!") + startSyncEs() + _, _ = skdCron.AddFunc("0 0/17 * * * ?", func() { + startSyncEs() + }) + skdCron.Start() +} + +func startSyncEs() { + ctx := context.Background() + updateEsData(ctx, esmod.GetVideoIndex(), getVideoEsData) + updateEsData(ctx, esmod.GetPlayletIndex(), getPlayletEsData) + updateEsData(ctx, esmod.GetActressIndex(), getActressEsData) +} + +func StopES() { + log.Info("srv_search:Stop...") + defer log.Info("srv_search:Stop!!!") +} + +func updateEsData(ctx context.Context, esIndex string, fn func(ctx context.Context, startTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr)) { + log.Info("srv_search:do...", log.Any("ESIndex", esIndex)) + defer log.Info("srv_search:do end", log.Any("EsIndex", esIndex)) + if fn == nil { + return + } + esRecord, serr := esmod.GetRecordTimeByESIndex(ctx, esIndex) + if serr != nil { + log.Error(fmt.Sprintf("srv_search:startEngine GetRecordByESIndex failed! Index:%v,err:%+v:", esIndex, serr)) + return + } + isNew := false + if esRecord == nil { + isNew = true + esRecord = &esmod.ESRecord{ + Id: primitive.NewObjectID(), + EsIndex: esIndex, + RecordTime: time.Time{}, + Timestamp: esmod.ESTimestamp{}, + UpdatedAt: time.Time{}, + CreatedAt: time.Now(), + } + } + var now = time.Now() + var esData esmanager.M + // 跨天凌晨5点重置所有数据 + if isNew || ((now.Hour() >= 5 && esRecord.UpdatedAt.Local().Hour() < 5) || esRecord.UpdatedAt.Local().Day() < now.Day()) { + log.Info("srv_search:updateEsData 全量同步", log.Any("index", esIndex), log.Any("isNew", isNew)) + esData, serr = fn(ctx, nil, nil, esIndex) + if serr != nil { + log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr)) + return + } + log.Info("srv_search:updateEsData 全量数据拉取完成", log.Any("index", esIndex), log.Any("count", len(esData))) + if len(esData) == 0 { + if err := esmod.SetOneEsRecordTime(nil, esIndex, now); err != nil { + return + } + return + } + isUpdate, err := commit(esData, esIndex, time.Now()) + if err != nil { + log.Error("Elastic sync video data RunSync commit err! ", log.E(err)) + return + } + log.Info("srv_search:updateEsData 全量写入ES完成", log.Any("index", esIndex), log.Any("isUpdate", isUpdate)) + if isUpdate { + if err := esmod.SetOneEsRecordTime(nil, esIndex, now); err != nil { + return + } + } + } else { + timestamp := esRecord.RecordTime + var recentMinute = utils.RecentMinute(now, esmod.FiveMinuteScale) + log.Info("srv_search:updateEsData 增量同步", log.Any("index", esIndex), + log.Any("from", timestamp.Format("2006-01-02 15:04:05")), + log.Any("to", recentMinute.Format("2006-01-02 15:04:05"))) + totalIncr := 0 + for recentMinute.After(timestamp) { + var end = timestamp.Add(esmod.FiveMinuteScale * time.Minute) + if end.After(recentMinute) { + end = recentMinute + } + esData, serr = fn(ctx, ×tamp, &end, esIndex) + if serr != nil { + log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr)) + return + } + if esData != nil && len(esData) > 0 { + totalIncr += len(esData) + log.Info("srv_search:updateEsData 增量批次", + log.Any("index", esIndex), + log.Any("window_start", timestamp.Format("2006-01-02 15:04:05")), + log.Any("window_end", end.Format("2006-01-02 15:04:05")), + log.Any("count", len(esData))) + isUpdate, err := commit(esData, esIndex, time.Now()) + if err != nil { + log.Error("Elastic sync video data RunSync commit err! ", log.E(err)) + return + } + if isUpdate { + if err := esmod.SetOneEsRecordTime(nil, esIndex, end); err != nil { + return + } + } + } else { + if err := esmod.SetOneEsRecordTime(nil, esIndex, end); err != nil { + return + } + } + timestamp = end + } + log.Info("srv_search:updateEsData 增量同步完成", log.Any("index", esIndex), log.Any("totalCount", totalIncr)) + } +} + +func commit(data esmanager.M, esIndex string, syncTime time.Time) (bool, error) { + bulkData := handleBulkCountLimit(data) + isHaveSuccess := false + failedBatches := make([]int, 0) + log.Info("srv_search:commit 开始写入ES", log.Any("index", esIndex), + log.Any("totalDocs", len(data)), log.Any("batches", len(bulkData))) + if bulkData != nil && len(bulkData) > 0 { + for i, doc := range bulkData { + if len(doc) == 0 { + continue + } + if err := esmanager.Instance.Bulk(esIndex, doc); err != nil { + log.Warn(fmt.Sprintf("Elastic sync commit video data SyncDataToES failed!, err:%+v\n", err), + log.Any("index", esIndex), log.Any("batch", i), log.Any("batchSize", len(doc))) + failedBatches = append(failedBatches, i) + } else { + isHaveSuccess = true + log.Info("srv_search:commit 批次写入成功", log.Any("index", esIndex), + log.Any("batch", i), log.Any("batchSize", len(doc))) + } + } + } + if len(failedBatches) > 0 { + return isHaveSuccess, fmt.Errorf("elastic sync commit failed batches: %v", failedBatches) + } + if isHaveSuccess { + if err := esmanager.Instance.Refresh(esIndex); err != nil { + return isHaveSuccess, err + } + return isHaveSuccess, nil + } else { + return isHaveSuccess, errors.New("Elastic sync commit video data UpsertOneTrans failed!") + } +} + +func handleBulkCountLimit(data esmanager.M) []esmanager.M { + if data == nil || len(data) == 0 { + return make([]esmanager.M, 0) + } + rsl := make([]esmanager.M, 0) + rsl = append(rsl, make(esmanager.M, batchSize)) + index := 0 + for k, v := range data { + rsl[index][k] = v + if len(rsl[index]) >= batchSize { + index++ + rsl = append(rsl, make(esmanager.M, batchSize)) + } + } + return rsl +} diff --git a/web/service/srv_search/es_playlet.go b/web/service/srv_search/es_playlet.go new file mode 100644 index 0000000..5f3278c --- /dev/null +++ b/web/service/srv_search/es_playlet.go @@ -0,0 +1,378 @@ +package srv_search + +import ( + "context" + "strconv" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/esmod" + "videoweb/pub/models/playletcategorymod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletthememod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const playletESPageSize int64 = 1000 + +var playletESProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + {Key: playletmod.FN.Playlet.IsFeatured, Value: 1}, + {Key: playletmod.FN.Playlet.IsRecommend, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1}, + {Key: playletmod.FN.Playlet.Banner, Value: 1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: 1}, + {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.Desc, Value: 1}, + {Key: playletmod.FN.Playlet.Anchor, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.TagIds, Value: 1}, + {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, + {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, + {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualHeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, + {Key: playletmod.FN.Playlet.CountLike, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Title, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Description, Value: 1}, + {Key: playletmod.FN.Playlet.SeoInfo.Keyworld, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, +} + +// getPlayletEsData 查询短剧增量数据并转换为ES批量写入数据 +func getPlayletEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) { + list, err1 := getPlayletData(ctx, minTime, endTime) + if err1 != nil { + return nil, err1 + } + return playletToEsData(ctx, list) +} + +// SyncPlayletsToESByLatestStatus 根据短剧最新上下架状态同步ES搜索数据 +func SyncPlayletsToESByLatestStatus(ctx context.Context, ids []int64) *srvdef.SrvErr { + ids = dedupePlayletESIds(ids) + if len(ids) == 0 { + return nil + } + list, err1 := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + })) + if err1 != nil { + return err1 + } + foundIds := make(map[int64]struct{}, len(list)) + upIds := make([]int64, 0, len(list)) + downIds := make([]int64, 0, len(ids)) + for _, item := range list { + if item == nil { + continue + } + foundIds[item.Id] = struct{}{} + if item.Status == playletmod.StatusUp { + upIds = append(upIds, item.Id) + } else { + downIds = append(downIds, item.Id) + } + } + for _, id := range ids { + if _, ok := foundIds[id]; !ok { + downIds = append(downIds, id) + } + } + if err1 := SyncPlayletStatusToES(ctx, upIds, playletmod.StatusUp); err1 != nil { + return err1 + } + if err1 := SyncPlayletStatusToES(ctx, downIds, playletmod.StatusDown); err1 != nil { + return err1 + } + return nil +} + +// SyncPlayletStatusToES 根据短剧上下架状态同步ES搜索数据 +func SyncPlayletStatusToES(ctx context.Context, ids []int64, status playletmod.Status) *srvdef.SrvErr { + ids = dedupePlayletESIds(ids) + if len(ids) == 0 { + return nil + } + if esmanager.Instance == nil { + log.Error("短剧ES同步失败,ES未初始化", log.Any("短剧ID", ids), log.Any("状态", status)) + return srvdef.New(srvdef.ErrElsOperError, "", "") + } + switch status { + case playletmod.StatusUp: + return upsertPlayletsToES(ctx, ids) + case playletmod.StatusDown: + return deletePlayletsFromES(ids) + default: + return nil + } +} + +func getPlayletData(ctx context.Context, minTime, endTime *time.Time) (playletmod.PlayletList, *srvdef.SrvErr) { + pageNum := int64(1) + result := make(playletmod.PlayletList, 0) + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + if minTime != nil && endTime != nil { + filter[playletmod.FN.Playlet.UpdatedAt] = bson.M{ + "$gte": minTime, + "$lte": endTime, + } + } + for { + list, err1 := playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetProjection(playletESProjection). + SetSort(bson.D{{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}}). + SetSkip((pageNum-1)*playletESPageSize). + SetLimit(playletESPageSize)) + if err1 != nil { + return nil, err1 + } + result = append(result, list...) + if int64(len(list)) < playletESPageSize { + break + } + pageNum++ + } + return result, nil +} + +func playletToEsData(ctx context.Context, list playletmod.PlayletList) (esmanager.M, *srvdef.SrvErr) { + categoryNameMap, err1 := buildPlayletESCategoryNameMap(ctx, list) + if err1 != nil { + return nil, err1 + } + themeNameMap, err1 := buildPlayletESThemeNameMap(ctx, list) + if err1 != nil { + return nil, err1 + } + tagNameMap, err1 := buildPlayletESTagNameMap(ctx, list) + if err1 != nil { + return nil, err1 + } + result := make(esmanager.M, len(list)) + for _, item := range list { + if item == nil { + continue + } + result[strconv.FormatInt(item.Id, 10)] = playletToESDoc(item, categoryNameMap, themeNameMap, tagNameMap) + } + return result, nil +} + +// upsertPlayletsToES 批量写入已上架短剧ES文档 +func upsertPlayletsToES(ctx context.Context, ids []int64) *srvdef.SrvErr { + list, err1 := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.Find().SetProjection(playletESProjection)) + if err1 != nil { + return err1 + } + if len(list) == 0 { + return nil + } + esData, err1 := playletToEsData(ctx, list) + if err1 != nil { + return err1 + } + if len(esData) == 0 { + return nil + } + if err1 = AddMultiDataToES(esmod.GetPlayletIndex(), esData); err1 != nil { + log.Error("批量写入短剧ES失败", log.Any("短剧ID", ids), log.E(err1)) + return err1 + } + log.Info("批量写入短剧ES成功", log.Any("数量", len(esData)), log.Any("短剧ID", ids)) + return nil +} + +// deletePlayletsFromES 批量删除短剧ES文档 +func deletePlayletsFromES(ids []int64) *srvdef.SrvErr { + strIds := make([]string, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + strIds = append(strIds, strconv.FormatInt(id, 10)) + } + if len(strIds) == 0 { + return nil + } + if err1 := batchDelFromES(esmod.GetPlayletIndex(), strIds); err1 != nil { + log.Error("批量删除短剧ES失败", log.Any("短剧ID", ids), log.E(err1)) + return err1 + } + log.Info("批量删除短剧ES成功", log.Any("数量", len(strIds)), log.Any("短剧ID", ids)) + return nil +} + +// playletToESDoc 转换短剧Mongo数据为ES文档 +func playletToESDoc(item *playletmod.Playlet, categoryNameMap map[int64]string, themeNameMap map[int64]string, tagNameMap map[int64]string) *esmod.ESPlaylet { + return &esmod.ESPlaylet{ + Id: item.Id, + Status: item.Status, + IsFeatured: item.IsFeatured, + IsRecommend: item.IsRecommend, + ShowOnBanner: item.ShowOnBanner, + Banner: item.Banner, + OnlineAt: item.OnlineAt, + CategoryIds: item.CategoryIds, + CategoryNames: playletESNamesByIds(item.CategoryIds, categoryNameMap), + ThemeIds: item.ThemeIds, + ThemeNames: playletESNamesByIds(item.ThemeIds, themeNameMap), + Title: item.Title, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: item.DisplayCover(), + TagIds: item.TagIds, + TagNames: playletESNamesByIds(item.TagIds, tagNameMap), + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + CountBrowse: item.DisplayCountBrowse(), + HeatValue: item.DisplayHeatValue(), + CountCollect: item.CountCollect, + CountLike: item.CountLike + item.VirtualCountLike, + SeoInfo: item.SeoInfo, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// buildPlayletESCategoryNameMap 批量查询短剧分类名称 +func buildPlayletESCategoryNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + ids := collectPlayletESIds(list, func(item *playletmod.Playlet) []int64 { + return item.CategoryIds + }) + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result, nil + } + categories, err1 := playletcategorymod.FindManyPlayletCategory(ctx, bson.M{ + playletcategorymod.FN.PlayletCategory.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.D{ + {Key: playletcategorymod.FN.PlayletCategory.Id, Value: 1}, + {Key: playletcategorymod.FN.PlayletCategory.Name, Value: 1}, + })) + if err1 != nil { + return nil, err1 + } + for _, category := range categories { + if category != nil { + result[category.Id] = category.Name + } + } + return result, nil +} + +// buildPlayletESThemeNameMap 批量查询短剧题材名称 +func buildPlayletESThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + ids := collectPlayletESIds(list, func(item *playletmod.Playlet) []int64 { + return item.ThemeIds + }) + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result, nil + } + themes, err1 := 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 err1 != nil { + return nil, err1 + } + for _, theme := range themes { + if theme != nil { + result[theme.Id] = theme.Name + } + } + return result, nil +} + +// buildPlayletESTagNameMap 批量查询短剧标签名称 +func buildPlayletESTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + ids := collectPlayletESIds(list, func(item *playletmod.Playlet) []int64 { + return item.TagIds + }) + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result, nil + } + tags, err1 := contenttagmod.GetMapTags(ctx, ids) + if err1 != nil { + return nil, err1 + } + for _, tag := range tags { + if tag != nil { + result[tag.Id] = tag.Name + } + } + return result, nil +} + +// collectPlayletESIds 汇总并去重短剧关联ID +func collectPlayletESIds(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 dedupePlayletESIds(ids []int64) []int64 { + result := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, 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 +} + +// playletESNamesByIds 按ID顺序返回ES文档名称集合 +func playletESNamesByIds(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 +} diff --git a/web/service/srv_search/esactress.go b/web/service/srv_search/esactress.go new file mode 100644 index 0000000..e8cbb21 --- /dev/null +++ b/web/service/srv_search/esactress.go @@ -0,0 +1,77 @@ +package srv_search + +import ( + "context" + "strconv" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/actress" + "videoweb/pub/models/esmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func getActressEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) { + data, serr := getActressData(ctx, minTime, endTime) + if serr != nil { + return nil, serr + } + return actressToEsData(data), nil +} +func getActressData(ctx context.Context, minTime, endTime *time.Time) ([]*actress.Actress, *srvdef.SrvErr) { + pageNumber := 1 + pageSize := 1000 + rsl := make([]*actress.Actress, 0) + filter := bson.M{} + if minTime != nil && endTime != nil { + filter[actress.FN.Actress.UpdatedAt] = bson.M{ + "$gte": minTime, + "$lte": endTime, + } + } + for { + opts := &options.FindOptions{} + opts.SetSort(bson.M{actress.FN.Actress.UpdatedAt: -1}) + opts.SetSkip(int64(pageSize * (pageNumber - 1))) + opts.SetLimit(int64(pageSize)) + data, serr := actress.FindManyActresss(ctx, filter, opts) + if serr != nil { + return nil, serr + } + rsl = append(rsl, data...) + if len(data) < pageSize { + break + } + pageNumber++ + } + return rsl, nil +} +func actressToEsData(data []*actress.Actress) esmanager.M { + rsl := make(map[string]interface{}, len(data)) + for _, v := range data { + esInfo := esmod.ESActress{ + Id: v.Id, + Alias: v.Alias, + Avatar: v.Avatar, + Meas: v.Meas, + Desc: v.Desc, + Office: v.Office, + DebutDate: v.DebutDate, + BornDate: v.BornDate, + Region: v.Region, + Score: v.Score, + Works: v.Works, + HotValue: v.HotValue, + Real: v.Real, + Status: v.Status, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + TranslationCategoryId: v.TranslationCategoryId, + Rank: v.Rank, + } + rsl[strconv.FormatInt(v.Id, 10)] = esInfo + } + return rsl +} diff --git a/web/service/srv_search/esmedia.go b/web/service/srv_search/esmedia.go new file mode 100644 index 0000000..69db9c0 --- /dev/null +++ b/web/service/srv_search/esmedia.go @@ -0,0 +1,92 @@ +package srv_search + +import ( + "context" + "strconv" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/esmod" + "videoweb/pub/models/videoinformationmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func getVideoEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) { + data, serr := getVideoData(ctx, minTime, endTime) + if serr != nil { + return nil, serr + } + return videoToEsData(data), nil +} +func getVideoData(ctx context.Context, minTime, endTime *time.Time) ([]*videoinformationmod.VideoInformation, *srvdef.SrvErr) { + pageNumber := 1 + pageSize := 1000 + rsl := make([]*videoinformationmod.VideoInformation, 0) + filter := bson.M{} + if minTime != nil && endTime != nil { + filter[videoinformationmod.FN.VideoInformation.UpdatedAt] = bson.M{ + "$gte": minTime, + "$lte": endTime, + } + } + for { + opts := &options.FindOptions{} + opts.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1}) + opts.SetSkip(int64(pageSize * (pageNumber - 1))) + opts.SetLimit(int64(pageSize)) + data, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if serr != nil { + return nil, serr + } + rsl = append(rsl, data...) + if len(data) < pageSize { + break + } + pageNumber++ + } + return rsl, nil +} +func videoToEsData(data []*videoinformationmod.VideoInformation) esmanager.M { + rsl := make(map[string]interface{}, len(data)) + for _, v := range data { + esInfo := esmod.ESVideo{ + Id: v.Id, + Title: v.Title, + MediaType: v.MediaType, + AddedTime: v.AddedTime, + CreatorName: v.CreatorName, + Keyworlds: "", + Desc: v.Desc, + Remark: v.Remark, + Status: v.Status, + IsHot: v.IsHot, + Sort: v.Sort, + WatchTimes: v.Real.WatchTimes, + ShowTimes: v.Real.ShowTimes, + Comments: v.Real.Comments, + UpdatedAt: v.UpdatedAt, + CreatedAt: v.CreatedAt, + Keyworld: v.Keyworld, + CreatorId: v.CreatorId, + FirstTag: v.FirstTag, + SecondTags: v.SecondTags, + CoverImg: v.CoverImg, + Nodes: v.Nodes, + Video: v.Video, + Tags: v.Tags, + Real: v.Real, + VideoCount: v.VideoCount, + IssuedBrand: v.IssuedBrand, + Bango: v.Bango, + IssuedAt: v.IssuedAt, + } + if v.Keyworld != nil && len(v.Keyworld) > 0 { + esInfo.Keyworlds = strings.Join(v.Keyworld, " ") + } + rsl[strconv.FormatInt(v.Id, 10)] = esInfo + } + return rsl +} diff --git a/web/service/srv_search/search.go b/web/service/srv_search/search.go new file mode 100644 index 0000000..0a95247 --- /dev/null +++ b/web/service/srv_search/search.go @@ -0,0 +1,27 @@ +package srv_search + +import ( + "github.com/robfig/cron/v3" + "videoweb/pub/common/log" +) + +var ( + skdCron *cron.Cron + batchSize int = 100 + opMaxDiff int32 = 10 +) + +func Start() { + skdCron = cron.New(cron.WithSeconds()) + log.Info("srv_search:Start...") + defer log.Info("srv_search:Start!!!") + go func() { + StartES() + }() +} + +func Stop() { + log.Info("srv_search:Stop...") + defer log.Info("srv_search:Stop!!!") + StopES() +} \ No newline at end of file diff --git a/web/service/srv_search/updater.go b/web/service/srv_search/updater.go new file mode 100644 index 0000000..193a113 --- /dev/null +++ b/web/service/srv_search/updater.go @@ -0,0 +1,229 @@ +package srv_search + +import ( + "context" + "fmt" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "strconv" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/esmanager" + "videoweb/pub/models/esmod" + "videoweb/pub/mongos" +) + +// resetAllDocsToES 重设所有文档到数据库 +func resetAllDocsToES(ctx context.Context, param *Param) *srvdef.SrvErr { + log.Info("srv_search:resetAllDocsToES...", log.Any("ESIndex", param.EsIndex)) + defer log.Info("srv_search:startActress!!!") + //删除所有记录 + delAllFromES(param.EsIndex) + + var pageNum int64 = 1 + var pageSize int64 = 200 + updSize := 0 + for { + findOpt := constructFindOption(pageNum, pageSize) + docs, serr := findManyFromDb(ctx, param, findOpt) + if serr != nil { + return serr + } + if len(docs) == 0 { + break + } + updSize = updSize + len(docs) + updateESData, serr := sliceMapToJsonMap(docs, param) + log.Info("srv_search:resetAllDocsToES", log.Any("updateESData", updateESData)) + if serr = batchAddToES(param.EsIndex, updateESData); serr != nil { + return serr + } + pageNum++ + log.Info("resetAllToES>>>>>>>>>>>>>>>>>>", log.Any("updateSize:", updSize)) + } + if updSize > 0 { + if err := esmanager.Instance.Refresh(param.EsIndex); err != nil { + log.Warn("srv_search:resetAllDocsToES Refresh failed", log.Any("ESIndex", param.EsIndex), log.E(err)) + return srvdef.New(srvdef.ErrElsOperError, "", "") + } + } + return nil +} + +// constructMatch 构建match +func constructMatch(param *Param) bson.M { + updateFilter := bson.A{} + for _, fieldName := range param.CollFields { + filter := bson.M{"updateDescription.updatedFields." + fieldName: bson.M{"$exists": true}} + updateFilter = append(updateFilter, filter) + } + match := bson.M{"$or": bson.A{ + bson.M{"operationType": bson.M{"$in": []string{"delete", "insert", "replace"}}}, + bson.M{"$and": bson.A{ + bson.M{"operationType": bson.M{"$in": []string{"update"}}}, + bson.M{"$or": updateFilter}}, + }}} + return match +} + +// constructActtressOption 查找选项 +func constructFindOption(pageNum int64, pageSize int64) *options.FindOptions { + opt := &options.FindOptions{} + opt.SetSort(bson.M{"createdAt": -1}) + skip := (pageNum - 1) * pageSize + opt.SetSkip(skip) + opt.SetLimit(pageSize) + return opt +} + +// AddMultiDataToES 添加一批Ex +func AddMultiDataToES(esIndex string, esvs esmanager.M) *srvdef.SrvErr { + if err := esmanager.Instance.Bulk(esIndex, esvs); err != nil { + info := fmt.Sprintf("(srv_search:AddMultiDataToES) ES Bulk failed! err:%+v:", err) + log.Warn(info) + return srvdef.New(srvdef.ErrElsOperError, "", "") + } + return nil +} + +// findManyFromDb 查找一批数据 +func findManyFromDb(ctx context.Context, param *Param, + opts ...*options.FindOptions) ([]bson.M, *srvdef.SrvErr) { + docs := make([]bson.M, 0) + if err := param.Coll.Find(ctx, &docs, param.CollFilter, opts...); err != nil { + log.Warn("srv_search:findManyFromDb db.Find failed", log.E(err), log.Any("filter", param.CollFilter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return docs, nil +} + +// sliceMapToJsonMap 从map切片构建Map对象 +func sliceMapToJsonMap(docs []bson.M, param *Param) (esmanager.M, *srvdef.SrvErr) { + esMap := make(esmanager.M) + for _, doc := range docs { + fieldMap := make(esmanager.M) + for key, value := range doc { + if utils.IncludeString(param.CollFields, key) { + fieldMap[key] = value + } else if key == "_id" { + fieldMap["id"] = value + } + } + if id, ok := doc["_id"].(int64); ok { + strId := strconv.FormatInt(id, 10) + esMap[strId] = fieldMap + } else { + log.Warn("srv_search:sliceMapToJsonMap Key doesn't exist", log.Any("keyId", "_id")) + } + } + return esMap, nil +} + +// getInfoFromChangeData 从改变流中获取数据信息 +func getInfoFromChangeData(changeData bson.M, param *Param) ( + string, *esmod.ESTimestamp, string, esmanager.M, *srvdef.SrvErr) { + operationType, ok := changeData["operationType"].(string) + if ok == false { + log.Error("srv_search:getInfoFromChangeData operationType type error") + return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "") + } + ptimestamp, ok := changeData["clusterTime"].(primitive.Timestamp) + if ok == false { + log.Error("srv_search:getInfoFromChangeData clusterTime type error") + return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "") + } + estimestamp := &esmod.ESTimestamp{T: ptimestamp.T, I: ptimestamp.I} + documentKey, ok := changeData["documentKey"].(primitive.M) + if ok == false { + log.Error("srv_search:getInfoFromChangeData documentKey type error") + return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "") + } + docKey, ok := documentKey["_id"].(int64) + if ok == false { + log.Error("srv_search:getInfoFromChangeData documentKey[_id] type error") + return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "") + } + strId := strconv.FormatInt(docKey, 10) + esDocMap := make(esmanager.M) + if changeData["fullDocument"] != nil { + docMap, ok := changeData["fullDocument"].(primitive.M) + if ok == false { + log.Error("srv_search:getInfoFromChangeData fullDocument type error") + return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "") + } + + for key, value := range docMap { + if utils.IncludeString(param.CollFields, key) { + esDocMap[key] = value + } else if key == "_id" { + esDocMap["id"] = value + } + } + } + return operationType, estimestamp, strId, esDocMap, nil +} + +// delAllFromES 删除所有数据 +func delAllFromES(esIndex string) *srvdef.SrvErr { + query := esmanager.M{ + "query": esmanager.M{ + "match_all": esmanager.M{}, + }, + } + if err := esmanager.Instance.DelMany([]string{esIndex}, query); err != nil { + log.Warn(fmt.Sprintf("(srv_search:DelAllFromEs) Es DelMany failed! err:%+v:", err), + log.Any("query", query)) + return srvdef.New(srvdef.ErrElsOperError, "", "") + } + return nil +} + +// batchAddToES 添加一批数据到ES +func batchAddToES(esIndex string, esvs esmanager.M) *srvdef.SrvErr { + log.Info("srv_search:resetAllDocsToES", log.Any("esIndex", esIndex), log.Any("esvs", esvs)) + if err := esmanager.Instance.Bulk(esIndex, esvs); err != nil { + info := fmt.Sprintf("(srv_search:batchAddToES) ES Bulk failed! err:%+v:", err) + log.Warn(info) + return srvdef.New(srvdef.ErrElsOperError, "", "") + } + return nil +} + +// batchDelFromES 删除一批数据从ES +func batchDelFromES(esIndex string, ids []string) *srvdef.SrvErr { + query := esmanager.M{ + "query": esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + { + "ids": esmanager.M{ + "values": ids, + }, + }, + }, + }, + }, + } + if err := esmanager.Instance.DelMany([]string{esIndex}, query); err != nil { + info := fmt.Sprintf( + "(srv_search:batchDelFromES) Es DelMany failed! err:%+v:", err) + log.Warn(info) + return srvdef.New(srvdef.ErrElsOperError, "", "") + } + return nil +} + +// watchOpLog 监控表变化 +func watchOpLog(ctx context.Context, db *mongos.Collection, pipeline []bson.M, + opt *options.ChangeStreamOptions) (*mongo.ChangeStream, *srvdef.SrvErr) { + changeStream, err := db.WatchOpLog(ctx, pipeline, opt) + if err != nil { + log.Warn("srv_search:watchOpLog db.WatchOpLog failed", + log.E(err), log.Any("pipeline", pipeline)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return changeStream, nil +} diff --git a/web/service/srv_seopush/notify.go b/web/service/srv_seopush/notify.go new file mode 100644 index 0000000..f9a8e60 --- /dev/null +++ b/web/service/srv_seopush/notify.go @@ -0,0 +1,60 @@ +package srv_seopush + +import ( + "context" + "time" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" + "videoweb/pub/seo/sitemapdirty" +) + +// MarkSitemapDirty 通知 app 进程重新生成 sitemap。 +func MarkSitemapDirty(ctx context.Context) { + sitemapdirty.Mark(ctx) +} + +// NotifyPlayletAfterWrite 短剧写入后:刷新 sitemap 标记。 +// 搜索引擎只在新上架(含新增即上架)或从已上架变为下架时推送,普通改内容不推。 +func NotifyPlayletAfterWrite(ctx context.Context, playletId int64, prevStatus *playletmod.Status, status playletmod.Status, onlineAt time.Time) { + MarkSitemapDirty(ctx) + wasUp := prevStatus != nil && *prevStatus == playletmod.StatusUp + if status == playletmod.StatusUp { + if !wasUp { + EnqueueUpdated(ctx, KindPlaylet, playletId, PageVisiblePlaylet(status, onlineAt)) + } + return + } + if wasUp { + EnqueueDeleted(ctx, KindPlaylet, playletId) + } +} + +// NotifyPlayletAfterStatus 批量上下架后的 SEO 通知 +func NotifyPlayletAfterStatus(ctx context.Context, ids []int64, status playletmod.Status, onlineAt time.Time) { + MarkSitemapDirty(ctx) + if status == playletmod.StatusUp { + for _, id := range ids { + EnqueueUpdated(ctx, KindPlaylet, id, PageVisiblePlaylet(status, onlineAt)) + } + return + } + for _, id := range ids { + EnqueueDeleted(ctx, KindPlaylet, id) + } +} + +// NotifyInfoAfterWrite 视频写入后的 SEO 通知。 +// 搜索引擎只在新上架或从已上架变为下架时推送,普通改内容不推。 +func NotifyInfoAfterWrite(ctx context.Context, kind string, id int64, prevStatus, status pubdef.InformationStatus) { + MarkSitemapDirty(ctx) + wasUp := prevStatus == pubdef.InformationStatusVerifyOK + if PageVisibleInfo(status) { + if !wasUp { + EnqueueUpdated(ctx, kind, id, true) + } + return + } + if wasUp { + EnqueueDeleted(ctx, kind, id) + } +} diff --git a/web/service/srv_seopush/push.go b/web/service/srv_seopush/push.go new file mode 100644 index 0000000..1260ac9 --- /dev/null +++ b/web/service/srv_seopush/push.go @@ -0,0 +1,347 @@ +// Package srv_seopush 负责内容新上架/下架时向搜索引擎推送页面地址。 +// 业务回调里即时推送一次,失败的任务由 web/skd/job/seopush 兜底重试。 +package srv_seopush + +import ( + "context" + "errors" + "strings" + "time" + "videoweb/pub/common/godef" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/seopushmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + "videoweb/pub/seo/googleindex" + seoindexnow "videoweb/pub/seo/indexnow" + "videoweb/web/service/srv_cfg" + "videoweb/web/webg" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + KindVideo = "video" + KindPlaylet = "playlet" +) + +// channelFields 把两个推送通道的字段名抽象出来,共用同一套状态更新逻辑 +type channelFields struct { + name string + status string + retry string + errMsg string + at string +} + +var googleChannel = channelFields{ + name: "google", + status: seopushmod.FN.SeoPushTask.GoogleStatus, + retry: seopushmod.FN.SeoPushTask.GoogleRetry, + errMsg: seopushmod.FN.SeoPushTask.GoogleErr, + at: seopushmod.FN.SeoPushTask.GoogleAt, +} + +var indexNowChannel = channelFields{ + name: "indexnow", + status: seopushmod.FN.SeoPushTask.IndexNowStatus, + retry: seopushmod.FN.SeoPushTask.IndexNowRetry, + errMsg: seopushmod.FN.SeoPushTask.IndexNowErr, + at: seopushmod.FN.SeoPushTask.IndexNowAt, +} + +func kindPrefix(kind string) string { + switch kind { + case KindVideo: + return "/video-detail" + case KindPlaylet: + return "/drama-detail" + default: + return "" + } +} + +// DetailURL 拼装详情页完整地址,路径与 sitemap 一致:/{prefix}/{slug},无 slug 回退 ID。 +func DetailURL(webAddr, prefix, slug string, id int64) string { + seg := utils.PathSegment(slug, id) + if seg == "" { + return "" + } + return utils.JoinUrlPathKeepSlash(webAddr, strings.TrimRight(prefix, "/")+"/"+seg) +} + +// PageVisiblePlaylet 判断短剧详情页当前是否对外可访问。 +func PageVisiblePlaylet(status playletmod.Status, onlineAt time.Time) bool { + if status != playletmod.StatusUp { + return false + } + if onlineAt.IsZero() { + return true + } + return !onlineAt.After(time.Now()) +} + +// PageVisibleInfo 判断视频详情页是否已上架。 +func PageVisibleInfo(status pubdef.InformationStatus) bool { + return status == pubdef.InformationStatusVerifyOK +} + +// SEOBaseURL 取站点对外地址 +func SEOBaseURL(ctx context.Context) string { + sysCfg, serr := srv_cfg.GetSystemConfig(ctx) + if serr != nil || sysCfg == nil { + log.Warn("seopush: get system config failed", log.E(serr)) + return "" + } + base := sysCfg.SEOBaseURL() + if base == "" { + log.Warn("seopush: SEO base URL is empty, skip push") + } + return base +} + +// Enabled 业务开关是否打开(不含环境判断)。 +func Enabled() bool { + return pubdef.Enabled() +} + +func canPushExternally() bool { + return pubdef.Enabled() && webg.Conf.RunMod == "prod" +} + +// EnqueueUpdated 内容新上架后登记并立即推送,仅在页面已可访问时生效 +func EnqueueUpdated(ctx context.Context, kind string, id int64, visible bool) { + if !pubdef.Enabled() || !visible { + return + } + enqueue(ctx, kind, id, seopushmod.ActionUpdated, true) +} + +// RegisterUpdated 只登记不立即推送,供批量补推使用 +func RegisterUpdated(ctx context.Context, kind string, id int64, visible bool) { + if !pubdef.Enabled() || !visible { + return + } + enqueue(ctx, kind, id, seopushmod.ActionUpdated, false) +} + +// EnqueueDeleted 内容下架/删除后登记并立即推送 +func EnqueueDeleted(ctx context.Context, kind string, id int64) { + if !pubdef.Enabled() { + return + } + enqueue(ctx, kind, id, seopushmod.ActionDeleted, true) +} + +func enqueue(ctx context.Context, kind string, id int64, action string, pushNow bool) { + webAddr := SEOBaseURL(ctx) + if webAddr == "" { + return + } + prefix := kindPrefix(kind) + if prefix == "" { + return + } + pageURL := DetailURL(webAddr, prefix, loadSlug(ctx, kind, id), id) + if pageURL == "" { + return + } + now := time.Now() + task := &seopushmod.SeoPushTask{ + Url: pageURL, + VideoId: id, + Action: action, + CreatedAt: now, + UpdatedAt: now, + } + + if action == seopushmod.ActionDeleted { + _, _ = seopushmod.DelManySeoPushTask(ctx, bson.M{ + seopushmod.FN.SeoPushTask.Url: pageURL, + seopushmod.FN.SeoPushTask.Action: seopushmod.ActionUpdated, + }) + } + if serr := seopushmod.UpsertPendingSeoPushTask(ctx, task); serr != nil { + log.Warn("seopush: enqueue task failed", log.Any("url", pageURL), log.E(serr)) + return + } + if action == seopushmod.ActionUpdated { + markQueued(ctx, kind, id) + } + if !pushNow { + return + } + + godef.Go(func() { + PushTask(context.Background(), webAddr, task) + }) +} + +func loadSlug(ctx context.Context, kind string, id int64) string { + if id <= 0 { + return "" + } + switch kind { + case KindVideo: + item, err := videoinformationmod.FindOneVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: id}, + options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}})) + if err != nil || item == nil { + return "" + } + return strings.TrimSpace(item.Slug) + case KindPlaylet: + item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: id}, + options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}})) + if err != nil || item == nil { + return "" + } + return strings.TrimSpace(item.Slug) + default: + return "" + } +} + +func markQueued(ctx context.Context, kind string, id int64) { + var serr error + switch kind { + case KindVideo: + serr = videoinformationmod.UpdateOneVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: id}, + bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}}) + case KindPlaylet: + serr = playletmod.UpdateOnePlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: id}, + bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}}) + } + if serr != nil { + log.Warn("seopush: mark queued failed", log.Any("kind", kind), log.Any("id", id), log.E(serr)) + } +} + +// PushTask 推送任务中尚未成功的通道 +func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { + if task == nil || !pubdef.Enabled() { + return + } + if strings.Contains(task.Url, "?") { + log.Warn("seopush: skip query-url task", log.Any("url", task.Url), log.Any("action", task.Action)) + return + } + if !canPushExternally() { + dryRun(ctx, task) + return + } + if task.GoogleStatus != seopushmod.PushStatusSuccess { + pushGoogle(ctx, task) + } + if task.IndexNowStatus != seopushmod.PushStatusSuccess { + pushIndexNow(ctx, webAddr, task) + } +} + +func dryRun(ctx context.Context, task *seopushmod.SeoPushTask) { + log.Info("seopush: dry-run, external push skipped in non-prod", + log.Any("url", task.Url), log.Any("action", task.Action)) + if task.GoogleStatus != seopushmod.PushStatusSuccess { + markSuccess(ctx, task, googleChannel) + } + if task.IndexNowStatus != seopushmod.PushStatusSuccess { + markSuccess(ctx, task, indexNowChannel) + } +} + +func pushGoogle(ctx context.Context, task *seopushmod.SeoPushTask) { + notifyType := googleindex.TypeUpdated + if task.Action == seopushmod.ActionDeleted { + notifyType = googleindex.TypeDeleted + } + + resp, err := googleindex.Publish(ctx, task.Url, notifyType) + if err == nil { + markSuccess(ctx, task, googleChannel) + log.Info("seopush=google=pushed", + log.Any("url", task.Url), + log.Any("type", notifyType), + log.Any("resp", resp)) + return + } + if errors.Is(err, googleindex.ErrQuotaExceeded) { + log.Warn("seopush=google=quota_exceeded", + log.Any("url", task.Url), log.Any("resp", err.Error())) + return + } + if errors.Is(err, googleindex.ErrNotEnabled) { + log.Warn("seopush=google=not_ready", log.Any("url", task.Url)) + return + } + markFailed(ctx, task, googleChannel, err) +} + +func pushIndexNow(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { + if strings.TrimSpace(webAddr) == "" { + return + } + config, err := seoindexnow.BuildConfig(webAddr) + if err != nil { + log.Warn("seopush: build indexnow config failed", log.Any("webAddr", webAddr), log.E(err)) + return + } + + result := seoindexnow.NewBatchIndexNowSubmitter(config).SubmitBatch([]string{task.Url}) + if result.SuccessCount > 0 { + markSuccess(ctx, task, indexNowChannel) + log.Info("seopush=indexnow=pushed", + log.Any("url", task.Url), + log.Any("statusCode", result.StatusCodes)) + return + } + markFailed(ctx, task, indexNowChannel, errors.New(strings.Join(result.Errors, "; "))) +} + +func markSuccess(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields) { + now := time.Now() + if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{ + "$set": bson.M{ + ch.status: seopushmod.PushStatusSuccess, + ch.errMsg: "", + ch.at: now, + seopushmod.FN.SeoPushTask.UpdatedAt: now, + }, + }); serr != nil { + log.Warn("seopush: mark success failed", + log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr)) + } +} + +func markFailed(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields, pushErr error) { + now := time.Now() + errText := "" + if pushErr != nil { + errText = pushErr.Error() + } + log.Warn("seopush: push failed", + log.Any("channel", ch.name), log.Any("url", task.Url), log.Any("err", errText)) + + if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{ + "$set": bson.M{ + ch.errMsg: errText, + ch.at: now, + seopushmod.FN.SeoPushTask.UpdatedAt: now, + }, + "$inc": bson.M{ch.retry: 1}, + }); serr != nil { + log.Warn("seopush: mark failed failed", + log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr)) + } +} + +func taskFilter(task *seopushmod.SeoPushTask) bson.M { + return bson.M{ + seopushmod.FN.SeoPushTask.Url: task.Url, + seopushmod.FN.SeoPushTask.Action: task.Action, + } +} diff --git a/web/service/srv_slug/sync.go b/web/service/srv_slug/sync.go new file mode 100644 index 0000000..e94c8fc --- /dev/null +++ b/web/service/srv_slug/sync.go @@ -0,0 +1,403 @@ +package srv_slug + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/actress" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/playletcategorymod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + syncSlugDefaultSize = int64(500) + syncSlugMaxSize = int64(2000) + + TypeVideo = "video" + TypeVideoCategory = "videoCategory" + TypeActress = "actress" + TypeVideoCreater = "videoCreater" + TypePlaylet = "playlet" + TypePlayletCategory = "playletCategory" + TypePlayletTheme = "playletTheme" + TypeContentTag = "contentTag" +) + +type slugJob struct { + kind string + set func(*proto.SlugSyncResp, int64) + run func(context.Context, int64) (int64, bool, *srvdef.SrvErr) + hasMore func(context.Context) bool +} + +// SyncEmptySlugs 为视频、分类、女优、作者、短剧、统一标签中空 slug 自动生成。可重复调用,hasMore 为 true 时继续请求。 +func SyncEmptySlugs(ctx context.Context, req *proto.SlugSyncReq) (*proto.SlugSyncResp, *srvdef.SrvErr) { + maxSize := syncSlugDefaultSize + if req != nil && req.MaxSize > 0 { + maxSize = req.MaxSize + } + if maxSize > syncSlugMaxSize { + maxSize = syncSlugMaxSize + } + kind := "" + if req != nil { + kind = strings.TrimSpace(req.Type) + } + + jobs := allSlugJobs() + if kind != "" { + job, ok := jobByKind(jobs, kind) + if !ok { + return nil, srvdef.New(srvdef.ErrParamError, "不支持的type", "不支持的type") + } + jobs = []slugJob{job} + } + + resp := &proto.SlugSyncResp{} + remain := maxSize + for i, job := range jobs { + if remain <= 0 { + resp.HasMore = anyHasEmpty(ctx, jobs[i:]) + resp.TotalUpdated = sumUpdated(resp) + return resp, nil + } + updated, more, err := job.run(ctx, remain) + if err != nil { + return nil, err + } + job.set(resp, updated) + remain -= updated + if more { + resp.HasMore = true + resp.TotalUpdated = sumUpdated(resp) + return resp, nil + } + } + resp.TotalUpdated = sumUpdated(resp) + return resp, nil +} + +func allSlugJobs() []slugJob { + return []slugJob{ + {TypeVideoCategory, func(r *proto.SlugSyncResp, n int64) { r.VideoCategoryUpdated = n }, syncVideoCategoryEmptySlugs, hasVideoCategoryEmpty}, + {TypePlayletCategory, func(r *proto.SlugSyncResp, n int64) { r.PlayletCategoryUpdated = n }, syncPlayletCategoryEmptySlugs, hasPlayletCategoryEmpty}, + {TypePlayletTheme, func(r *proto.SlugSyncResp, n int64) { r.PlayletThemeUpdated = n }, syncPlayletThemeEmptySlugs, hasPlayletThemeEmpty}, + {TypeContentTag, func(r *proto.SlugSyncResp, n int64) { r.ContentTagUpdated = n }, syncContentTagEmptySlugs, hasContentTagEmpty}, + {TypeActress, func(r *proto.SlugSyncResp, n int64) { r.ActressUpdated = n }, syncActressEmptySlugs, hasActressEmpty}, + {TypeVideoCreater, func(r *proto.SlugSyncResp, n int64) { r.VideoCreaterUpdated = n }, syncVideoCreaterEmptySlugs, hasVideoCreaterEmpty}, + {TypePlaylet, func(r *proto.SlugSyncResp, n int64) { r.PlayletUpdated = n }, syncPlayletEmptySlugs, hasPlayletEmpty}, + {TypeVideo, func(r *proto.SlugSyncResp, n int64) { r.VideoUpdated = n }, syncVideoEmptySlugs, hasVideoEmpty}, + } +} + +func jobByKind(jobs []slugJob, kind string) (slugJob, bool) { + for _, job := range jobs { + if job.kind == kind { + return job, true + } + } + return slugJob{}, false +} + +func anyHasEmpty(ctx context.Context, jobs []slugJob) bool { + for _, job := range jobs { + if job.hasMore(ctx) { + return true + } + } + return false +} + +func sumUpdated(resp *proto.SlugSyncResp) int64 { + return resp.VideoUpdated + resp.VideoCategoryUpdated + resp.ActressUpdated + + resp.VideoCreaterUpdated + + resp.PlayletUpdated + resp.PlayletCategoryUpdated + + resp.PlayletThemeUpdated + resp.ContentTagUpdated +} + +func findLimit(maxSize int64) *options.FindOptions { + return options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}).SetLimit(maxSize + 1) +} + +func fillEmptySlugs[T any]( + ctx context.Context, + list []T, + maxSize int64, + idOf func(T) int64, + sourceOf func(T) string, + exists func(slug string, excludeId int64) bool, + update func(context.Context, int64, string) *srvdef.SrvErr, +) (int64, bool, *srvdef.SrvErr) { + hasMore := int64(len(list)) > maxSize + if hasMore { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if any(item) == nil { + continue + } + id := idOf(item) + if id <= 0 { + continue + } + slug := "" + if serr := slugx.Ensure(&slug, sourceOf(item), func(s string) bool { + return exists(s, id) + }); serr != nil { + return updated, hasMore, serr + } + if serr := update(ctx, id, slug); serr != nil { + return updated, hasMore, serr + } + updated++ + } + return updated, hasMore, nil +} + +func setSlug(ctx context.Context, updateOne func(context.Context, bson.M, bson.M) *srvdef.SrvErr, idField, slugField, updatedAtField string, id int64, slug string) *srvdef.SrvErr { + setDoc := bson.M{slugField: slug} + if updatedAtField != "" { + setDoc[updatedAtField] = time.Now() + } + return updateOne(ctx, bson.M{idField: id}, bson.M{"$set": setDoc}) +} + +func videoEmptySlugFilter(ctx context.Context) bson.M { + filter := slugx.EmptyFilter() + playletIDs := playletchaptermod.ListLinkedMediaIDs(ctx) + if len(playletIDs) == 0 { + return filter + } + return bson.M{ + "$and": []bson.M{ + filter, + { + "$nor": []bson.M{ + {videoinformationmod.FN.VideoInformation.SyncId: bson.M{"$in": playletIDs}}, + {videoinformationmod.FN.VideoInformation.Video.FileId: bson.M{"$in": playletIDs}}, + }, + }, + }, + } +} + +func hasVideoEmpty(ctx context.Context) bool { + n, _ := videoinformationmod.CountVideoInformations(ctx, videoEmptySlugFilter(ctx)) + return n > 0 +} +func hasVideoCategoryEmpty(ctx context.Context) bool { + n, _ := videocategorymod.CountVideoCategorys(ctx, slugx.EmptyFilter()) + return n > 0 +} +func hasActressEmpty(ctx context.Context) bool { + n, _ := actress.CountActresss(ctx, slugx.EmptyFilter()) + return n > 0 +} +func hasVideoCreaterEmpty(ctx context.Context) bool { + n, _ := videocreatermod.CountVideoCreaterInfos(ctx, slugx.EmptyFilter()) + return n > 0 +} +func hasPlayletEmpty(ctx context.Context) bool { + n, _ := playletmod.CountPlaylet(ctx, slugx.EmptyFilter()) + return n > 0 +} +func hasPlayletCategoryEmpty(ctx context.Context) bool { + n, _ := playletcategorymod.CountPlayletCategory(ctx, slugx.EmptyFilter()) + return n > 0 +} +func hasPlayletThemeEmpty(ctx context.Context) bool { + n, _ := playletthememod.CountPlayletTheme(ctx, slugx.EmptyFilter()) + return n > 0 +} +func hasContentTagEmpty(ctx context.Context) bool { + n, _ := contenttagmod.CountContentTag(ctx, slugx.EmptyFilter()) + return n > 0 +} + +func syncVideoEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videoinformationmod.FindManyVideoInformations(ctx, videoEmptySlugFilter(ctx), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*videoinformationmod.VideoInformation(list), maxSize, + func(item *videoinformationmod.VideoInformation) int64 { return item.Id }, + func(item *videoinformationmod.VideoInformation) string { + return utils.SlugSourceFromKeywordsOrTitle(item.Keyworld, item.Title) + }, + func(s string, excludeId int64) bool { + doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter( + videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, videoinformationmod.UpdateOneVideoInformation, videoinformationmod.FN.VideoInformation.Id, + videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.UpdatedAt, id, slug) + }, + ) +} + +func syncVideoCategoryEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videocategorymod.FindManyVideoCategorys(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*videocategorymod.VideoCategory(list), maxSize, + func(item *videocategorymod.VideoCategory) int64 { return item.Id }, + func(item *videocategorymod.VideoCategory) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name) + }, + func(s string, excludeId int64) bool { + doc, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter( + videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, videocategorymod.UpdateOneVideoCategory, videocategorymod.FN.VideoCategory.Id, + videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.UpdatedAt, id, slug) + }, + ) +} + +func syncActressEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := actress.FindManyActresss(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*actress.Actress(list), maxSize, + func(item *actress.Actress) int64 { return item.Id }, + func(item *actress.Actress) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Alias) + }, + func(s string, excludeId int64) bool { + doc, _ := actress.FindOneActress(ctx, slugx.Filter(actress.FN.Actress.Slug, actress.FN.Actress.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, actress.UpdateOneActress, actress.FN.Actress.Id, + actress.FN.Actress.Slug, actress.FN.Actress.UpdatedAt, id, slug) + }, + ) +} + +func syncVideoCreaterEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videocreatermod.FindManyVideoCreaterInfos(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*videocreatermod.VideoCreaterInfo(list), maxSize, + func(item *videocreatermod.VideoCreaterInfo) int64 { return item.Id }, + func(item *videocreatermod.VideoCreaterInfo) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name) + }, + func(s string, excludeId int64) bool { + doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter( + videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, videocreatermod.UpdateOneVideoCreaterInfo, videocreatermod.FN.VideoCreaterInfo.Id, + videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.UpdatedAt, id, slug) + }, + ) +} + +func syncPlayletEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := playletmod.FindManyPlaylet(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*playletmod.Playlet(list), maxSize, + func(item *playletmod.Playlet) int64 { return item.Id }, + func(item *playletmod.Playlet) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Title) + }, + func(s string, excludeId int64) bool { + doc, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, playletmod.UpdateOnePlaylet, playletmod.FN.Playlet.Id, + playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.UpdatedAt, id, slug) + }, + ) +} + +func syncPlayletCategoryEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := playletcategorymod.FindManyPlayletCategory(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*playletcategorymod.PlayletCategory(list), maxSize, + func(item *playletcategorymod.PlayletCategory) int64 { return item.Id }, + func(item *playletcategorymod.PlayletCategory) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name) + }, + func(s string, excludeId int64) bool { + doc, _ := playletcategorymod.FindOnePlayletCategory(ctx, slugx.Filter( + playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, playletcategorymod.UpdateOnePlayletCategory, playletcategorymod.FN.PlayletCategory.Id, + playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.UpdatedAt, id, slug) + }, + ) +} + +func syncPlayletThemeEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := playletthememod.FindManyPlayletTheme(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*playletthememod.PlayletTheme(list), maxSize, + func(item *playletthememod.PlayletTheme) int64 { return item.Id }, + func(item *playletthememod.PlayletTheme) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name) + }, + func(s string, excludeId int64) bool { + doc, _ := playletthememod.FindOnePlayletTheme(ctx, slugx.Filter( + playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, playletthememod.UpdateOnePlayletTheme, playletthememod.FN.PlayletTheme.Id, + playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.UpdatedAt, id, slug) + }, + ) +} + +func syncContentTagEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := contenttagmod.FindManyContentTag(ctx, slugx.EmptyFilter(), findLimit(maxSize)) + if err != nil { + return 0, false, err + } + return fillEmptySlugs(ctx, []*contenttagmod.ContentTag(list), maxSize, + func(item *contenttagmod.ContentTag) int64 { return item.Id }, + func(item *contenttagmod.ContentTag) string { + return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name) + }, + func(s string, excludeId int64) bool { + doc, _ := contenttagmod.FindOneContentTag(ctx, slugx.Filter( + contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.Id, s, excludeId)) + return doc != nil + }, + func(ctx context.Context, id int64, slug string) *srvdef.SrvErr { + return setSlug(ctx, contenttagmod.UpdateOneContentTag, contenttagmod.FN.ContentTag.Id, + contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.UpdatedAt, id, slug) + }, + ) +} diff --git a/web/service/srv_translation/define.go b/web/service/srv_translation/define.go new file mode 100644 index 0000000..6c62834 --- /dev/null +++ b/web/service/srv_translation/define.go @@ -0,0 +1,36 @@ +package srv_translation + +import ( + "time" + "videoweb/pub/models/translationmod" + "videoweb/pub/pubdef" +) + +type TranslationInfoReq struct { + pubdef.Page + Id int64 `json:"id" form:"id"` // 导航ID + Name string `json:"name" form:"name"` // 导航名称 + TransCategoryId int64 `json:"transCategoryId" form:"transCategoryId"` // 导航分类ID +} +type TranslationInfoResp struct { + Total int64 `json:"total"` + List translationmod.TranslationInfoList `json:"list"` +} +type TranslationInfoUpdate struct { + Name *string `json:"name" bson:"name,omitempty"` // 分类名称 + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + Rank *int64 `json:"rank" bson:"rank,omitempty"` // 排序 + HtmlId *int64 `json:"htmlId" bson:"htmlId,omitempty"` // 富文本ID + Link *string `json:"link" bson:"link,omitempty"` // 外部跳转 + IsFriendAds *bool `json:"isFriendAds" bson:"isFriendAds"` // 是否是友链广告位 + TransCategoryId *int64 `json:"transCategoryId" bson:"transCategoryId,omitempty"` // + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` +} +type TranslationInfoUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param TranslationInfoUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type TranslationInfoDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_translation/translation.go b/web/service/srv_translation/translation.go new file mode 100644 index 0000000..5a15dfb --- /dev/null +++ b/web/service/srv_translation/translation.go @@ -0,0 +1,93 @@ +package srv_translation + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/translationmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetTranslationInfos 按照参数搜索视频标签 +func GetTranslationInfos(ctx context.Context, param *TranslationInfoReq) (*TranslationInfoResp, *srvdef.SrvErr) { + filter := bson.M{} + opt := &options.FindOptions{} + resp := &TranslationInfoResp{} + + if param.Id > 0 { + filter[translationmod.FN.TranslationInfo.Id] = param.Id + } + if name := strings.TrimSpace(param.Name); name != "" { + filter[translationmod.FN.TranslationInfo.Name] = bson.M{"$regex": name} + } + if param.TransCategoryId > 0 { + filter[translationmod.FN.TranslationInfo.TransCategoryId] = param.TransCategoryId + } + + opt.SetSort(bson.M{translationmod.FN.TranslationInfo.Rank: -1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, err1 := translationmod.CountTranslationInfos(ctx, filter) + if err1 != nil { + return resp, err1 + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, err1 := translationmod.FindManyTranslationInfos(ctx, filter, opt) + if err1 != nil { + return resp, err1 + } + resp.List = ads + + return resp, nil +} + +// AddTranslationInfo 添加一视频标签 +func AddTranslationInfo(ctx context.Context, + ac *translationmod.TranslationInfo) *srvdef.SrvErr { + + ac.Id = mongos.Lid.Translation.Get() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + serr := translationmod.InsertOneTranslationInfo(ctx, ac) + if serr != nil { + return serr + } + return nil +} + +// DelTranslationInfo 删除指定视频标签 +func DelTranslationInfo(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{translationmod.FN.TranslationInfo.Id: id} + //删除 + count, serr := translationmod.DelOneTranslationInfo(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateTranslationInfo 更新指定视频标签 +func UpdateTranslationInfo(ctx context.Context, id int64, param TranslationInfoUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + adFilter := bson.M{translationmod.FN.TranslationInfo.Id: id} + updateDoc := bson.M{"$set": param} + serr := translationmod.UpdateOneTranslationInfo(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + return nil +} diff --git a/web/service/srv_translationcategory/define.go b/web/service/srv_translationcategory/define.go new file mode 100644 index 0000000..d21f952 --- /dev/null +++ b/web/service/srv_translationcategory/define.go @@ -0,0 +1,35 @@ +package srv_translationcategory + +import ( + "time" + "videoweb/pub/models/translationcategorymod" + "videoweb/pub/pubdef" +) + +type TranslationCategoryReq struct { + pubdef.Page + Id int64 `json:"id" form:"id"` // 导航分类ID + Name string `json:"name" form:"name"` // 导航分类名称 +} +type TranslationCategoryResp struct { + Total int64 `json:"total"` + List translationcategorymod.TranslationCategoryList `json:"list"` +} +type TranslationCategoryUpdate struct { + AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id + Name *string `json:"name" bson:"name,omitempty"` // 名称 + Icon *string `json:"icon" bson:"icon,omitempty"` // Icon + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + Rank *int64 `json:"rank" bson:"rank,omitempty"` // + CategoryType *int `json:"categoryType" bson:"categoryType"` // 导航类型 1=url跳转 2=二级导航 3=底部导航 + UrlValue *string `json:"urlValue" bson:"urlValue"` // 跳转URL + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` +} +type TranslationCategoryUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param TranslationCategoryUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type TranslationCategoryDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_translationcategory/translation.go b/web/service/srv_translationcategory/translation.go new file mode 100644 index 0000000..7af34b4 --- /dev/null +++ b/web/service/srv_translationcategory/translation.go @@ -0,0 +1,90 @@ +package srv_translationcategory + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/translationcategorymod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetTranslationCategorys 按照参数搜索视频标签 +func GetTranslationCategorys(ctx context.Context, param *TranslationCategoryReq) (*TranslationCategoryResp, *srvdef.SrvErr) { + filter := bson.M{} + opt := &options.FindOptions{} + resp := &TranslationCategoryResp{} + + if param.Id > 0 { + filter[translationcategorymod.FN.TranslationCategory.Id] = param.Id + } + if name := strings.TrimSpace(param.Name); name != "" { + filter[translationcategorymod.FN.TranslationCategory.Name] = bson.M{"$regex": name} + } + + opt.SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, err1 := translationcategorymod.CountTranslationCategorys(ctx, filter) + if err1 != nil { + return resp, err1 + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, err1 := translationcategorymod.FindManyTranslationCategorys(ctx, filter, opt) + if err1 != nil { + return resp, err1 + } + resp.List = ads + + return resp, nil +} + +// AddTranslationCategory 添加一视频标签 +func AddTranslationCategory(ctx context.Context, + ac *translationcategorymod.TranslationCategory) *srvdef.SrvErr { + + ac.Id = mongos.Lid.TransCategory.Get() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + serr := translationcategorymod.InsertOneTranslationCategory(ctx, ac) + if serr != nil { + return serr + } + return nil +} + +// DelTranslationCategory 删除指定视频标签 +func DelTranslationCategory(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{translationcategorymod.FN.TranslationCategory.Id: id} + //删除 + count, serr := translationcategorymod.DelOneTranslationCategory(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateTranslationCategory 更新指定视频标签 +func UpdateTranslationCategory(ctx context.Context, id int64, param TranslationCategoryUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + adFilter := bson.M{translationcategorymod.FN.TranslationCategory.Id: id} + updateDoc := bson.M{"$set": param} + serr := translationcategorymod.UpdateOneTranslationCategory(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + return nil +} diff --git a/web/service/srv_user/define.go b/web/service/srv_user/define.go new file mode 100644 index 0000000..177fde6 --- /dev/null +++ b/web/service/srv_user/define.go @@ -0,0 +1,23 @@ +package srv_user + +import ( + "videoweb/pub/models/usermod" + "videoweb/pub/pubdef" +) + +type UserListReq struct { + pubdef.Page + Id *int64 `json:"id"` // 用户ID + NickName string `json:"nickName"` // 昵称(模糊) + Status *pubdef.UserStatus `json:"status"` // 状态 +} + +type UserListResp struct { + Total int64 `json:"total"` + List usermod.UserList `json:"list"` +} + +type UserUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param usermod.UserUpdate `json:"param"` +} diff --git a/web/service/srv_user/user.go b/web/service/srv_user/user.go new file mode 100644 index 0000000..94819e6 --- /dev/null +++ b/web/service/srv_user/user.go @@ -0,0 +1,56 @@ +package srv_user + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/usermod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetUsers 分页查询用户列表 +func GetUsers(ctx context.Context, req *UserListReq) (*UserListResp, *srvdef.SrvErr) { + filter := bson.M{} + if req.Id != nil && *req.Id > 0 { + filter[usermod.FN.User.Id] = *req.Id + } + if req.NickName != "" { + filter[usermod.FN.User.NickName] = bson.M{"$regex": req.NickName} + } + if req.Status != nil { + filter[usermod.FN.User.Status] = *req.Status + } + + opts := options.Find(). + SetSort(bson.M{usermod.FN.User.Id: -1}). + SetSkip((req.PageNum - 1) * req.PageSize). + SetLimit(req.PageSize) + + resp := &UserListResp{} + total, serr := usermod.CountUsers(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + list, serr := usermod.FindManyUser(ctx, filter, opts) + if serr != nil { + return resp, serr + } + resp.List = list + return resp, nil +} + +// UpdateUser 更新指定用户信息 +func UpdateUser(ctx context.Context, id int64, param usermod.UserUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = &now + return usermod.UpdateOneUser(ctx, + bson.M{usermod.FN.User.Id: id}, + bson.M{"$set": param}, + ) +} diff --git a/web/service/srv_videocategory/category.go b/web/service/srv_videocategory/category.go new file mode 100644 index 0000000..ad7e2d2 --- /dev/null +++ b/web/service/srv_videocategory/category.go @@ -0,0 +1,172 @@ +package srv_videocategory + +import ( + "context" + "time" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/mongos" + "videoweb/web/service/srv_contenttag" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetVideoCategorys 按照参数搜索视频分类 +func GetVideoCategorys(ctx context.Context, param *VideoCategoryReq) (*VideoCategoryResp, *srvdef.SrvErr) { + filter := bson.M{} + if param.Id != nil && *param.Id > 0 { + filter[videocategorymod.FN.VideoCategory.Id] = *param.Id + } + if param.Name != nil && *param.Name != "" { + filter[videocategorymod.FN.VideoCategory.Name] = bson.M{"$regex": *param.Name} + } + if param.DisplayPosition != nil { + filter[videocategorymod.FN.VideoCategory.DisplayPosition] = *param.DisplayPosition + } + if param.SortType != nil { + filter[videocategorymod.FN.VideoCategory.SortType] = *param.SortType + } + opt := &options.FindOptions{} + resp := &VideoCategoryResp{} + + opt.SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, serr := videocategorymod.CountVideoCategorys(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, serr := videocategorymod.FindManyVideoCategorys(ctx, filter, opt) + if serr != nil { + return resp, serr + } + resp.List = ads + + return resp, nil +} + +// AddVideoCategory 添加一个视频分类 +func AddVideoCategory(ctx context.Context, + ac *videocategorymod.VideoCategory) *srvdef.SrvErr { + //if ac.Name != "" { + // count, serr := videocategorymod.CountVideoCategorys(ctx, bson.M{videocategorymod.FN.VideoCategory.Name: ac.Name}) + // if serr != nil { + // return serr + // } + // if count > 0 { + // return srvdef.New(srvdef.ErrDbInputExist, "视频分类已存在") + // } + //} + + ac.Id = mongos.Lid.VideoCategory.Get() + if ac.Tags == nil { + ac.Tags = make([]int64, 0) + } + tagIds, err := srv_contenttag.NormalizeContentTagIds(ac.Tags) + if err != nil { + return err + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil { + return err + } + ac.Tags = tagIds + exists := func(s string) bool { + doc, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter( + videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, s, 0)) + return doc != nil + } + if serr := slugx.Ensure(&ac.Slug, utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name), exists); serr != nil { + return serr + } + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + serr := videocategorymod.InsertOneVideoCategory(ctx, ac) + if serr != nil { + return serr + } + playletcache.ClearHomeCache() + return nil +} + +// DelVideoCategory 删除指定视频分类 +func DelVideoCategory(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id} + //删除 + count, serr := videocategorymod.DelOneVideoCategory(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + playletcache.ClearHomeCache() + return nil +} + +// UpdateVideoCategory 更新指定视频分类 +func UpdateVideoCategory(ctx context.Context, id int64, param VideoCategoryUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id} + current, serr := videocategorymod.FindOneVideoCategory(ctx, adFilter) + if serr != nil { + return serr + } + name := current.Name + if param.Name != nil { + name = *param.Name + } + keywords := current.SeoInfo.Keyworld + if param.SeoInfo != nil { + keywords = param.SeoInfo.Keyworld + } + exists := func(s string) bool { + doc, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter( + videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, s, id)) + return doc != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, param.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists) + if serr != nil { + return serr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + if param.Tags != nil { + tagIds, err := srv_contenttag.NormalizeContentTagIds(*param.Tags) + if err != nil { + return err + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil { + return err + } + param.Tags = &tagIds + } + param.IndexNow = nil + if param.IsReIndexNow != nil && *param.IsReIndexNow { + indexNow := int64(0) + param.IndexNow = &indexNow + } + updateDoc := bson.M{"$set": param} + serr = videocategorymod.UpdateOneVideoCategory(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + playletcache.ClearHomeCache() + return nil +} diff --git a/web/service/srv_videocategory/define.go b/web/service/srv_videocategory/define.go new file mode 100644 index 0000000..14401f4 --- /dev/null +++ b/web/service/srv_videocategory/define.go @@ -0,0 +1,44 @@ +package srv_videocategory + +import ( + "time" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/pubdef" +) + +type VideoCategoryReq struct { + pubdef.Page + Id *int64 `json:"id"` // 分类 ID + Name *string `json:"name"` // 分类名称(模糊匹配) + DisplayPosition *pubdef.CategoryDisplayPosition `json:"displayPosition" bson:"displayPosition"` // 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + SortType *pubdef.CategorySortType `json:"sortType" bson:"sortType"` // 排序方式 +} +type VideoCategoryResp struct { + Total int64 `json:"total"` + List videocategorymod.VideoCategoryList `json:"list"` +} +type VideoCategoryUpdate struct { + Name *string `json:"name" bson:"name,omitempty"` // 名称 + Slug *string `json:"slug" bson:"slug,omitempty"` // slug + Title *string `json:"title" bson:"title,omitempty"` // APP显示 + Desc *string `json:"desc" bson:"desc,omitempty"` // 描述 + DisplayPosition *pubdef.CategoryDisplayPosition `json:"displayPosition" bson:"displayPosition,omitempty"` // 显示位置 0=仅TAB 1=仅首页 2=两者都显示 + SortType *pubdef.CategorySortType `json:"sortType" bson:"sortType,omitempty"` // 排序方式 + Tags *[]int64 `json:"tags" bson:"tags,omitempty"` // 关联视频标签 + Rank *int64 `json:"rank" bson:"rank,omitempty"` // + TranslationCategoryId *int64 `json:"translationCategoryId" bson:"translationCategoryId,omitempty"` // 导航分类 + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + DisplayMode *pubdef.DisplayMode `json:"displayMode" bson:"displayMode,omitempty"` // 展示方式 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` + IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow,omitempty"` // 是否重新提交索引 + IndexNow *int64 `bson:"indexNow,omitempty"` +} +type VideoCategoryUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param VideoCategoryUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type VideoCategoryDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_videocollection/define.go b/web/service/srv_videocollection/define.go new file mode 100644 index 0000000..4a18036 --- /dev/null +++ b/web/service/srv_videocollection/define.go @@ -0,0 +1,42 @@ +package srv_videocollection + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +type VideoCollectionOptionMediaReq struct { + CollectionID int64 `json:"collectionID" bson:"collectionID"` // 合集ID + IsAdd bool `json:"isAdd" bson:"isAdd"` // 增加还是移除 + VideoIds []int64 `json:"videoIds" bson:"videoIds"` // 视频ID +} + +type UpdateCollectionMediaRankReq struct { + CollectionID int64 `json:"collectionID" bson:"collectionID"` //合集ID + VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID + Rank int64 `json:"rank" bson:"rank"` // 排序 +} + +type GetCollectionMediaListReq struct { + CollectionID int64 `json:"collectionID" bson:"collectionID"` + pubdef.Page +} +type GetCollectionMediaListResp struct { + Total int64 `json:"total" bson:"total"` + List []CollectionMediaInfo `json:"list" bson:"list"` +} + +type CollectionMediaInfo struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Title string `json:"title" bson:"title"` // 帖子标题 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Nodes []videoinformationmod.Node `json:"nodes" bson:"nodes"` // 节点 + Status pubdef.InformationStatus `json:"status" bson:"status"` // 帖子上架状态 + Video videoinformationmod.VideoInfo `json:"video" bson:"video"` // 视频 + Rank int64 `json:"rank" bson:"rank"` // 排序 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} diff --git a/web/service/srv_videocollection/videocollection.go b/web/service/srv_videocollection/videocollection.go new file mode 100644 index 0000000..68f044e --- /dev/null +++ b/web/service/srv_videocollection/videocollection.go @@ -0,0 +1,166 @@ +package srv_videocollection + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocollectionmediamod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func VideoCollectionOptionMedia(ctx context.Context, req *VideoCollectionOptionMediaReq) *srvdef.SrvErr { + _, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.CollectionID, + videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_Collection, + }) + if err != nil { + return err + } + if req.IsAdd { + // 追加视频 + videos, err := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID, + videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: bson.M{"$in": req.VideoIds}, + }) + if err != nil { + return err + } + existVideoIds := make(map[int64]struct{}, len(videos)) + for _, video := range videos { + existVideoIds[video.VideoId] = struct{}{} + } + addIds := make([]int64, 0, len(req.VideoIds)) + for _, id := range req.VideoIds { + if _, ok := existVideoIds[id]; !ok { + addIds = append(addIds, id) + existVideoIds[id] = struct{}{} + } + } + if len(addIds) > 0 { + now := time.Now() + addInfos := make(videocollectionmediamod.VideoCollectionMediaModelList, 0, len(addIds)) + for _, id := range addIds { + addInfos = append(addInfos, &videocollectionmediamod.VideoCollectionMediaModel{ + ID: primitive.NewObjectID(), + CollectionID: req.CollectionID, + VideoId: id, + Status: pubdef.InformationStatusVerifyOK, + Rank: 0, + CreatedAt: now, + UpdatedAt: now, + }) + } + err = videocollectionmediamod.InsertManyVideoCollectionMedia(ctx, addInfos) + if err != nil { + return err + } + } + } else { + // 移除视频 + _, err = videocollectionmediamod.DelManyVideoCollectionMediaModel(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID, + videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: bson.M{"$in": req.VideoIds}, + }) + if err != nil { + return err + } + } + count, err := videocollectionmediamod.CountVideoCollectionMediaModels(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID, + }) + if err == nil { + _ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.CollectionID, + }, bson.M{"$set": bson.M{ + videoinformationmod.FN.VideoInformation.VideoCount: count, + videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(), + }}) + } + return nil +} + +func UpdateCollectionMediaRank(ctx context.Context, req *UpdateCollectionMediaRankReq) *srvdef.SrvErr { + err := videocollectionmediamod.UpdateOneVideoCollectionMediaModel(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID, + videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: req.VideoId, + }, bson.M{"$set": bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.Rank: req.Rank, + }}) + return err +} + +func GetCollectionMediaList(ctx context.Context, req *GetCollectionMediaListReq) (*GetCollectionMediaListResp, *srvdef.SrvErr) { + _, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.CollectionID, + videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_Collection, + }) + if err != nil { + return nil, err + } + opts := &options.FindOptions{} + opts.SetSort(bson.M{videocollectionmediamod.FN.VideoCollectionMediaModel.Rank: 1}) + opts.SetLimit(req.PageSize) + opts.SetSkip((req.PageNum - 1) * req.PageSize) + total, err := videocollectionmediamod.CountVideoCollectionMediaModels(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID, + }) + if err != nil { + return nil, err + } + if total == 0 { + return &GetCollectionMediaListResp{ + Total: 0, + List: make([]CollectionMediaInfo, 0), + }, nil + } + videos, err := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID, + }, opts) + if err != nil { + return nil, err + } + videoIds := make([]int64, 0, len(videos)) + for _, video := range videos { + videoIds = append(videoIds, video.VideoId) + } + videoInfos, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds}, + }) + if err != nil { + return nil, err + } + videoInfoMap := make(map[int64]*videoinformationmod.VideoInformation, len(videoInfos)) + for _, info := range videoInfos { + videoInfoMap[info.Id] = info + } + infos := make([]CollectionMediaInfo, 0, len(videos)) + for _, video := range videos { + info, ok := videoInfoMap[video.VideoId] + if !ok { + continue + } + infos = append(infos, CollectionMediaInfo{ + Id: video.VideoId, + Title: info.Title, + CoverImg: info.CoverImg, + Desc: info.Desc, + Remark: info.Remark, + Nodes: info.Nodes, + Status: info.Status, + Video: info.Video, + Rank: video.Rank, + UpdatedAt: video.UpdatedAt, + CreatedAt: video.CreatedAt, + }) + } + resp := &GetCollectionMediaListResp{ + Total: total, + List: infos, + } + return resp, nil +} diff --git a/web/service/srv_videocreater/creater.go b/web/service/srv_videocreater/creater.go new file mode 100644 index 0000000..a6c16c8 --- /dev/null +++ b/web/service/srv_videocreater/creater.go @@ -0,0 +1,126 @@ +package srv_videocreater + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetVideoCreaters 按照参数搜索视频标签 +func GetVideoCreaters(ctx context.Context, param *VideoCreaterReq) (*VideoCreaterResp, *srvdef.SrvErr) { + filter := bson.M{} + opt := &options.FindOptions{} + resp := &VideoCreaterResp{} + + opt.SetSort(bson.M{videocreatermod.FN.VideoCreaterInfo.Id: 1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, serr := videocreatermod.CountVideoCreaterInfos(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, serr := videocreatermod.FindManyVideoCreaterInfos(ctx, filter, opt) + if serr != nil { + return resp, serr + } + resp.List = ads + + return resp, nil +} + +// AddVideoCreater 添加一视频标签 +func AddVideoCreater(ctx context.Context, + ac *videocreatermod.VideoCreaterInfo) *srvdef.SrvErr { + + ac.Id = mongos.Lid.VideoCreator.Get() + exists := func(s string) bool { + doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter( + videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, 0)) + return doc != nil + } + if serr := slugx.Ensure(&ac.Slug, utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name), exists); serr != nil { + return serr + } + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + log.Info("InsertOneVideoCreaterInfo", log.Any("", *ac)) + serr := videocreatermod.InsertOneVideoCreaterInfo(ctx, ac) + if serr != nil { + return serr + } + return nil +} + +// DelVideoCreater 删除指定视频标签 +func DelVideoCreater(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id} + //删除 + count, serr := videocreatermod.DelOneVideoCreaterInfo(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateVideoCreater 更新指定视频标签 +func UpdateVideoCreater(ctx context.Context, id int64, param VideoCreaterUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id} + current, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, adFilter) + if serr != nil { + return serr + } + name := current.Name + if param.Name != nil { + name = *param.Name + } + keywords := current.SeoInfo.Keyworld + if param.SeoInfo != nil { + keywords = param.SeoInfo.Keyworld + } + exists := func(s string) bool { + doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter( + videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, id)) + return doc != nil + } + slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, param.Slug, + utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists) + if serr != nil { + return serr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + param.IndexNow = nil + if param.IsReIndexNow != nil && *param.IsReIndexNow { + indexNow := int64(0) + param.IndexNow = &indexNow + } + updateDoc := bson.M{"$set": param} + serr = videocreatermod.UpdateOneVideoCreaterInfo(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + return nil +} diff --git a/web/service/srv_videocreater/define.go b/web/service/srv_videocreater/define.go new file mode 100644 index 0000000..0d50ae6 --- /dev/null +++ b/web/service/srv_videocreater/define.go @@ -0,0 +1,40 @@ +package srv_videocreater + +import ( + "time" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/pubdef" +) + +type VideoCreaterReq struct { + pubdef.Page +} +type VideoCreaterResp struct { + Total int64 `json:"total"` + List videocreatermod.VideoCreaterInfoList `json:"list"` +} +type VideoCreaterUpdate struct { + AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id + Name *string `json:"name" bson:"name,omitempty"` // 名称 + Slug *string `json:"slug" bson:"slug,omitempty"` // slug + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序 + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` + IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引 + MediaTypes *[]int64 `json:"mediaTypes" bson:"mediaTypes"` // 作者关联的资源类型 1视频 + IndexNow *int64 `bson:"indexNow,omitempty" json:"-"` + Works *int64 `json:"works" bson:"works,omitempty"` // 作品数量 + Image *string `json:"image" bson:"image,omitempty"` // 头像 + Background *string `json:"background" bson:"background,omitempty"` // 背景 + CreateType *int64 `json:"createType" bson:"createType,omitempty"` // 创作者类型 0=普通 1=官方 + Desc *string `json:"desc" bson:"desc,omitempty"` // 简介 +} +type VideoCreaterUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param VideoCreaterUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type VideoCreaterDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_videoinfo/define.go b/web/service/srv_videoinfo/define.go new file mode 100644 index 0000000..f77099a --- /dev/null +++ b/web/service/srv_videoinfo/define.go @@ -0,0 +1,166 @@ +package srv_videoinfo + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +type VideoInfoReq struct { + Id *int64 `json:"id"` // + ActressIds *[]int64 `json:"actressIds" bson:"actressIds"` // 女优ID + Title *string `json:"title"` // 标题 + Bango *string `json:"bango"` // 番号, + CreatorId *int64 `json:"creatorId"` // 作者Id + Status *pubdef.InformationStatus `json:"status"` // 帖子上架状态 + Tag *int64 `json:"tag" ` // 标签 + IsHot *bool `json:"isHot"` // 是否热门 + IsRecommend *bool `json:"isRecommend"` // 是否推荐 true-推荐 false-不推荐 + MediaType *videoinformationmod.MediaType `json:"mediaType"` // 资源类型 + CreatorName *string `json:"creatorName"` // 作者名称 + Keyworld *string `json:"keyworld"` // 关键字 + CategoryId *int64 `json:"categoryId"` // 分类ID + VideoTaskId *string `json:"videoTaskId"` // 视频任务id + IsTop *bool `json:"isTop" bson:"isTop"` // 是否置顶 + VideoFileId *string `json:"videoFileId"` // 切片id(切片列表的ID) + Sort int64 `json:"sort"` // 排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数。按分类查询且排序方式为排序值时,使用该分类的 categoryRanks 排序 + pubdef.Page + StartTime *string `json:"startTime"` // 创建时间-开始 + EndTime *string `json:"endTime"` // 创建时间-截止 + OptionAccount *string `json:"optionAccount"` // 最后操作人 +} + +type VideoInfoResp struct { + Total int64 `json:"total"` + List videoinformationmod.VideoInformationList `json:"list"` +} + +type AddVideoReq struct { + FileIds []string `json:"fileId"` // 资源id +} + +type SyncMediaFileReq struct { + FileIds []string `json:"fileId"` // 媒资库影片 ID + SourceVersion string `json:"sourceVersion,omitempty"` // v1 / v2 + FirstTagMap map[string]string `json:"firstTagMap,omitempty"` // 覆盖一级标签 + MinVideoSeconds int32 `json:"minVideoSeconds,omitempty"` // 最短时长,0 表示不限制 + VideoList []SyncMediaFileItem `json:"videoList,omitempty"` // 媒资库搜索结果,优先使用 +} + +type SyncMediaFileItem struct { + Id string `json:"id"` + Title string `json:"title"` + FieldNameFs string `json:"fieldNameFs"` + FirstTag string `json:"firstTag"` + SecondTags []string `json:"secondTags"` + VideoTime int32 `json:"videoTime"` + CoverImg string `json:"coverImg"` + Publish string `json:"publish"` + Bango string `json:"bango"` + TaskID string `json:"taskId"` + Keyworld []string `json:"keyworld"` + Width int64 `json:"width"` + Height int64 `json:"height"` +} + +type SyncMediaFileResp struct { + Synced int `json:"synced"` + Failed int `json:"failed"` +} +type VideoInfoUpdate struct { + Title *string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题 + Slug *string `json:"slug,omitempty" bson:"slug,omitempty"` // slug + ActressIds *[]int64 `json:"actressIds" bson:"actressIds,omitempty"` // 女优ID + AddedTime *time.Time `json:"addedTime,omitempty" bson:"addedTime,omitempty"` + CreatorId *int64 `json:"creatorId,omitempty" bson:"creatorId,omitempty"` // 作者Id + Keyworld *[]string `bson:"keyworld,omitempty" json:"keyworld,omitempty"` // + MediaType *videoinformationmod.MediaType `json:"mediaType,omitempty" bson:"mediaType,omitempty"` // 资源类型 + FirstTag *string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签 + SecondTags *[]string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签 + CoverImg *string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面 + UnencryptedImage *string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + Desc *string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述 + Remark *string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注 + Nodes *[]videoinformationmod.Node `json:"nodes,omitempty" bson:"nodes,omitempty"` // 节点 + Status *pubdef.InformationStatus `json:"status,omitempty" bson:"status,omitempty"` // 帖子上架状态 + Sort *int64 `json:"sort,omitempty" bson:"sort,omitempty"` // 排序 + CategoryId *int64 `json:"categoryId" bson:"categoryId,omitempty"` // 分类ID + CategoryRanks *[]videoinformationmod.VideoCategoryRank `json:"categoryRanks" bson:"categoryRanks,omitempty"` // 各分类下的排序,传空数组表示清空 + + Cover *string `json:"cover,omitempty" bson:"cover,omitempty"` // 视频封面 + Height *int64 `json:"height,omitempty" bson:"height,omitempty"` // 分辨率高(视频才会存在) + Width *int64 `json:"width,omitempty" bson:"width,omitempty"` // 分辨率宽(视频才会存在) + VideoTime *int32 `bson:"videoTime,omitempty" json:"videoTime,omitempty"` // 影片长度 + + Tags *[]int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签 + IsHot *bool `json:"isHot,omitempty" bson:"isHot,omitempty"` // 是否热门 + IsRecommend *bool `json:"isRecommend,omitempty" bson:"isRecommend,omitempty"` // 是否推荐 true-推荐 false-不推荐 + UpdatedAt *time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` // 更新时间 + + CreatorName *string `bson:"creatorName,omitempty"` // 作者名 + IsTop *bool `json:"isTop,omitempty" bson:"isTop,omitempty"` // 是否置顶 + Video *videoinformationmod.VideoInfo `bson:"video,omitempty"` // 视频 + IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引 + IndexNow *int64 `bson:"indexNow,omitempty"` + IssuedAt *time.Time `json:"issuedAt" bson:"issuedAt"` // 发行时间 + IssuedBrand *string `json:"issuedBrand" bson:"issuedBrand"` // 发行商 + Bango *string `bson:"bango,omitempty" json:"bango"` // 番号,唯一 + SecondUpdatedAt *time.Time `json:"secondUpdatedAt,omitempty" bson:"secondUpdatedAt,omitempty"` // 二次修改时间 +} +type VideoInfoUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param VideoInfoUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type VideoInfoDelReq struct { + Id int64 `json:"id" binding:"required"` +} +type AddVideoCollectionReq struct { + Title string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld,omitempty"` // 关键字 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面 + Desc string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述 + Remark string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注 + Tags []int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签 +} + +type VideoAddTagsReq struct { + VideoIds []int64 `json:"videoIds" binding:"required"` // 视频ID集合 + Tags []int64 `json:"tags" binding:"required"` // 统一标签ID集合 +} + +type VideoSetTagsReq struct { + Ids []int64 `json:"ids" binding:"required"` // 视频ID集合,传1个表示单条设置,传多个表示批量设置 + TagIds *[]int64 `json:"tagIds"` // 标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误 +} + +type SetFakeLikesReq struct { + Ids []int64 `json:"ids"` // 视频ID集合,支持单个或批量 + LikesMin int64 `json:"likesMin"` // 假点赞数随机最小值,包含该值 + LikesMax int64 `json:"likesMax"` // 假点赞数随机最大值,包含该值 +} + +type SetFakeWatchTimesReq struct { + Ids []int64 `json:"ids"` // 视频ID集合,支持单个或批量 + WatchTimesMin int64 `json:"watchTimesMin"` // 假观看次数随机最小值,包含该值 + WatchTimesMax int64 `json:"watchTimesMax"` // 假观看次数随机最大值,包含该值 +} + +// SetVideoHeatReq 单/批量设置视频热度 +type SetVideoHeatReq struct { + Ids []int64 `json:"ids"` // 视频ID集合 + HeatMin int64 `json:"heatMin"` // 热度最小值 + HeatMax int64 `json:"heatMax"` // 热度最大值 +} + +type SetRecommendReq struct { + Ids []int64 `json:"ids"` // 视频ID集合,传1个表示单条设置,传多个表示批量设置 + IsRecommend bool `json:"isRecommend"` // 是否推荐 true-推荐 false-不推荐 +} + +type SetFakeCountResp struct { + MatchedCount int64 `json:"matchedCount"` // 匹配到的视频数量 + ModifiedCount int64 `json:"modifiedCount"` // 实际修改的视频数量 +} diff --git a/web/service/srv_videoinfo/externallist.go b/web/service/srv_videoinfo/externallist.go new file mode 100644 index 0000000..4539f5d --- /dev/null +++ b/web/service/srv_videoinfo/externallist.go @@ -0,0 +1,152 @@ +package srv_videoinfo + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type ExternalVideoListReq struct { + pubdef.Page + Date string `form:"date"` // 日期筛选,格式:2006-01-02 + Publisher string `form:"publisher"` // 发布人 +} + +type ExternalVideoListItem struct { + Date string `json:"date"` + Publisher string `json:"publisher"` + SiteName string `json:"siteName"` + Title string `json:"title"` + Keyworld []string `json:"keyworld"` + Tags []string `json:"tags"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Url string `json:"url"` + Content []string `json:"content"` + UploadUserName string `json:"uploadUserName"` +} + +type ExternalVideoListResp struct { + Total int64 `json:"total"` + List []ExternalVideoListItem `json:"list"` +} + +// SearchExternalVideoList 按日期查询已上架视频,供外部数据接口使用。 +func SearchExternalVideoList(ctx context.Context, req *ExternalVideoListReq) (*ExternalVideoListResp, *srvdef.SrvErr) { + cst := time.FixedZone("CST", 8*3600) + + var dayStart, dayEnd time.Time + if req.Date != "" { + t, err := time.ParseInLocation("2006-01-02", req.Date, cst) + if err == nil { + dayStart = t + } else { + dayStart = time.Now().In(cst).Truncate(24 * time.Hour).Add(-24 * time.Hour) + } + } else { + now := time.Now().In(cst) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, cst) + dayStart = today.Add(-24 * time.Hour) + } + dayEnd = dayStart.Add(24 * time.Hour) + + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + "$or": bson.A{ + bson.M{ + videoinformationmod.FN.VideoInformation.AddedTime: bson.M{ + "$gte": dayStart.UTC(), + "$lt": dayEnd.UTC(), + }, + }, + bson.M{ + videoinformationmod.FN.VideoInformation.SecondUpdatedAt: bson.M{ + "$gte": dayStart.UTC(), + "$lt": dayEnd.UTC(), + }, + }, + }, + } + if req.Publisher != "" { + filter[videoinformationmod.FN.VideoInformation.UploadUserName] = req.Publisher + } + + total, serr := videoinformationmod.CountVideoInformations(ctx, filter) + if serr != nil { + log.Warn("SearchExternalVideoList CountVideoInformations failed", log.E(serr)) + return nil, serr + } + + pageSize := req.Page.PageSize + if pageSize <= 0 { + pageSize = 1000 + } + pageNum := req.Page.PageNum + if pageNum <= 0 { + pageNum = 1 + } + opts := &options.FindOptions{} + opts.SetLimit(pageSize) + opts.SetSkip((pageNum - 1) * pageSize) + opts.SetSort(bson.D{{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}}) + list, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if serr != nil { + log.Warn("SearchExternalVideoList FindManyVideoInformations failed", log.E(serr)) + return nil, serr + } + + tagIdSet := make(map[int64]struct{}) + for _, v := range list { + for _, tid := range v.Tags { + tagIdSet[tid] = struct{}{} + } + } + tagTitleMap := make(map[int64]string) + if len(tagIdSet) > 0 { + tagIds := make([]int64, 0, len(tagIdSet)) + for tid := range tagIdSet { + tagIds = append(tagIds, tid) + } + tags, _ := contenttagmod.GetMapTags(ctx, tagIds) + for _, t := range tags { + tagTitleMap[t.Id] = t.Name + } + } + + items := make([]ExternalVideoListItem, 0, len(list)) + for _, v := range list { + tagTitles := make([]string, 0, len(v.Tags)) + for _, tid := range v.Tags { + if title, ok := tagTitleMap[tid]; ok { + tagTitles = append(tagTitles, title) + } + } + var content []string + for _, node := range v.Nodes { + content = append(content, node.Text) + } + items = append(items, ExternalVideoListItem{ + Date: utils.GetDateString(v.AddedTime), + CreatedAt: utils.GetTimeString(v.CreatedAt), + UpdatedAt: utils.GetTimeString(v.SecondUpdatedAt), + Publisher: v.OptionAccount, + SiteName: pubdef.AppName, + Title: v.Title, + Keyworld: v.Keyworld, + Tags: tagTitles, + Url: fmt.Sprintf("/video/detail?id=%d", v.Id), + Content: content, + UploadUserName: v.UploadUserName, + }) + } + return &ExternalVideoListResp{Total: total, List: items}, nil +} diff --git a/web/service/srv_videoinfo/hevc_transcode.go b/web/service/srv_videoinfo/hevc_transcode.go new file mode 100644 index 0000000..32a71eb --- /dev/null +++ b/web/service/srv_videoinfo/hevc_transcode.go @@ -0,0 +1,160 @@ +package srv_videoinfo + +import ( + "context" + "fmt" + "strings" + "videoweb/pub/common/laosiji" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/videoinformationmod" + "videoweb/web/webg" + + "go.mongodb.org/mongo-driver/bson" +) + +// TranscodeVideo 手动提交主视频 H265 转码。 +func TranscodeVideo(ctx context.Context, videoId int64) *srvdef.SrvErr { + info, serr := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + }) + if serr != nil { + return serr + } + src := strings.TrimSpace(info.Video.Url) + if src == "" { + return srvdef.New(srvdef.Err, "", "该视频没有可转码的源地址") + } + if laosiji.LaosijiClient == nil || laosiji.LaosijiDomains == nil { + return srvdef.New(srvdef.Err, "", "转码服务未就绪") + } + if info.HevcStatus == videoinformationmod.HevcStatusPending { + return srvdef.New(srvdef.Err, "", "转码请求已提交,正在转码中,请勿重复提交") + } + if err := SubmitTranscodeVideo(ctx, videoId, src, info.Video.FileId); err != nil { + return srvdef.New(srvdef.Err, "", "提交转码失败:"+err.Error()) + } + return nil +} + +func buildTranscodeSourceURL(videoUrl string) string { + name := strings.TrimSpace(videoUrl) + if name == "" { + return "" + } + if !strings.Contains(name, ".m3u8") { + name += ".m3u8" + } + host := strings.TrimSpace(webg.Conf.WebM3u8Host) + if host == "" { + return "" + } + return utils.JoinUrlPath(host, "/api/web/m3u8/"+name) +} + +// SubmitTranscodeVideo 提交主视频转码任务。 +func SubmitTranscodeVideo(ctx context.Context, videoId int64, src, fileId string) error { + fileURL := buildTranscodeSourceURL(src) + if fileURL == "" { + return fmt.Errorf("empty transcode source url, videoUrl:%s host:%s", src, webg.Conf.WebM3u8Host) + } + sd, err := laosiji.LaosijiDomains.Get(ctx) + if err != nil { + return err + } + result, err := laosiji.LaosijiClient.SubmitTranscoding(ctx, fileURL, fileId, *sd, videoId) + if err != nil { + return err + } + return applyTranscodeResult(ctx, videoId, result) +} + +// RefreshTranscodeVideo 查询转码任务状态并回写。 +func RefreshTranscodeVideo(ctx context.Context, videoId int64, src, fileId string) error { + fileURL := buildTranscodeSourceURL(src) + if fileURL == "" { + return fmt.Errorf("empty transcode source url, videoUrl:%s host:%s", src, webg.Conf.WebM3u8Host) + } + sd, err := laosiji.LaosijiDomains.Get(ctx) + if err != nil { + return err + } + result, found, err := laosiji.LaosijiClient.QueryTranscoding(ctx, fileURL, fileId, *sd) + if err != nil { + return err + } + if !found { + return nil + } + return applyTranscodeResult(ctx, videoId, result) +} + +func applyTranscodeResult(ctx context.Context, videoId int64, result laosiji.TranscodeResult) error { + log.Infof("==applyTranscodeResult===> result", log.Any("videoId", videoId), log.Any("result", result)) + var update bson.M + switch { + case result.Done: + meta := toVideoMetaInfo(result.HevcMeta) + if meta == nil { + meta = &videoinformationmod.VideoMetaInfo{} + } + h265Url := normalizeH265UrlForSave(result.HevcURL) + meta.M3u8URL = h265Url + set := bson.M{ + videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusSuccess, + videoinformationmod.FN.VideoInformation.HevcMetaInfo: meta, + videoinformationmod.FN.VideoInformation.Video.H265Url: h265Url, + } + if result.PreviewVtt != "" { + set[videoinformationmod.FN.VideoInformation.PreviewVtt] = result.PreviewVtt + } + if result.AudioFile != "" { + set[videoinformationmod.FN.VideoInformation.AudioFile] = result.AudioFile + } + if result.SmartCover != "" { + set[videoinformationmod.FN.VideoInformation.SmartCover] = result.SmartCover + } + update = bson.M{"$set": set} + case result.Failed: + update = bson.M{ + "$set": bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusFailed}, + "$inc": bson.M{videoinformationmod.FN.VideoInformation.HevcFailCount: 1}, + } + default: + update = bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusPending}} + } + if serr := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + }, update); serr != nil { + return fmt.Errorf("update video transcode status: %v", serr) + } + return nil +} + +func normalizeH265UrlForSave(url string) string { + url = strings.TrimSpace(url) + return strings.TrimPrefix(url, "/") +} + +func toVideoMetaInfo(m *laosiji.MetaInfo) *videoinformationmod.VideoMetaInfo { + if m == nil { + return nil + } + return &videoinformationmod.VideoMetaInfo{ + Codec: m.Codec, + Profile: m.Profile, + Width: m.Width, + Height: m.Height, + Fps: m.Fps, + VideoBitrate: m.VideoBitrate, + PixFmt: m.PixFmt, + AudioCodec: m.AudioCodec, + AudioBitrate: m.AudioBitrate, + AudioSampleRate: m.AudioSampleRate, + AudioChannels: m.AudioChannels, + Bitrate: m.Bitrate, + Size: m.Size, + Duration: m.Duration, + } +} diff --git a/web/service/srv_videoinfo/sync.go b/web/service/srv_videoinfo/sync.go new file mode 100644 index 0000000..50a3179 --- /dev/null +++ b/web/service/srv_videoinfo/sync.go @@ -0,0 +1,294 @@ +package srv_videoinfo + +import ( + "context" + "math/rand" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + "videoweb/pub/storage/proxy" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func videoSyncKey(media proxy.MediaResp) string { + if !media.MediaSourceId.IsZero() { + return media.MediaSourceId.Hex() + } + return media.Id.Hex() +} + +func buildVideoSyncFilter(media proxy.MediaResp) bson.M { + keys := []string{media.Id.Hex()} + if !media.MediaSourceId.IsZero() { + msid := media.MediaSourceId.Hex() + if msid != keys[0] { + keys = append(keys, msid) + } + } + or := make([]bson.M, 0, len(keys)*2) + for _, key := range keys { + or = append(or, + bson.M{videoinformationmod.FN.VideoInformation.Video.FileId: key}, + bson.M{videoinformationmod.FN.VideoInformation.SyncId: key}, + ) + } + return bson.M{"$or": or} +} + +func IsPlayletMedia(ctx context.Context, media proxy.MediaResp) bool { + return playletchaptermod.IsLinkedMedia(ctx, media.IsPlaylet, media.PlayletKeys()...) +} + +func SyncOneVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr { + if IsPlayletMedia(ctx, media) { + log.Info("VideoSync skip playlet media", log.Any("mediaId", media.Id.Hex()), log.Any("mediaSourceId", media.MediaSourceId.Hex())) + return nil + } + // 同步时不再自动写 video_taginfo,标签由后台统一标签绑定维护 + syncKey := videoSyncKey(media) + filter := buildVideoSyncFilter(media) + videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, filter) + if serr != nil { + // 新加入的视频 + if serr.Code == srvdef.ErrNoData { + videoInfo = media.ToVideoInfo() + if videoInfo == nil { + log.Error("VideoSync FindOneVideoInformation", log.Any("MediaId", media.Id)) + return serr + } + if len(media.Publish) > 0 { + creater, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{ + videocreatermod.FN.VideoCreaterInfo.Name: media.Publish, + }) + if serr != nil && serr.Code != srvdef.ErrNoData { + log.Error("VideoSync.FindOneVideoCreaterInfo", log.Any("media", media), log.E(serr)) + return serr + } + if creater == nil { + createrId := mongos.Lid.VideoCreator.Get() + creater = &videocreatermod.VideoCreaterInfo{ + Id: createrId, + Name: media.Publish, + MediaTypes: []int64{1}, + Status: true, + Sort: 0, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + } + createrExists := func(s string) bool { + doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter( + videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, 0)) + return doc != nil + } + if serr = slugx.Ensure(&creater.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, creater.Name), createrExists); serr != nil { + return serr + } + serr = videocreatermod.InsertOneVideoCreaterInfo(ctx, creater) + if serr != nil { + log.Error("VideoSync.InsertOneVideoCreaterInfo", log.Any("media", media), log.E(serr)) + return serr + } + } else { + isFind := false + for _, v := range creater.MediaTypes { + if v == 1 { + isFind = true + } + } + if !isFind { + mediaTypes := append(creater.MediaTypes, 1) + _ = videocreatermod.UpdateOneVideoCreaterInfo(ctx, bson.M{videocreatermod.FN.VideoCreaterInfo.Id: creater.Id}, bson.M{ + "$set": bson.M{videocreatermod.FN.VideoCreaterInfo.MediaTypes: mediaTypes}, + }) + } + } + videoInfo.CreatorId = creater.Id + videoInfo.CreatorName = creater.Name + } + videoInfo.IndexNow = 0 + applyDefaultFakeFlow(&videoInfo.Fake, rand.New(rand.NewSource(time.Now().UnixNano()))) + exists := func(s string) bool { + doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter( + videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, 0)) + return doc != nil + } + if serr = slugx.Ensure(&videoInfo.Slug, utils.SlugSourceFromKeywordsOrTitle(videoInfo.Keyworld, videoInfo.Title), exists); serr != nil { + return serr + } + serr = videoinformationmod.InsertOneVideoInformation(ctx, videoInfo) + if serr != nil { + log.Error("VideoSync InsertOneVideoInformation", log.Any("videoInfo", videoInfo), log.E(serr)) + return serr + } + } else { + log.Error("VideoSync FindOneVideoInformation", log.Any("filter", filter), log.E(serr)) + return serr + } + } else { + // 更新资源 + nodes := make([]videoinformationmod.Node, 0) + if videoInfo != nil { + mediaNodeMap := make(map[string]videoinformationmod.Node, len(media.Nodes)) + for _, node := range media.Nodes { + if node.NodeId != "" { + mediaNodeMap[node.NodeId] = node + } + } + // 更新节点里面图片水印 以本地视频列表的节点为主 + for _, node := range videoInfo.Nodes { + if mediaNode, ok := mediaNodeMap[node.NodeId]; ok { + node.Images = mediaNode.Images + } + nodes = append(nodes, node) + } + } else { + nodes = media.Nodes + } + setDoc := bson.M{ + videoinformationmod.FN.VideoInformation.Nodes: nodes, + videoinformationmod.FN.VideoInformation.Keyworld: media.Keyworld, + videoinformationmod.FN.VideoInformation.FirstTag: media.FirstTag, + videoinformationmod.FN.VideoInformation.SecondTags: media.SecondTags, + videoinformationmod.FN.VideoInformation.CoverImg: media.CoverImg, + videoinformationmod.FN.VideoInformation.IssuedBrand: media.IssuedBrand, + videoinformationmod.FN.VideoInformation.IssuedAt: media.IssuedAt, + } + for k, v := range defaultFakeFlowSetDoc(rand.New(rand.NewSource(time.Now().UnixNano()))) { + setDoc[k] = v + } + setDoc[videoinformationmod.FN.VideoInformation.SyncId] = syncKey + if len(media.FieldNameFs) > 0 { + video := videoinformationmod.VideoInfo{ + TaskId: media.TaskID, + FileId: syncKey, + Url: media.FieldNameFs, + Cover: media.CoverImg, + Height: media.Height, + Width: media.Width, + VideoTime: media.VideoTime, + } + if !strings.HasSuffix(video.Url, ".m3u8") { + video.Url += ".m3u8" + } + setDoc[videoinformationmod.FN.VideoInformation.Video.TaskId] = video.TaskId + setDoc[videoinformationmod.FN.VideoInformation.Video.FileId] = video.FileId + setDoc[videoinformationmod.FN.VideoInformation.Video.Url] = video.Url + setDoc[videoinformationmod.FN.VideoInformation.Video.Cover] = video.Cover + setDoc[videoinformationmod.FN.VideoInformation.Video.Height] = video.Height + setDoc[videoinformationmod.FN.VideoInformation.Video.Width] = video.Width + setDoc[videoinformationmod.FN.VideoInformation.Video.VideoTime] = video.VideoTime + } + if preMp4 := strings.TrimSpace(media.PreMp4FileNameFs); preMp4 != "" { + setDoc[videoinformationmod.FN.VideoInformation.Video.PreMp4FileNameFs] = preMp4 + } + if unencryptedPreMp4 := strings.TrimSpace(media.UnencryptedPreMp4); unencryptedPreMp4 != "" { + setDoc[videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4] = unencryptedPreMp4 + } + if videoInfo.Slug == "" { + exists := func(s string) bool { + doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter( + videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, videoInfo.Id)) + return doc != nil + } + slug := "" + if serr = slugx.Ensure(&slug, utils.SlugSourceFromKeywordsOrTitle(media.Keyworld, videoInfo.Title), exists); serr != nil { + return serr + } + setDoc[videoinformationmod.FN.VideoInformation.Slug] = slug + } + serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoInfo.Id}, bson.M{"$set": setDoc}) + if serr != nil { + return serr + } + } + playletcache.ClearHomeCache() + _ = syncNodeVideo(ctx, media) + return nil +} + +// syncNodeVideo 同步节点里的视频 +func syncNodeVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr { + mediaID := videoSyncKey(media) + nodeIDs := []string{mediaID} + if !media.MediaSourceId.IsZero() { + msid := media.MediaSourceId.Hex() + if msid != mediaID { + nodeIDs = append(nodeIDs, msid) + } + } + if media.Id.Hex() != mediaID && media.Id.Hex() != "" { + nodeIDs = append(nodeIDs, media.Id.Hex()) + } + filter := bson.M{"nodes.nodeId": bson.M{"$in": nodeIDs}} + opts := &options.FindOptions{} + opts.SetLimit(100) + videoInfo, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if serr != nil { + log.Warn("syncNodeVideo", log.Any("filter", filter), log.E(serr)) + return serr + } + if !strings.HasSuffix(media.FieldNameFs, ".m3u8") { + media.FieldNameFs += ".m3u8" + } + if len(videoInfo) > 0 { + writeModels := make([]mongo.WriteModel, 0, len(videoInfo)) + now := time.Now() + // 更新视频路径 + for _, video := range videoInfo { + if video.Nodes != nil && len(video.Nodes) > 0 { + isFind := false + for i, node := range video.Nodes { + if node.Videos != nil && len(node.Videos) > 0 { + for j, v := range node.Videos { + if v.FileId == mediaID || containsString(nodeIDs, v.FileId) { + node.Videos[j].Url = media.FieldNameFs + isFind = true + break + } + } + } + if isFind { + video.Nodes[i] = node + break + } + } + if isFind { + writeModels = append(writeModels, mongo.NewUpdateOneModel(). + SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: video.Id}). + SetUpdate(bson.M{"$set": bson.M{ + videoinformationmod.FN.VideoInformation.UpdatedAt: now, + videoinformationmod.FN.VideoInformation.Nodes: video.Nodes, + }})) + } + } + } + if len(writeModels) > 0 { + _, serr = videoinformationmod.BulkWriteVideoInformation(ctx, writeModels, options.BulkWrite().SetOrdered(false)) + if serr != nil { + log.Warn("syncNodeVideo BulkWriteVideoInformation failed", log.Any("filter", filter), log.E(serr)) + return serr + } + } + } + return nil +} + +func containsString(list []string, target string) bool { + for _, item := range list { + if item == target { + return true + } + } + return false +} diff --git a/web/service/srv_videoinfo/update_json.go b/web/service/srv_videoinfo/update_json.go new file mode 100644 index 0000000..ca45ba9 --- /dev/null +++ b/web/service/srv_videoinfo/update_json.go @@ -0,0 +1,341 @@ +package srv_videoinfo + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// UnmarshalJSON 兼容 id 使用数字字符串,以及 param 里的表单写法。 +func (r *VideoInfoUpdateReq) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + return nil + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if value, ok := raw["id"]; ok { + next, keep, err := normalizeIntRaw(value) + if err != nil { + return fmt.Errorf("id %w", err) + } + if keep && next != nil && !bytes.Equal(bytes.TrimSpace(next), []byte("null")) { + if err := json.Unmarshal(next, &r.Id); err != nil { + return err + } + } + } + if value, ok := raw["param"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + if err := json.Unmarshal(value, &r.Param); err != nil { + return err + } + } + return nil +} + +// UnmarshalJSON 兼容后台表单里的空字符串、数字字符串,以及分类排序的多种写法。 +func (v *VideoInfoUpdate) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + return nil + } + normalized, err := normalizeVideoUpdateJSON(data) + if err != nil { + return err + } + type alias VideoInfoUpdate + var out alias + if err := json.Unmarshal(normalized, &out); err != nil { + return err + } + *v = VideoInfoUpdate(out) + return nil +} + +func normalizeVideoUpdateJSON(data []byte) ([]byte, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + for _, key := range []string{"addedTime", "updatedAt", "issuedAt", "secondUpdatedAt"} { + value, ok := raw[key] + if !ok { + continue + } + next, err := normalizeTimeRaw(value) + if err != nil { + return nil, err + } + if next == nil { + delete(raw, key) + continue + } + raw[key] = next + } + for _, key := range []string{"categoryId", "sort", "creatorId", "height", "width", "videoTime", "indexNow", "mediaType", "status"} { + value, ok := raw[key] + if !ok { + continue + } + next, keep, err := normalizeIntRaw(value) + if err != nil { + return nil, fmt.Errorf("%s %w", key, err) + } + if !keep { + continue + } + if next == nil { + delete(raw, key) + continue + } + raw[key] = next + } + for _, key := range []string{"actressIds", "tags"} { + value, ok := raw[key] + if !ok { + continue + } + next, keep, err := normalizeIntSliceRaw(value) + if err != nil { + return nil, fmt.Errorf("%s %w", key, err) + } + if !keep { + continue + } + if next == nil { + delete(raw, key) + continue + } + raw[key] = next + } + if value, ok := raw["categoryRanks"]; ok { + next, keep, err := normalizeCategoryRanksRaw(value, raw["categoryId"]) + if err != nil { + return nil, err + } + if !keep { + delete(raw, "categoryRanks") + } else { + raw["categoryRanks"] = next + } + } + return json.Marshal(raw) +} + +func normalizeTimeRaw(raw json.RawMessage) (json.RawMessage, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return raw, nil + } + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return nil, fmt.Errorf("时间格式错误") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, nil + } + parsed, err := parseFlexTime(text) + if err != nil { + return nil, err + } + return json.Marshal(parsed) +} + +func parseFlexTime(text string) (time.Time, error) { + if parsed, err := time.Parse(time.RFC3339Nano, text); err == nil { + return parsed, nil + } + if parsed, err := time.Parse(time.RFC3339, text); err == nil { + return parsed, nil + } + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"} { + if parsed, err := time.ParseInLocation(layout, text, time.Local); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("时间格式错误: %s", text) +} + +// normalizeIntRaw 把数字或数字字符串整理成 JSON 数字。空字符串表示删除该字段。 +func normalizeIntRaw(raw json.RawMessage) (json.RawMessage, bool, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return nil, false, nil + } + if bytes.Equal(raw, []byte("null")) { + return raw, true, nil + } + if raw[0] != '"' { + var number json.Number + if err := json.Unmarshal(raw, &number); err != nil { + return nil, false, fmt.Errorf("数字格式错误") + } + if _, err := number.Int64(); err != nil { + return nil, false, fmt.Errorf("数字格式错误") + } + return raw, true, nil + } + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return nil, false, fmt.Errorf("数字格式错误") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, true, nil + } + value, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return nil, false, fmt.Errorf("数字格式错误: %s", text) + } + encoded, err := json.Marshal(value) + return encoded, true, err +} + +func normalizeIntSliceRaw(raw json.RawMessage) (json.RawMessage, bool, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return raw, true, nil + } + if bytes.Equal(raw, []byte(`""`)) { + return nil, true, nil + } + if raw[0] != '[' { + return nil, false, fmt.Errorf("数组格式错误") + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return nil, false, err + } + values := make([]int64, 0, len(items)) + for _, item := range items { + next, keep, err := normalizeIntRaw(item) + if err != nil { + return nil, false, err + } + if !keep || next == nil || bytes.Equal(bytes.TrimSpace(next), []byte("null")) { + return nil, false, fmt.Errorf("数字格式错误") + } + var value int64 + if err := json.Unmarshal(next, &value); err != nil { + return nil, false, err + } + values = append(values, value) + } + encoded, err := json.Marshal(values) + return encoded, true, err +} + +func normalizeCategoryRanksRaw(raw, categoryIDRaw json.RawMessage) (json.RawMessage, bool, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte(`""`)) { + return nil, false, nil + } + fallbackID := int64FromRaw(categoryIDRaw) + if raw[0] != '[' { + item, ok, err := normalizeCategoryRankItem(raw, fallbackID) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + encoded, err := json.Marshal([]categoryRankJSON{item}) + return encoded, true, err + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return nil, false, fmt.Errorf("分类排序格式错误") + } + if len(items) == 0 { + encoded, err := json.Marshal([]categoryRankJSON{}) + return encoded, true, err + } + cleaned := make([]categoryRankJSON, 0, len(items)) + for _, item := range items { + rank, ok, err := normalizeCategoryRankItem(item, fallbackID) + if err != nil { + return nil, false, err + } + if !ok { + continue + } + cleaned = append(cleaned, rank) + } + if len(cleaned) == 0 { + return nil, false, nil + } + encoded, err := json.Marshal(cleaned) + return encoded, true, err +} + +type categoryRankJSON struct { + Id int64 `json:"id"` + Rank int64 `json:"rank"` +} + +func normalizeCategoryRankItem(raw json.RawMessage, fallbackID int64) (categoryRankJSON, bool, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return categoryRankJSON{}, false, nil + } + if raw[0] != '{' { + if bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte(`""`)) || rawIsZero(raw) { + return categoryRankJSON{}, false, nil + } + rank := int64FromRaw(raw) + if rank == 0 { + return categoryRankJSON{}, false, fmt.Errorf("分类排序格式错误") + } + if fallbackID <= 0 { + return categoryRankJSON{}, false, fmt.Errorf("分类排序缺少分类ID") + } + return categoryRankJSON{Id: fallbackID, Rank: rank}, true, nil + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return categoryRankJSON{}, false, fmt.Errorf("分类排序格式错误") + } + id := int64FromRaw(fields["id"]) + if id <= 0 { + id = int64FromRaw(fields["categoryId"]) + } + rank := int64FromRaw(fields["rank"]) + if id <= 0 && !rawHasValue(fields["rank"]) { + return categoryRankJSON{}, false, nil + } + if id <= 0 { + id = fallbackID + } + if id <= 0 { + return categoryRankJSON{}, false, fmt.Errorf("分类排序缺少分类ID") + } + return categoryRankJSON{Id: id, Rank: rank}, true, nil +} + +func rawHasValue(raw json.RawMessage) bool { + raw = bytes.TrimSpace(raw) + return len(raw) > 0 && !bytes.Equal(raw, []byte("null")) && !bytes.Equal(raw, []byte(`""`)) +} + +func int64FromRaw(raw json.RawMessage) int64 { + next, keep, err := normalizeIntRaw(raw) + if err != nil || !keep || next == nil || bytes.Equal(bytes.TrimSpace(next), []byte("null")) { + return 0 + } + var value int64 + if err := json.Unmarshal(next, &value); err != nil { + return 0 + } + return value +} + +func rawIsZero(raw json.RawMessage) bool { + next, keep, err := normalizeIntRaw(raw) + return err == nil && keep && bytes.Equal(bytes.TrimSpace(next), []byte("0")) +} diff --git a/web/service/srv_videoinfo/videoinfo.go b/web/service/srv_videoinfo/videoinfo.go new file mode 100644 index 0000000..f9e38e8 --- /dev/null +++ b/web/service/srv_videoinfo/videoinfo.go @@ -0,0 +1,992 @@ +package srv_videoinfo + +import ( + "context" + "math/rand" + "net/http" + "time" + "videoweb/pub/common/godef" + "videoweb/pub/common/log" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/actress" + "videoweb/pub/models/contenttagmod" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocollectionmediamod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + "videoweb/pub/storage" + "videoweb/pub/storage/proxy" + "videoweb/web/service/srv_contenttag" + "videoweb/web/service/srv_report" + "videoweb/web/service/srv_seopush" + + "go.mongodb.org/mongo-driver/bson/primitive" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetVideoInfos 按照参数搜索APP +func GetVideoInfos(ctx context.Context, param *VideoInfoReq) (*VideoInfoResp, *srvdef.SrvErr) { + filter := bson.M{} + if param.MediaType != nil { + filter[videoinformationmod.FN.VideoInformation.MediaType] = *param.MediaType + } + if param.Id != nil && *param.Id > 0 { + filter[videoinformationmod.FN.VideoInformation.Id] = *param.Id + } + if param.Title != nil && len(*param.Title) > 0 { + filter[videoinformationmod.FN.VideoInformation.Title] = bson.M{"$regex": *param.Title} + } + if param.Bango != nil && *param.Bango != "" { + filter[videoinformationmod.FN.VideoInformation.Bango] = bson.M{"$regex": *param.Bango} + } + if param.CreatorId != nil && *param.CreatorId > 0 { + filter[videoinformationmod.FN.VideoInformation.CreatorId] = *param.CreatorId + } + if param.Status != nil { + filter[videoinformationmod.FN.VideoInformation.Status] = *param.Status + } + if param.Tag != nil && *param.Tag > 0 { + filter[videoinformationmod.FN.VideoInformation.Tags] = bson.M{"$in": []int64{*param.Tag}} + } + if param.IsHot != nil { + filter[videoinformationmod.FN.VideoInformation.IsHot] = *param.IsHot + } + if param.IsRecommend != nil { + filter[videoinformationmod.FN.VideoInformation.IsRecommend] = *param.IsRecommend + } + if param.CreatorName != nil && len(*param.CreatorName) > 0 { + filter[videoinformationmod.FN.VideoInformation.CreatorName] = bson.M{"$regex": *param.CreatorName} + } + if param.Keyworld != nil && len(*param.Keyworld) > 0 { + filter[videoinformationmod.FN.VideoInformation.Keyworld] = bson.M{"$in": []string{*param.Keyworld}} + } + if param.VideoTaskId != nil && len(*param.VideoTaskId) > 0 { + filter[videoinformationmod.FN.VideoInformation.Video.TaskId] = *param.VideoTaskId + } + if param.VideoFileId != nil && len(*param.VideoFileId) > 0 { + filter[videoinformationmod.FN.VideoInformation.Video.FileId] = *param.VideoFileId + } + if param.CategoryId != nil && *param.CategoryId > 0 { + filter[videoinformationmod.FN.VideoInformation.CategoryId] = *param.CategoryId + } + if param.IsTop != nil { + filter[videoinformationmod.FN.VideoInformation.IsTop] = *param.IsTop + } + if param.ActressIds != nil && len(*param.ActressIds) > 0 { + filter[videoinformationmod.FN.VideoInformation.ActressIds] = bson.M{"$in": *param.ActressIds} + } + if param.StartTime != nil && param.EndTime != nil { + start := utils.GetDayBeginByTime(*param.StartTime) + end := utils.GetDayEndByTime(*param.EndTime) + if end.Unix() > start.Unix() { + filter[videoinformationmod.FN.VideoInformation.CreatedAt] = bson.M{ + "$gte": start, "$lte": end, + } + } + } + if param.OptionAccount != nil { + filter[videoinformationmod.FN.VideoInformation.OptionAccount] = *param.OptionAccount + } + opt := &options.FindOptions{} + resp := &VideoInfoResp{ + Total: 0, + List: make(videoinformationmod.VideoInformationList, 0), + } + + opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: -1}) + switch param.Sort { + case 0: + opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.CreatedAt: -1}) + case 1: + opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1}) + case 2: + opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1}) + case 3: + opt.SetSort(bson.D{ + {videoinformationmod.FN.VideoInformation.Sort, -1}, + {videoinformationmod.FN.VideoInformation.AddedTime, -1}, + }) + case 4: + opt.SetSort(bson.D{ + {videoinformationmod.FN.VideoInformation.Real.WatchTimes, -1}, + {videoinformationmod.FN.VideoInformation.AddedTime, -1}, + }) + } + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, serr := videoinformationmod.CountVideoInformations(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + var ads videoinformationmod.VideoInformationList + if param.Sort == 3 && param.CategoryId != nil && *param.CategoryId > 0 { + ads, serr = videoinformationmod.FindManyVideoByCategoryRank(ctx, filter, *param.CategoryId, (param.PageNum-1)*param.PageSize, param.PageSize, nil) + } else { + ads, serr = videoinformationmod.FindManyVideoInformations(ctx, filter, opt) + } + if serr != nil { + return resp, serr + } + resp.List = ads + + return resp, nil +} + +// SetVideoRecommend 单/批量设置视频是否推荐 +func SetVideoRecommend(ctx context.Context, req *SetRecommendReq) *srvdef.SrvErr { + if req == nil || len(req.Ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + ids, err := normalizeFakeFlowIds(req.Ids) + if err != nil { + return err + } + err = videoinformationmod.UpdateManyVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids}, + }, bson.M{ + "$set": bson.M{ + videoinformationmod.FN.VideoInformation.IsRecommend: req.IsRecommend, + videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(), + }, + }) + if err != nil { + return err + } + playletcache.ClearHomeCache() + return nil +} + +// SetVideoFakeLikes 单/批量随机设置视频假点赞数 +func SetVideoFakeLikes(ctx context.Context, req *SetFakeLikesReq) (*SetFakeCountResp, *srvdef.SrvErr) { + if req == nil || len(req.Ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + if req.LikesMin < 0 || req.LikesMax < req.LikesMin { + return nil, srvdef.New(srvdef.ErrParamError, "点赞数区间无效") + } + ids, serr := normalizeFakeFlowIds(req.Ids) + if serr != nil { + return nil, serr + } + return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.Fake.Likes, req.LikesMin, req.LikesMax) +} + +// SetVideoFakeWatchTimes 单/批量随机设置视频假观看次数 +func SetVideoFakeWatchTimes(ctx context.Context, req *SetFakeWatchTimesReq) (*SetFakeCountResp, *srvdef.SrvErr) { + if req == nil || len(req.Ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + if req.WatchTimesMin < 0 || req.WatchTimesMax < req.WatchTimesMin { + return nil, srvdef.New(srvdef.ErrParamError, "观看次数区间无效") + } + ids, err := normalizeFakeFlowIds(req.Ids) + if err != nil { + return nil, err + } + return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.Fake.WatchTimes, req.WatchTimesMin, req.WatchTimesMax) +} + +// SetVideoHeat 单/批量随机设置视频热度 +func SetVideoHeat(ctx context.Context, req *SetVideoHeatReq) (*SetFakeCountResp, *srvdef.SrvErr) { + if req == nil || len(req.Ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + if req.HeatMin < 0 || req.HeatMax < req.HeatMin { + return nil, srvdef.New(srvdef.ErrParamError, "热度区间无效") + } + ids, err := normalizeFakeFlowIds(req.Ids) + if err != nil { + return nil, err + } + return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.HeatValue, req.HeatMin, req.HeatMax) +} + +func setVideoFakeField(ctx context.Context, ids []int64, field string, min, max int64) (*SetFakeCountResp, *srvdef.SrvErr) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + docs := make([]mongo.WriteModel, 0, len(ids)) + for _, id := range ids { + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: id}). + SetUpdate(bson.M{"$set": bson.M{field: randomFakeFlowValue(r, min, max)}})) + } + result, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false)) + if err != nil { + return nil, err + } + if result.MatchedCount == 0 { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + playletcache.ClearHomeCache() + return &SetFakeCountResp{MatchedCount: result.MatchedCount, ModifiedCount: result.ModifiedCount}, nil +} + +func normalizeFakeFlowIds(rawIds []int64) ([]int64, *srvdef.SrvErr) { + ids := make([]int64, 0, len(rawIds)) + seen := make(map[int64]struct{}, len(rawIds)) + for _, id := range rawIds { + if id <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids包含无效id") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + return ids, nil +} + +const ( + defaultFakeFlowMin = int64(1000) + defaultFakeFlowMax = int64(10000) +) + +func randomDefaultFakeFlowPair(r *rand.Rand) (watchTimes, likes int64) { + if r == nil { + r = rand.New(rand.NewSource(time.Now().UnixNano())) + } + likes = randomFakeFlowValue(r, defaultFakeFlowMin, defaultFakeFlowMax-1) + watchTimes = randomFakeFlowValue(r, likes+1, defaultFakeFlowMax) + return watchTimes, likes +} + +func applyDefaultFakeFlow(fake *pubdef.FakeFlow, r *rand.Rand) { + if fake == nil { + return + } + fake.WatchTimes, fake.Likes = randomDefaultFakeFlowPair(r) +} + +func defaultFakeFlowSetDoc(r *rand.Rand) bson.M { + watchTimes, likes := randomDefaultFakeFlowPair(r) + return bson.M{ + videoinformationmod.FN.VideoInformation.Fake.WatchTimes: watchTimes, + videoinformationmod.FN.VideoInformation.Fake.Likes: likes, + } +} + +const fillZeroFakeFlowBatchSize = 500 + +func zeroFakeFlowFilter() bson.M { + return bson.M{ + "$or": []bson.M{ + { + "$expr": bson.M{ + "$lt": []interface{}{ + bson.M{"$add": []interface{}{"$real.watchTimes", "$fake.watchTimes"}}, + defaultFakeFlowMin, + }, + }, + }, + { + "$expr": bson.M{ + "$lt": []interface{}{ + bson.M{"$add": []interface{}{"$real.likes", "$fake.likes"}}, + defaultFakeFlowMin, + }, + }, + }, + }, + } +} + +// FillZeroVideoFakeFlow 为播放量或收藏总量小于 1000 的视频随机设置 fake 播放量和收藏数(1000-10000,播放量大于收藏量)。 +func FillZeroVideoFakeFlow(ctx context.Context) (*SetFakeCountResp, *srvdef.SrvErr) { + filter := zeroFakeFlowFilter() + opt := options.Find(). + SetProjection(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}). + SetBatchSize(fillZeroFakeFlowBatchSize). + SetLimit(fillZeroFakeFlowBatchSize) + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + resp := &SetFakeCountResp{} + var lastID int64 + + for { + pageFilter := filter + if lastID > 0 { + pageFilter = bson.M{ + "$and": []bson.M{ + filter, + {videoinformationmod.FN.VideoInformation.Id: bson.M{"$gt": lastID}}, + }, + } + } + findOpt := opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}) + list, serr := videoinformationmod.FindManyVideoInformations(ctx, pageFilter, findOpt) + if serr != nil { + return nil, serr + } + if len(list) == 0 { + break + } + + docs := make([]mongo.WriteModel, 0, len(list)) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + lastID = item.Id + resp.MatchedCount++ + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}). + SetUpdate(bson.M{"$set": defaultFakeFlowSetDoc(r)})) + } + if len(docs) == 0 { + break + } + result, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false)) + if err != nil { + return nil, err + } + resp.ModifiedCount += result.ModifiedCount + if len(list) < fillZeroFakeFlowBatchSize { + break + } + } + if resp.ModifiedCount > 0 { + playletcache.ClearHomeCache() + } + return resp, nil +} + +func randomFakeFlowValue(r *rand.Rand, min, max int64) int64 { + if min == max { + return min + } + return min + r.Int63n(max-min+1) +} + +// SyncVideoInfo 同步视频 +func SyncVideoInfo(ctx context.Context, + req *AddVideoReq) *srvdef.SrvErr { + + ids := make([]string, 0) + for _, id := range req.FileIds { + ids = append(ids, id) + } + if len(ids) == 0 { + return nil + } + + queryMediaReq := &proxy.MediaQueryesReq{ + Ids: ids, + } + resp, err := storage.QueryMedias(ctx, queryMediaReq, "") + if err != nil || resp.Code != http.StatusOK { + if resp != nil { + log.Warn("AddVideoInfo.QueryMedias failed", log.Any("filter", req.FileIds), log.E(err), log.Any("respCode", resp.Code)) + } else { + log.Warn("AddVideoInfo.QueryMedias failed", log.Any("filter", req.FileIds), log.E(err)) + } + return srvdef.New(srvdef.ErrMediaNotExsit, err.Error(), err.Error()) + } + if len(resp.Data) == 0 { + return nil + } + for _, media := range resp.Data { + se := SyncOneVideo(ctx, media) + if se != nil { + log.Warn("SyncVideoInfo SYncOneVideo failed", log.Any("filter", req.FileIds), log.E(se)) + continue + } + } + return nil +} + +func mediaFileDomain(sourceVersion string) string { + if sourceVersion == "v1" { + return proxy.BucketVideoAddressV1 + } + return proxy.BucketVideoAddressV2 +} + +func buildMediaRespFromSyncItem(item SyncMediaFileItem) (proxy.MediaResp, error) { + oid, err := primitive.ObjectIDFromHex(item.Id) + if err != nil { + return proxy.MediaResp{}, err + } + return proxy.MediaResp{ + Id: oid, + MediaSourceId: oid, + MediaType: 2, + Title: item.Title, + FieldNameFs: item.FieldNameFs, + FirstTag: item.FirstTag, + SecondTags: item.SecondTags, + Keyworld: item.Keyworld, + VideoTime: item.VideoTime, + CoverImg: item.CoverImg, + Publish: item.Publish, + Bango: item.Bango, + TaskID: item.TaskID, + Width: item.Width, + Height: item.Height, + }, nil +} + +// SyncVideoFromMediaFile 从媒资库直同步到视频列表(不经过切片任务)。 +func SyncVideoFromMediaFile(ctx context.Context, req *SyncMediaFileReq) (*SyncMediaFileResp, *srvdef.SrvErr) { + result := &SyncMediaFileResp{} + if req == nil { + return result, nil + } + minVideoSeconds := req.MinVideoSeconds + + syncOne := func(id string, media proxy.MediaResp) { + if media.Id.IsZero() { + result.Failed++ + return + } + if minVideoSeconds > 0 && media.VideoTime > 0 && media.VideoTime <= minVideoSeconds { + log.Info("SyncVideoFromMediaFile skip short video", + log.Any("id", id), + log.Any("videoTime", media.VideoTime)) + result.Failed++ + return + } + if len(media.FieldNameFs) == 0 { + log.Warn("SyncVideoFromMediaFile empty fieldNameFs", log.Any("id", id)) + result.Failed++ + return + } + media.MediaSourceId = media.Id + media.MediaType = 2 + if req.FirstTagMap != nil { + if firstTag, ok := req.FirstTagMap[id]; ok && len(firstTag) > 0 { + media.FirstTag = firstTag + } + } + if se := SyncOneVideo(ctx, media); se != nil { + log.Warn("SyncVideoFromMediaFile SyncOneVideo failed", log.Any("id", id), log.E(se)) + result.Failed++ + return + } + result.Synced++ + } + + if len(req.VideoList) > 0 { + for _, item := range req.VideoList { + media, err := buildMediaRespFromSyncItem(item) + if err != nil { + log.Warn("SyncVideoFromMediaFile invalid item", log.Any("id", item.Id), log.E(err)) + result.Failed++ + continue + } + syncOne(item.Id, media) + } + return result, nil + } + + domain := mediaFileDomain(req.SourceVersion) + for _, id := range req.FileIds { + if len(id) == 0 { + continue + } + resp, err := storage.QueryMedia(ctx, &proxy.MediaQueryeReq{Id: id}, domain) + if err != nil || resp == nil || resp.Code != proxy.Success { + if resp != nil { + log.Warn("SyncVideoFromMediaFile QueryMedia failed", log.Any("id", id), log.E(err), log.Any("respCode", resp.Code)) + } else { + log.Warn("SyncVideoFromMediaFile QueryMedia failed", log.Any("id", id), log.E(err)) + } + result.Failed++ + continue + } + media := resp.Data + if media.Id.IsZero() { + oid, parseErr := primitive.ObjectIDFromHex(id) + if parseErr != nil { + log.Warn("SyncVideoFromMediaFile invalid id", log.Any("id", id), log.E(parseErr)) + result.Failed++ + continue + } + media.Id = oid + } + syncOne(id, media) + } + return result, nil +} + +// DelVideoInfo 删除指定APP +func DelVideoInfo(ctx context.Context, id int64) *srvdef.SrvErr { + info, err1 := videoinformationmod.FindOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: id, + }, options.FindOne().SetProjection(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + })) + if err1 != nil { + return err1 + } + adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id} + //删除 + count, serr := videoinformationmod.DelOneVideoInformation(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + playletcache.ClearHomeCache() + // 删除后检查一下合集数据 + collectionInfos, _ := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: id, + }) + if len(collectionInfos) > 0 { + collectionIds := make([]int64, 0) + for _, collectionInfo := range collectionInfos { + collectionIds = append(collectionIds, collectionInfo.CollectionID) + } + if len(collectionIds) > 0 { + _ = videoinformationmod.UpdateManyVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": collectionInfos}, + }, bson.M{"$inc": bson.M{ + videoinformationmod.FN.VideoInformation.VideoCount: -1, + }}) + } + } + _, _ = videocollectionmediamod.DelManyVideoCollectionMediaModel(ctx, bson.M{ + videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: id, + }) + if len(info.Tags) > 0 { + oldTagIds := append([]int64{}, info.Tags...) + godef.Go(func() { + _ = srv_contenttag.SyncContentTagBindCount(context.Background(), oldTagIds, nil, contenttagmod.ContentTagTypeVideo) + }) + } + return nil +} + +// UpdateVideoInfo 更新指定APP +func UpdateVideoInfo(ctx context.Context, id, userId int64, param VideoInfoUpdate) *srvdef.SrvErr { + now := time.Now() + info, serr := videoinformationmod.FindOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: id}) + if serr != nil { + return serr + } + param.UpdatedAt = &now + param.SecondUpdatedAt = &now + if param.CreatorId != nil { + name := "" + if *param.CreatorId == 0 { + param.CreatorName = &name + } else { + // 查询作者 + c, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{ + videocreatermod.FN.VideoCreaterInfo.Id: *param.CreatorId, + }) + if serr != nil { + return serr + } + name = c.Name + param.CreatorName = &name + } + } + if param.Tags != nil { + tagIds, err1 := srv_contenttag.NormalizeContentTagIds(*param.Tags) + if err1 != nil { + return err1 + } + if err1 := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err1 != nil { + return err1 + } + param.Tags = &tagIds + } + if param.CategoryRanks != nil { + seen := make(map[int64]bool, len(*param.CategoryRanks)) + for _, rank := range *param.CategoryRanks { + if rank.Id <= 0 || seen[rank.Id] { + return srvdef.New(srvdef.ErrParamError, "分类ID无效或重复", "分类ID无效或重复") + } + seen[rank.Id] = true + if _, err := videocategorymod.FindOneVideoCategory(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: rank.Id}); err != nil { + if err.Code == srvdef.ErrNoData { + return srvdef.New(srvdef.ErrParamError, "视频分类不存在", "视频分类不存在") + } + return err + } + } + } + isChangeActress := checkActressChange(*info, param) + isChangeTag := checkTagChange(*info, param) + if param.ActressIds != nil && len(*param.ActressIds) > 0 { + acts, serr := actress.FindManyActresss(ctx, bson.M{ + actress.FN.Actress.Id: bson.M{"$in": *param.ActressIds}, + }) + if serr != nil { + log.Warn("UpdateVideoInfo FindManyActresss failed", log.Any("filter", param.ActressIds), log.E(serr)) + return serr + } + acIds := make([]int64, 0) + for _, a := range acts { + acIds = append(acIds, a.Id) + } + param.ActressIds = &acIds + } + + if param.Nodes != nil && len(*param.Nodes) > 0 { + nodes := *param.Nodes + for i, node := range nodes { + if len(node.NodeId) == 0 { + node.NodeId = primitive.NewObjectID().Hex() + nodes[i] = node + } + } + param.Nodes = &nodes + } + if param.Cover != nil && len(*param.Cover) > 0 { + if len(info.Video.Url) > 0 { + info.Video.Cover = *param.Cover + param.Video = &info.Video + } + } + if param.Status != nil && *param.Status != info.Status { + isChangeActress = true + tempActressIds := make([]int64, 0) + if param.ActressIds != nil { + tempActressIds = *param.ActressIds + } + go updateActressWorks(info.ActressIds, tempActressIds) + } + title := info.Title + if param.Title != nil { + title = *param.Title + } + keyworld := info.Keyworld + if param.Keyworld != nil { + keyworld = *param.Keyworld + } + exists := func(s string) bool { + doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter( + videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, id)) + return doc != nil + } + slug, changed, slugErr := slugx.PrepareOnUpdate(info.Slug, param.Slug, + utils.SlugSourceFromKeywordsOrTitle(keyworld, title), exists) + if slugErr != nil { + return slugErr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id} + param.IndexNow = nil + isPublished := param.Status != nil && + info.Status != *param.Status && + *param.Status == pubdef.InformationStatusVerifyOK + if isPublished || (param.IsReIndexNow != nil && *param.IsReIndexNow) { + indexNow := int64(0) + param.IndexNow = &indexNow + } + serr = videoinformationmod.UpdateOneVideoInformation(ctx, adFilter, bson.M{"$set": param}) + if serr != nil { + return serr + } + playletcache.ClearHomeCache() + if isChangeActress { + tempActressIds := make([]int64, 0) + if param.ActressIds != nil { + tempActressIds = *param.ActressIds + } + go updateActressWorks(info.ActressIds, tempActressIds) + } + if isChangeTag { + tempIds := make([]int64, 0) + if param.Tags != nil { + tempIds = *param.Tags + } + oldTagIds := append([]int64{}, info.Tags...) + newTagIds := append([]int64{}, tempIds...) + godef.Go(func() { + _ = srv_contenttag.SyncContentTagBindCount(context.Background(), oldTagIds, newTagIds, contenttagmod.ContentTagTypeVideo) + }) + } + if param.Status != nil && info.Status != *param.Status { + if flag := srv_report.VideoStatusFlag(*param.Status); flag != 0 { + mediaCopy := *info + mediaCopy.Status = *param.Status + godef.Go(func() { + srv_report.ReportVideoStatusChangeEvent(&mediaCopy, flag, userId) + }) + } + } + newStatus := info.Status + if param.Status != nil { + newStatus = *param.Status + } + srv_seopush.NotifyInfoAfterWrite(ctx, srv_seopush.KindVideo, id, info.Status, newStatus) + return nil +} + +func checkActressChange(info videoinformationmod.VideoInformation, updateParam VideoInfoUpdate) bool { + if updateParam.ActressIds != nil { + if len(info.ActressIds) != len(*updateParam.ActressIds) { + return true + } + actressIds := *updateParam.ActressIds + for i, v := range info.ActressIds { + if v != actressIds[i] { + return true + } + } + } + return false +} +func checkTagChange(info videoinformationmod.VideoInformation, updateParam VideoInfoUpdate) bool { + if updateParam.Tags != nil { + if len(info.Tags) != len(*updateParam.Tags) { + return true + } + tags := *updateParam.Tags + for i, v := range info.Tags { + if v != tags[i] { + return true + } + } + } + return false +} + +func updateActressWorks(oldIds []int64, newIds []int64) { + defer func() { + if err := recover(); err != nil { + log.Error("updateActressWorks failed", log.Any("err", err)) + } + }() + mapIds := make(map[int64]struct{}) + if oldIds == nil { + oldIds = make([]int64, 0) + } + if newIds == nil { + newIds = make([]int64, 0) + } + for _, id := range newIds { + mapIds[id] = struct{}{} + } + for _, id := range oldIds { + mapIds[id] = struct{}{} + } + ids := make([]int64, 0) + for id := range mapIds { + ids = append(ids, id) + } + ctx := context.Background() + for _, id := range ids { + count, err := videoinformationmod.CountVideoInformations(ctx, bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, videoinformationmod.FN.VideoInformation.ActressIds: bson.M{"$in": []int64{id}}}) + if err == nil { + _ = actress.UpdateOneActress(ctx, bson.M{ + actress.FN.Actress.Id: id, + }, bson.M{ + "$set": bson.M{ + actress.FN.Actress.Works: count, + }, + }) + } + } +} + +func AddVideoCollection(ctx context.Context, param *AddVideoCollectionReq) *srvdef.SrvErr { + tagIds, err1 := srv_contenttag.NormalizeContentTagIds(param.Tags) + if err1 != nil { + return err1 + } + if err1 := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err1 != nil { + return err1 + } + info := videoinformationmod.VideoInformation{ + Id: mongos.Lid.VideoInfo.Get(), + Title: param.Title, + MediaType: videoinformationmod.MediaType_Collection, + AddedTime: time.Now(), + Keyworld: param.Keyworld, + FirstTag: param.FirstTag, + SecondTags: param.SecondTags, + CoverImg: param.CoverImg, + Desc: param.Desc, + Remark: param.Remark, + Nodes: make([]videoinformationmod.Node, 0), + Status: pubdef.InformationStatusWaiting, + Video: videoinformationmod.VideoInfo{}, + Tags: tagIds, + Real: pubdef.Flow{}, + VideoCount: 0, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + } + if err1 := videoinformationmod.InsertOneVideoInformation(ctx, &info); err1 != nil { + return err1 + } + return srv_contenttag.SyncContentTagBindCount(ctx, nil, tagIds, contenttagmod.ContentTagTypeVideo) +} + +// AddVideoTags 批量追加视频统一标签 +func AddVideoTags(ctx context.Context, req *VideoAddTagsReq) *srvdef.SrvErr { + if req == nil || len(req.VideoIds) == 0 || len(req.Tags) == 0 { + return srvdef.New(srvdef.ErrParamError, "视频ID和标签ID不能为空", "") + } + videoIds, err := normalizeFakeFlowIds(req.VideoIds) + if err != nil { + return err + } + tagIds, err := srv_contenttag.NormalizeContentTagIds(req.Tags) + if err != nil { + return err + } + if len(tagIds) == 0 { + return srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil { + return err + } + list, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds}, + }, options.Find().SetProjection(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + })) + if err != nil { + return err + } + if len(list) == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + now := time.Now() + docs := make([]mongo.WriteModel, 0, len(list)) + changes := make([]srv_contenttag.ContentTagBindChange, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + newTagIds, changed := appendMissingVideoTagIds(item.Tags, tagIds) + if !changed { + continue + } + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}). + SetUpdate(bson.M{"$set": bson.M{ + videoinformationmod.FN.VideoInformation.Tags: newTagIds, + videoinformationmod.FN.VideoInformation.UpdatedAt: now, + }})) + changes = append(changes, srv_contenttag.ContentTagBindChange{ + OldTagIds: append([]int64{}, item.Tags...), + NewTagIds: newTagIds, + }) + } + if len(docs) == 0 { + return nil + } + if _, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false)); err != nil { + return err + } + playletcache.ClearHomeCache() + return srv_contenttag.SyncContentTagBindCountBatch(ctx, changes, contenttagmod.ContentTagTypeVideo) +} + +// SetVideoTags 单/批量覆盖设置视频统一标签,并同步新标签表视频绑定数量 +func SetVideoTags(ctx context.Context, req *VideoSetTagsReq) *srvdef.SrvErr { + if req == nil || len(req.Ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "视频ID不能为空", "") + } + if req.TagIds == nil { + return srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") + } + videoIds, err := normalizeFakeFlowIds(req.Ids) + if err != nil { + return err + } + tagIds, err := srv_contenttag.NormalizeContentTagIds(*req.TagIds) + if err != nil { + return err + } + if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil { + return err + } + list, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds}, + }, options.Find().SetProjection(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + })) + if err != nil { + return err + } + if len(list) == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + now := time.Now() + docs := make([]mongo.WriteModel, 0, len(list)) + changes := make([]srv_contenttag.ContentTagBindChange, 0, len(list)) + for _, item := range list { + if item == nil || sameVideoTagIds(item.Tags, tagIds) { + continue + } + docs = append(docs, mongo.NewUpdateOneModel(). + SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}). + SetUpdate(bson.M{"$set": bson.M{ + videoinformationmod.FN.VideoInformation.Tags: tagIds, + videoinformationmod.FN.VideoInformation.UpdatedAt: now, + }})) + changes = append(changes, srv_contenttag.ContentTagBindChange{ + OldTagIds: append([]int64{}, item.Tags...), + NewTagIds: append([]int64{}, tagIds...), + }) + } + if len(docs) == 0 { + return nil + } + if _, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false)); err != nil { + return err + } + playletcache.ClearHomeCache() + return srv_contenttag.SyncContentTagBindCountBatch(ctx, changes, contenttagmod.ContentTagTypeVideo) +} + +func sameVideoTagIds(current, target []int64) bool { + if len(current) != len(target) { + return false + } + for i := range current { + if current[i] != target[i] { + return false + } + } + return true +} + +func appendMissingVideoTagIds(current, addIds []int64) ([]int64, bool) { + result := append([]int64{}, current...) + tagMap := make(map[int64]struct{}, len(current)+len(addIds)) + for _, tagId := range current { + if tagId > 0 { + tagMap[tagId] = struct{}{} + } + } + changed := false + for _, tagId := range addIds { + if _, ok := tagMap[tagId]; ok { + continue + } + tagMap[tagId] = struct{}{} + result = append(result, tagId) + changed = true + } + return result, changed +} diff --git a/web/service/srv_videotag/define.go b/web/service/srv_videotag/define.go new file mode 100644 index 0000000..82d8524 --- /dev/null +++ b/web/service/srv_videotag/define.go @@ -0,0 +1,38 @@ +package srv_videotag + +import ( + "time" + "videoweb/pub/models/videotagmod" + "videoweb/pub/pubdef" +) + +type VideoTagInfoReq struct { + pubdef.Page + Id *int64 `json:"id"` + Title *string `json:"title"` +} +type VideoTagInfoResp struct { + Total int64 `json:"total"` + List videotagmod.VideoTagInfoList `json:"list"` +} +type VideoTagInfoUpdate struct { + AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id + Title *string `json:"title" bson:"title,omitempty"` // 帖子标题 + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序 + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + Works *int64 `json:"works" bson:"works,omitempty"` // 作品数量 + MediaTypes *[]int64 `json:"mediaTypes" bson:"mediaTypes"` // 作者关联的资源类型 1视频 + Images *string `json:"images" bson:"images,omitempty"` // 背景 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` + IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引 + IndexNow *int64 `bson:"indexNow,omitempty"` +} +type VideoTagInfoUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param VideoTagInfoUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type VideoTagInfoDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_videotag/hottag.go b/web/service/srv_videotag/hottag.go new file mode 100644 index 0000000..451898f --- /dev/null +++ b/web/service/srv_videotag/hottag.go @@ -0,0 +1,103 @@ +package srv_videotag + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/ipdailystatmod" + "videoweb/pub/models/videotagmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// HotTagReq 热门标签请求参数 +type HotTagReq struct { + Limit int64 `form:"limit" json:"limit"` // 返回条数,默认 10,最大 1000 +} + +// HotTagItem 热门标签结果 +type HotTagItem struct { + TagId int64 `json:"tagId"` + Title string `json:"title"` + WatchTimes int64 `json:"watchTimes"` +} + +// HotTagResp 热门标签返回 +type HotTagResp struct { + List []*HotTagItem `json:"list"` +} + +// GetHotTagsByPlayData 按预计算的 watchTimes 直接取 TopN(O(logN),依赖 {status:1, watchTimes:-1} 索引) +func GetHotTagsByPlayData(ctx context.Context, req *HotTagReq) (*HotTagResp, *srvdef.SrvErr) { + limit := req.Limit + if limit <= 0 { + limit = 10 + } + if limit > 1000 { + limit = 1000 + } + + resp := &HotTagResp{List: []*HotTagItem{}} + + filter := bson.M{ + videotagmod.FN.VideoTagInfo.Status: true, + videotagmod.FN.VideoTagInfo.WatchTimes: bson.M{"$gt": 0}, + } + opts := options.Find(). + SetSort(bson.D{ + {Key: videotagmod.FN.VideoTagInfo.WatchTimes, Value: -1}, + {Key: videotagmod.FN.VideoTagInfo.Id, Value: 1}, + }). + SetLimit(limit) + + tags, serr := videotagmod.FindManyVideoTagInfos(ctx, filter, opts) + if serr != nil { + return resp, serr + } + + for _, t := range tags { + resp.List = append(resp.List, &HotTagItem{ + TagId: t.Id, + Title: t.Title, + WatchTimes: t.WatchTimes, + }) + } + return resp, nil +} + +type IpStatReq struct { + TodayIP int64 `json:"todayIP"` + YesterdayIP int64 `json:"yesterdayIP"` +} + +// FetchIpStat 从 MongoDB ip_daily_stat 集合读取 app 端同步过来的 IP 去重数。 +// app 端中间件收集 IP 存入本地 Redis,定时任务将 SCard 写入共享 MongoDB,web 在此读取。 +// 日期统一按北京时间(Asia/Shanghai)计算。 +func FetchIpStat(ctx context.Context) *IpStatReq { + ipStatReq := &IpStatReq{ + TodayIP: 0, + YesterdayIP: 0, + } + now := time.Now() + bjNow := now.In(utils.LocBJ) + today := bjNow.Format("20060102") + yesterday := bjNow.AddDate(0, 0, -1).Format("20060102") + + if cnt, err := ipdailystatmod.FindCount(ctx, pubdef.AppId, today); err == nil { + ipStatReq.TodayIP = cnt + } else { + log.Warn("FetchIpStat: FindCount today failed", + log.Any("appId", pubdef.AppId), log.E(err)) + } + if cnt, err := ipdailystatmod.FindCount(ctx, pubdef.AppId, yesterday); err == nil { + ipStatReq.YesterdayIP = cnt + } else { + log.Warn("FetchIpStat: FindCount yesterday failed", + log.Any("appId", pubdef.AppId), log.E(err)) + } + return ipStatReq +} diff --git a/web/service/srv_videotag/taginfo.go b/web/service/srv_videotag/taginfo.go new file mode 100644 index 0000000..e192cba --- /dev/null +++ b/web/service/srv_videotag/taginfo.go @@ -0,0 +1,167 @@ +package srv_videotag + +import ( + "context" + "strings" + "sync" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotagmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + MediaTypeVideo int64 = 1 + MediaTypePost int64 = 2 +) + +var firstTagMu sync.Mutex + +// FindOrCreateByTitle 查询或创建同步数据中的一级标签。 +func FindOrCreateByTitle(ctx context.Context, title string, mediaType int64) (int64, *srvdef.SrvErr) { + title = strings.TrimSpace(title) + if title == "" { + return 0, nil + } + + firstTagMu.Lock() + defer firstTagMu.Unlock() + + filter := bson.M{videotagmod.FN.VideoTagInfo.Title: title} + tag, serr := videotagmod.FindOneVideoTagInfo(ctx, filter) + if serr == nil { + if mediaType > 0 && !containsMediaType(tag.MediaTypes, mediaType) { + if serr = videotagmod.UpdateOneVideoTagInfo(ctx, filter, bson.M{ + "$addToSet": bson.M{videotagmod.FN.VideoTagInfo.MediaTypes: mediaType}, + "$set": bson.M{videotagmod.FN.VideoTagInfo.UpdatedAt: time.Now()}, + }); serr != nil { + return 0, serr + } + } + return tag.Id, nil + } + if serr.Code != srvdef.ErrNoData { + return 0, serr + } + + now := time.Now() + tag = &videotagmod.VideoTagInfo{ + Id: mongos.Lid.VideoTag.Get(), + Title: title, + Status: true, + MediaTypes: []int64{mediaType}, + IndexNow: 0, + CreatedAt: now, + UpdatedAt: now, + } + if serr = videotagmod.InsertOneVideoTagInfo(ctx, tag); serr != nil { + return 0, serr + } + return tag.Id, nil +} + +// AppendTagID 向标签列表追加 ID,并避免重复。 +func AppendTagID(tags []int64, tagID int64) []int64 { + if tagID <= 0 { + return tags + } + for _, id := range tags { + if id == tagID { + return tags + } + } + return append(tags, tagID) +} + +func containsMediaType(mediaTypes []int64, mediaType int64) bool { + for _, value := range mediaTypes { + if value == mediaType { + return true + } + } + return false +} + +// GetVideoTagInfos 按照参数搜索视频标签 +func GetVideoTagInfos(ctx context.Context, param *VideoTagInfoReq) (*VideoTagInfoResp, *srvdef.SrvErr) { + filter := bson.M{} + opt := &options.FindOptions{} + resp := &VideoTagInfoResp{} + if param.Id != nil { + filter[videotagmod.FN.VideoTagInfo.Id] = *param.Id + } + if param.Title != nil { + filter[videotagmod.FN.VideoTagInfo.Title] = bson.M{"$regex": *param.Title} + } + + opt.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Works: -1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, serr := videotagmod.CountVideoTagInfos(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, serr := videotagmod.FindManyVideoTagInfos(ctx, filter, opt) + if serr != nil { + return resp, serr + } + resp.List = ads + + return resp, nil +} + +// AddVideoTagInfo 添加一视频标签 +func AddVideoTagInfo(ctx context.Context, + ac *videotagmod.VideoTagInfo) *srvdef.SrvErr { + + ac.Id = mongos.Lid.VideoTag.Get() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + serr := videotagmod.InsertOneVideoTagInfo(ctx, ac) + if serr != nil { + return serr + } + return nil +} + +// DelVideoTagInfo 删除指定视频标签 +func DelVideoTagInfo(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{videotagmod.FN.VideoTagInfo.Id: id} + //删除 + count, serr := videotagmod.DelOneVideoTagInfo(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateVideoTagInfo 更新指定视频标签 +func UpdateVideoTagInfo(ctx context.Context, id int64, param VideoTagInfoUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + adFilter := bson.M{videotagmod.FN.VideoTagInfo.Id: id} + param.IndexNow = nil + if param.IsReIndexNow != nil && *param.IsReIndexNow { + indexNow := int64(0) + param.IndexNow = &indexNow + } + updateDoc := bson.M{"$set": param} + serr := videotagmod.UpdateOneVideoTagInfo(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + return nil +} diff --git a/web/skd/googleanalytics/analytics.go b/web/skd/googleanalytics/analytics.go new file mode 100644 index 0000000..a741d8d --- /dev/null +++ b/web/skd/googleanalytics/analytics.go @@ -0,0 +1,305 @@ +package googleanalytics + +import ( + "context" + "os" + "strconv" + "time" + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/analyticsdailystat" + "videoweb/pub/models/analyticsstat" + "videoweb/web/webg" + + "go.mongodb.org/mongo-driver/bson" + analyticsdata "google.golang.org/api/analyticsdata/v1beta" + "google.golang.org/api/option" +) + +// HourlyData 用于存储和排序每小时的数据 +type HourlyData struct { + Hour int + Users int64 +} +type ChannelAnalyzeReq struct { + Channel string `from:"channel" json:"channel" binding:"required"` // 渠道 + Begin time.Time `json:"begin" form:"begin"` // 开始时间 + End time.Time `json:"end" form:"end"` // 结束时间 + AppId *int32 `from:"appId" json:"appId" ` // 应用id +} + +type AnalyzeItem struct { + Channel string `json:"channel" form:"channel"` // 渠道 + AppId int32 `json:"appId" form:"appId"` // 应用ID + AppName string `json:"appName" form:"appName"` // 应用名称 + Begin time.Time `json:"begin" form:"begin"` // 开始时间 + End time.Time `json:"end" form:"end"` // 结束时间 + RegisterCount int64 `json:"registerCount" form:"registerCount"` // 总注册IP数 + RegisterIpCount int64 `json:"registerIpCount" form:"registerIpCount"` // 总注册IP数 + VisitCount int64 `json:"visitCount" form:"visitCount"` // 总访问数 + AdvertiseClick int64 `json:"advertiseClick" form:"advertiseClick"` // 广告点击数 + RechargeCount int64 `json:"rechargeCount" form:"rechargeCount"` // 充值次数 + RechargeAmount int64 `json:"rechargeAmount" form:"rechargeAmount"` // 充值金额 + RechargeUsers int64 `json:"rechargeUsers" form:"rechargeUsers"` // 充值用户数 + YesTodayRegisterCount int64 `json:"yesTodayRegisterCount" form:"yesTodayRegisterCount"` // 昨日注册IP数 + YesTodayVisitCount int64 `json:"yesTodayVisitCount" form:"yesTodayVisitCount"` // 昨日访问数 + YesTodayRechargeAmount int64 `json:"yesTodayRechargeAmount" form:"yesTodayRechargeAmount"` // 昨日充值 + YesTodayRetainRate int64 `json:"yesTodayRetainRate" form:"yesTodayRetainRate"` // 昨日次流 +} +type ChannelAnalyzeResp struct { + List []*AnalyzeItem `json:"list" form:"list"` +} +type AnalyzeResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data ChannelAnalyzeResp `json:"data"` +} + +func AnalyticsData(appId int64, appName string, propertyId string) { + ctx := context.Background() + now := time.Now() + // 1. 认证授权 - 使用服务账号的 JSON 密钥文件 + credentialsFile := "composed-anvil-470512-r8-fd674ac0d7dc.json" + b, err := os.ReadFile(credentialsFile) + if err != nil { + log.Error("AnalyticsData 读取凭证文件失败", log.E(err)) + return + } + + curStatTime := utils.RecentMinute(now.Add(time.Minute), 30) // 按20分钟对齐 + + data := analyticsstat.AnalyticsInfo{ + AppId: appId, + SumDate: curStatTime, + AppName: appName, + DaySumDate: utils.BeginOfTime(curStatTime), + CreatedAt: now, + UpdatedAt: now, + } + // 2. 创建 API 客户端 + client, err := analyticsdata.NewService(ctx, option.WithCredentialsJSON(b)) + if err != nil { + log.Error("AnalyticsData 无法创建 Analytics Data 客户端", log.E(err)) + return + } + if client == nil { + log.Error("AnalyticsData client 为空") + return + } + + req := &analyticsdata.RunRealtimeReportRequest{ + // 虽然实时报告默认就是最近30分钟,但你可以通过 dateRanges 进行限制(如果需要的话) + // DateRanges: []*analyticsdata.DateRange{ + // {StartDate: "30 minutes ago", EndDate: "now"}, // 某些客户端库可能支持相对时间 + // }, + Dimensions: []*analyticsdata.Dimension{ + // 添加你关心的维度,例如国家或地区 + {Name: "country"}, + // 实时报告支持的维度有限,请参考官方文档: + // https://developers.google.com/analytics/devguides/reporting/data/v1/realtime-basics?hl=zh-cn + }, + Metrics: []*analyticsdata.Metric{ + {Name: "activeUsers"}, // 获取活跃用户数 + }, + } + + // 4. 调用 RealTime API + resp, err := client.Properties.RunRealtimeReport(propertyId, req).Do() + if err != nil { + log.Error("AnalyticsData RunRealtimeReport 请求报告出错", log.E(err)) + return + } + if resp == nil { + log.Error("AnalyticsData RunRealtimeReport resp 为空") + return + } + + for _, row := range resp.Rows { + if row == nil || len(row.MetricValues) == 0 || row.MetricValues[0] == nil { + continue + } + activeUsers, _ := strconv.ParseInt(row.MetricValues[0].Value, 10, 64) + data.ActiveUsers += activeUsers + } + + yestodayReq := &analyticsdata.RunReportRequest{ + DateRanges: []*analyticsdata.DateRange{ + {StartDate: "yesterday", EndDate: "yesterday"}, // 查询昨天 + }, + Dimensions: []*analyticsdata.Dimension{ + {Name: "date"}, // 核心:添加日期维度,按天分组 + }, + Metrics: []*analyticsdata.Metric{ + {Name: "screenPageViews"}, + {Name: "totalUsers"}, + {Name: "activeUsers"}, // 获取活跃用户数 + {Name: "sessions"}, // 会话数 + {Name: "newUsers"}, // 新增数量 + }, + } + + yestodayResp, err := client.Properties.RunReport(propertyId, yestodayReq).Do() + if err != nil { + log.Error("AnalyticsData RunReport 请求报告出错", log.E(err)) + return + } + if yestodayResp == nil { + log.Error("AnalyticsData RunReport yestodayResp 为空") + return + } + for _, row := range yestodayResp.Rows { + if row == nil || len(row.MetricValues) < 5 { + continue + } + data.YestodayPV, _ = strconv.ParseInt(row.MetricValues[0].Value, 10, 64) + data.YestodayUV, _ = strconv.ParseInt(row.MetricValues[1].Value, 10, 64) + data.YestodayActiveUsers, _ = strconv.ParseInt(row.MetricValues[2].Value, 10, 64) + data.YestodaySessions, _ = strconv.ParseInt(row.MetricValues[3].Value, 10, 64) + data.YestodayNewUsers, _ = strconv.ParseInt(row.MetricValues[4].Value, 10, 64) + } + + analyticsstat.InsertOneAnalyticsInfo(ctx, &data) +} + +func AnalyticsDailyData(appId int64, appName string, channel string, propertyId string) { + ctx := context.Background() + now := time.Now() + // 1. 认证授权 - 使用服务账号的 JSON 密钥文件 + credentialsFile := "composed-anvil-470512-r8-fd674ac0d7dc.json" + b, err := os.ReadFile(credentialsFile) + if err != nil { + log.Error("AnalyticsDailyData 读取凭证文件失败", log.E(err)) + return + } + + // 2. 创建 API 客户端 + client, err := analyticsdata.NewService(ctx, option.WithCredentialsJSON(b)) + if err != nil { + log.Error("AnalyticsDailyData 无法创建 Analytics Data 客户端", log.E(err)) + return + } + if client == nil { + log.Error("AnalyticsDailyData client 为空") + return + } + + data := analyticsdailystat.AnalyticsDailyInfo{ + AppId: appId, + SumDate: utils.BeginOfTime(now), + AppName: appName, + CreatedAt: now, + UpdatedAt: now, + } + + yestodayReq := &analyticsdata.RunReportRequest{ + DateRanges: []*analyticsdata.DateRange{ + {StartDate: "7daysAgo", EndDate: "today"}, // 查询昨天 + }, + Dimensions: []*analyticsdata.Dimension{ + {Name: "date"}, // 核心:添加日期维度,按天分组 + }, + Metrics: []*analyticsdata.Metric{ + {Name: "screenPageViews"}, + {Name: "totalUsers"}, + {Name: "activeUsers"}, // 获取活跃用户数 + {Name: "sessions"}, // 会话数 + {Name: "newUsers"}, // 新增数量 + }, + } + + yestodayResp, err := client.Properties.RunReport(propertyId, yestodayReq).Do() + if err != nil { + log.Error("AnalyticsDailyData RunReport 请求报告出错", log.E(err)) + return + } + if yestodayResp == nil { + log.Error("AnalyticsDailyData RunReport yestodayResp 为空") + return + } + for _, row := range yestodayResp.Rows { + if row == nil || len(row.DimensionValues) == 0 || row.DimensionValues[0] == nil || len(row.MetricValues) < 5 { + continue + } + // data.SumDate + layout := "20060102" // Go 的特定格式:2006=年,01=月,02=日 + + // 解析字符串为时间对象 + dayTime, err := time.Parse(layout, row.DimensionValues[0].Value) + if err != nil { + log.Error("AnalyticsDailyData time.Parse 失败", log.E(err)) + continue + } + data.SumDate = dayTime.Add(-8 * time.Hour) + data.YestodayPV, _ = strconv.ParseInt(row.MetricValues[0].Value, 10, 64) + data.YestodayUV, _ = strconv.ParseInt(row.MetricValues[1].Value, 10, 64) + data.YestodayActiveUsers, _ = strconv.ParseInt(row.MetricValues[2].Value, 10, 64) + data.YestodaySessions, _ = strconv.ParseInt(row.MetricValues[3].Value, 10, 64) + data.YestodayNewUsers, _ = strconv.ParseInt(row.MetricValues[4].Value, 10, 64) + + LastFilter := bson.M{ + analyticsdailystat.FN.AnalyticsDailyInfo.SumDate: data.SumDate, + analyticsdailystat.FN.AnalyticsDailyInfo.AppId: appId, + } + + _, serr := analyticsdailystat.FindOneAnalyticsDailyInfo(ctx, LastFilter) + if serr != nil && serr.Code == srvdef.ErrNoData { + analyticsdailystat.InsertOneAnalyticsDailyInfo(ctx, &data) + } else { + LastUpdate := bson.M{ + "$set": bson.M{ + analyticsdailystat.FN.AnalyticsDailyInfo.YestodayPV: data.YestodayPV, + analyticsdailystat.FN.AnalyticsDailyInfo.YestodayUV: data.YestodayUV, + analyticsdailystat.FN.AnalyticsDailyInfo.YestodayActiveUsers: data.YestodayActiveUsers, + analyticsdailystat.FN.AnalyticsDailyInfo.YestodaySessions: data.YestodaySessions, + analyticsdailystat.FN.AnalyticsDailyInfo.YestodayNewUsers: data.YestodayNewUsers, + analyticsdailystat.FN.AnalyticsDailyInfo.UpdatedAt: now, + }, + } + analyticsdailystat.UpdateOneAnalyticsDailyInfo(ctx, LastFilter, LastUpdate) + } + } + + analyzeList := ChannelAnalyzeResp{} + req := ChannelAnalyzeReq{ + Channel: channel, + Begin: utils.BeginOfTime(now.AddDate(0, 0, -1)), + End: utils.EndOfTime(now.AddDate(0, 0, -1)), + } + + param, _ := utils.ToJsonM(req) + innerStatUrl := webg.Conf.NewInnerStatUrl + "/api/stat/dailyStat/channel/analyze" + resp := AnalyzeResp{} + code, err := httputil.Client().POSTJsonWithJResp(&resp, innerStatUrl, nil, param) + if err != nil { + log.Error("ChannelAnalyze httputil innerStatUrl failed!", log.E(err)) + } + if code != 200 { + log.Error("ChannelAnalyze httputil innerStatUrl code1!=200", log.Any("code", code)) + } + log.Info("ChannelAnalyze", log.Any("req", req), log.Any("innerStatUrl", innerStatUrl), log.Any("analyzeList", resp.Data)) + analyzeList = resp.Data + + for _, channelAnalyze := range analyzeList.List { + data.LeadNewCount += float64(channelAnalyze.RegisterCount) + data.LeadAmount += float64(channelAnalyze.RechargeAmount) + } + LastFilter := bson.M{ + analyticsdailystat.FN.AnalyticsDailyInfo.SumDate: utils.BeginOfTime(now), + analyticsdailystat.FN.AnalyticsDailyInfo.AppId: appId, + } + _, serr := analyticsdailystat.FindOneAnalyticsDailyInfo(ctx, LastFilter) + if serr != nil && serr.Code == srvdef.ErrNoData { + analyticsdailystat.InsertOneAnalyticsDailyInfo(ctx, &data) + } else { + LastUpdate := bson.M{ + "$set": bson.M{ + analyticsdailystat.FN.AnalyticsDailyInfo.LeadNewCount: data.LeadNewCount, + analyticsdailystat.FN.AnalyticsDailyInfo.LeadAmount: data.LeadAmount, + }, + } + + analyticsdailystat.UpdateOneAnalyticsDailyInfo(ctx, LastFilter, LastUpdate) + } +} diff --git a/web/skd/job/reportdata/event.go b/web/skd/job/reportdata/event.go new file mode 100644 index 0000000..241a9c3 --- /dev/null +++ b/web/skd/job/reportdata/event.go @@ -0,0 +1,124 @@ +package reportdata + +import ( + "context" + "encoding/json" + "videoweb/pub/common/log" + "videoweb/pub/dataReport" + "videoweb/pub/models/eventmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var isRuining = false + +const ( + maxRetryCount = 10 // 最大重试次数 + maxReportNum = 100 // 最大上报数 +) + +func Report() { + //防止重入 + if isRuining { + return + } + isRuining = true + defer func() { + isRuining = false + }() + ctx := context.Background() + opts := options.Find() + opts = opts.SetSort(bson.D{{Key: "_id", Value: 1}}).SetLimit(1000) + // 查询出一千条记录批量上传 + list, stdErr := eventmod.FindManyEvent(ctx, bson.M{ + "$or": []bson.M{ + bson.M{ + "status": eventmod.Default, + }, + bson.M{ + "status": eventmod.Failure, + "retryCount": bson.M{"$lte": maxRetryCount}, + }, + }, + }, opts) + if stdErr != nil { + log.Error("Get event list error", log.E(stdErr)) + return + } + if len(list) == 0 { + return + } + var eventList []*dataReport.CommonField + for _, v := range list { + event := &dataReport.CommonField{} + err := json.Unmarshal([]byte(v.Data), &event) + if err != nil { + log.Error("event report json.Unmarshal fail", log.Any("eventData", v.Data), log.E(err)) + continue + } + eventList = append(eventList, event) + if len(eventList) < maxReportNum { + continue + } + // 批量上报 + err = dataReport.BatchReport(ctx, eventList) + if err != nil { + log.Error("Batch report error", log.E(err)) + // 将这些设置为失败 + onReportEventFail(ctx, eventList) + } else { + onReportEventSuccess(ctx, eventList) + } + eventList = []*dataReport.CommonField{} // 重新变成0个 + } + + // 将剩余的批量上报 + err := dataReport.BatchReport(ctx, eventList) + if err != nil { + log.Error("Batch report error", log.E(err)) + // 将这些设置为失败 + onReportEventFail(ctx, eventList) + } else { + onReportEventSuccess(ctx, eventList) + } +} + +func onReportEventSuccess(ctx context.Context, eventList []*dataReport.CommonField) { + eventIds := []string{} + for _, v := range eventList { + eventIds = append(eventIds, v.EventID) + } + if len(eventIds) == 0 { + return + } + err := eventmod.UpdateManyEvent(ctx, bson.M{ + "eventId": bson.M{"$in": eventIds}, + }, bson.M{ + "$set": bson.M{"status": eventmod.Success}, + }) + if err != nil { + log.Error("Update event fail", log.E(err)) + return + } +} + +func onReportEventFail(ctx context.Context, eventList []*dataReport.CommonField) { + eventIds := []string{} + for _, v := range eventList { + eventIds = append(eventIds, v.EventID) + } + if len(eventIds) == 0 { + return + } + err := eventmod.UpdateManyEvent(ctx, bson.M{ + "eventId": bson.M{"$in": eventIds}, + }, bson.M{ + "$set": bson.M{"status": eventmod.Failure}, + "$inc": bson.M{"retryCount": 1}, + }) + if err != nil { + log.Error("Update event fail", log.E(err)) + return + } +} diff --git a/web/skd/job/seopush/seopush.go b/web/skd/job/seopush/seopush.go new file mode 100644 index 0000000..2078e3c --- /dev/null +++ b/web/skd/job/seopush/seopush.go @@ -0,0 +1,230 @@ +// Package seopush 为搜索引擎推送做兜底:补推已上架内容,并重试失败的任务。 +package seopush + +import ( + "context" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/seopushmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + "videoweb/web/service/srv_seopush" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + backfillLimit = 100 + retryLimit = 50 + taskKeepDays = 30 +) + +var jobMu sync.Mutex + +// Run 定时兜底任务 +func Run() { + if !jobMu.TryLock() { + log.Warn("seopush: skip job, previous run still in progress") + return + } + defer jobMu.Unlock() + + ctx := context.Background() + if !srv_seopush.Enabled() { + discardPendingTasks(ctx) + return + } + webAddr := srv_seopush.SEOBaseURL(ctx) + if webAddr == "" { + return + } + backfillVideos(ctx) + backfillPlaylets(ctx) + retryPendingTasks(ctx, webAddr) + cleanupExpiredTasks(ctx) +} + +func discardPendingTasks(ctx context.Context) { + filter := bson.M{"$or": []bson.M{ + {seopushmod.FN.SeoPushTask.GoogleStatus: seopushmod.PushStatusPending}, + {seopushmod.FN.SeoPushTask.IndexNowStatus: seopushmod.PushStatusPending}, + }} + list, serr := seopushmod.FindManySeoPushTasks(ctx, filter) + if serr != nil { + log.Warn("seopush: find pending tasks to discard failed", log.E(serr)) + return + } + if len(list) == 0 { + return + } + byKind := map[string][]int64{} + seen := map[string]map[int64]struct{}{} + for _, task := range list { + if task == nil || task.VideoId <= 0 { + continue + } + kind := kindFromTaskURL(task.Url) + if kind == "" { + continue + } + if seen[kind] == nil { + seen[kind] = make(map[int64]struct{}) + } + if _, ok := seen[kind][task.VideoId]; ok { + continue + } + seen[kind][task.VideoId] = struct{}{} + byKind[kind] = append(byKind[kind], task.VideoId) + } + count, serr := seopushmod.DelManySeoPushTask(ctx, filter) + if serr != nil { + log.Warn("seopush: discard pending tasks failed", log.E(serr)) + return + } + resetIndexNow(ctx, srv_seopush.KindVideo, byKind[srv_seopush.KindVideo]) + resetIndexNow(ctx, srv_seopush.KindPlaylet, byKind[srv_seopush.KindPlaylet]) + log.Info("seopush=skip=disabled", log.Any("discarded", count)) +} + +func kindFromTaskURL(pageURL string) string { + switch { + case strings.Contains(pageURL, "/video-detail"): + return srv_seopush.KindVideo + case strings.Contains(pageURL, "/drama-detail"): + return srv_seopush.KindPlaylet + default: + return "" + } +} + +func resetIndexNow(ctx context.Context, kind string, ids []int64) { + if len(ids) == 0 { + return + } + in := bson.M{"$in": ids} + setZero := bson.M{"$set": bson.M{}} + var serr error + switch kind { + case srv_seopush.KindVideo: + setZero["$set"] = bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(0)} + serr = videoinformationmod.UpdateManyVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: in}, setZero) + case srv_seopush.KindPlaylet: + setZero["$set"] = bson.M{playletmod.FN.Playlet.IndexNow: int64(0)} + serr = playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: in}, setZero) + } + if serr != nil { + log.Warn("seopush: reset indexNow failed", log.Any("kind", kind), log.E(serr)) + } +} + +func indexNowPendingFilter(statusField, indexNowField string, statusVal interface{}) bson.M { + return bson.M{ + statusField: statusVal, + "$or": []bson.M{ + {indexNowField: 0}, + {indexNowField: bson.M{"$exists": false}}, + }, + } +} + +func backfillVideos(ctx context.Context) { + opt := options.Find(). + SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1}). + SetLimit(backfillLimit) + list, serr := videoinformationmod.FindManyVideoInformations(ctx, indexNowPendingFilter( + videoinformationmod.FN.VideoInformation.Status, + videoinformationmod.FN.VideoInformation.IndexNow, + pubdef.InformationStatusVerifyOK, + ), opt) + if serr != nil { + log.Warn("seopush: backfill find videos failed", log.E(serr)) + return + } + if len(list) == 0 { + return + } + for _, item := range list { + if item == nil { + continue + } + srv_seopush.RegisterUpdated(ctx, srv_seopush.KindVideo, item.Id, srv_seopush.PageVisibleInfo(item.Status)) + } + log.Info("seopush: backfill videos registered", log.Any("count", len(list))) +} + +func backfillPlaylets(ctx context.Context) { + opt := options.Find(). + SetSort(bson.M{playletmod.FN.Playlet.OnlineAt: -1}). + SetLimit(backfillLimit) + list, serr := playletmod.FindManyPlaylet(ctx, indexNowPendingFilter( + playletmod.FN.Playlet.Status, + playletmod.FN.Playlet.IndexNow, + playletmod.StatusUp, + ), opt) + if serr != nil { + log.Warn("seopush: backfill find playlets failed", log.E(serr)) + return + } + if len(list) == 0 { + return + } + for _, item := range list { + if item == nil { + continue + } + srv_seopush.RegisterUpdated(ctx, srv_seopush.KindPlaylet, item.Id, + srv_seopush.PageVisiblePlaylet(item.Status, item.OnlineAt)) + } + log.Info("seopush: backfill playlets registered", log.Any("count", len(list))) +} + +func retryPendingTasks(ctx context.Context, webAddr string) { + conds := []bson.M{ + { + seopushmod.FN.SeoPushTask.GoogleStatus: seopushmod.PushStatusPending, + seopushmod.FN.SeoPushTask.GoogleRetry: bson.M{"$lt": seopushmod.MaxPushRetry}, + }, + { + seopushmod.FN.SeoPushTask.IndexNowStatus: seopushmod.PushStatusPending, + seopushmod.FN.SeoPushTask.IndexNowRetry: bson.M{"$lt": seopushmod.MaxPushRetry}, + }, + } + opt := options.Find(). + SetSort(bson.M{seopushmod.FN.SeoPushTask.CreatedAt: 1}). + SetLimit(retryLimit) + taskList, serr := seopushmod.FindManySeoPushTasks(ctx, bson.M{"$or": conds}, opt) + if serr != nil { + log.Warn("seopush: find pending tasks failed", log.E(serr)) + return + } + if len(taskList) == 0 { + return + } + for _, task := range taskList { + if task == nil || strings.Contains(task.Url, "?") { + continue + } + srv_seopush.PushTask(ctx, webAddr, task) + } + log.Info("seopush: pending tasks processed", log.Any("count", len(taskList))) +} + +func cleanupExpiredTasks(ctx context.Context) { + deadline := time.Now().AddDate(0, 0, -taskKeepDays) + count, serr := seopushmod.DelManySeoPushTask(ctx, bson.M{ + seopushmod.FN.SeoPushTask.UpdatedAt: bson.M{"$lt": deadline}, + }) + if serr != nil { + log.Warn("seopush: cleanup expired tasks failed", log.E(serr)) + return + } + if count > 0 { + log.Info("seopush: expired tasks cleaned", log.Any("count", count)) + } +} diff --git a/web/skd/job/transcode_video.go b/web/skd/job/transcode_video.go new file mode 100644 index 0000000..8ba5755 --- /dev/null +++ b/web/skd/job/transcode_video.go @@ -0,0 +1,108 @@ +package job + +import ( + "context" + "strings" + "sync/atomic" + "videoweb/pub/common/laosiji" + "videoweb/pub/common/log" + "videoweb/pub/models/videoinformationmod" + "videoweb/web/service/srv_videoinfo" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const transcodeBatchSize = 5 +const transcodeMaxFailCount = 3 + +var transcodeRunning int32 +var transcodeCheckRunning int32 + +const transcodeCheckBatchSize = 100 + +// TranscodeVideoByWatchTimes 按观看次数挑选尚未转码成功的视频,批量提交 H265 转码。 +func TranscodeVideoByWatchTimes() { + if !atomic.CompareAndSwapInt32(&transcodeRunning, 0, 1) { + log.Info("TranscodeVideoByWatchTimes skip: previous run still running") + return + } + defer atomic.StoreInt32(&transcodeRunning, 0) + + ctx := context.Background() + if laosiji.LaosijiClient == nil || laosiji.LaosijiDomains == nil { + log.Warn("TranscodeVideoByWatchTimes skip: laosiji not ready") + return + } + if _, err := laosiji.LaosijiDomains.Get(ctx); err != nil { + log.Warn("TranscodeVideoByWatchTimes skip: domains not ready", log.E(err)) + return + } + + filter := bson.M{ + videoinformationmod.FN.VideoInformation.HevcStatus: bson.M{ + "$in": bson.A{videoinformationmod.HevcStatusNone, videoinformationmod.HevcStatusFailed}, + }, + videoinformationmod.FN.VideoInformation.HevcFailCount: bson.M{"$lt": transcodeMaxFailCount}, + videoinformationmod.FN.VideoInformation.Video.Url: bson.M{"$ne": ""}, + } + opts := options.Find(). + SetSort(bson.D{{Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: -1}}). + SetLimit(transcodeBatchSize) + + videoList, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if serr != nil { + log.Error("TranscodeVideoByWatchTimes find video failed", log.Any("serr", serr)) + return + } + if len(videoList) == 0 { + return + } + + log.Info("TranscodeVideoByWatchTimes start", log.Any("count", len(videoList))) + for _, v := range videoList { + src := strings.TrimSpace(v.Video.Url) + if src == "" { + continue + } + if err := srv_videoinfo.SubmitTranscodeVideo(ctx, v.Id, src, v.Video.FileId); err != nil { + log.Warn("TranscodeVideoByWatchTimes submit failed", + log.Any("videoId", v.Id), log.Any("watchTimes", v.Real.WatchTimes), log.E(err)) + } + } + log.Info("TranscodeVideoByWatchTimes done", log.Any("count", len(videoList))) +} + +// CheckTranscodingResults 查询转码中任务并回写结果。 +func CheckTranscodingResults() { + if !atomic.CompareAndSwapInt32(&transcodeCheckRunning, 0, 1) { + log.Info("CheckTranscodingResults skip: previous run still running") + return + } + defer atomic.StoreInt32(&transcodeCheckRunning, 0) + + ctx := context.Background() + if laosiji.LaosijiClient == nil || laosiji.LaosijiDomains == nil { + return + } + + filter := bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusPending} + opts := options.Find().SetLimit(transcodeCheckBatchSize) + videoList, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if serr != nil { + log.Error("CheckTranscodingResults find video failed", log.Any("serr", serr)) + return + } + if len(videoList) == 0 { + return + } + for _, v := range videoList { + src := strings.TrimSpace(v.Video.Url) + if src == "" { + continue + } + if err := srv_videoinfo.RefreshTranscodeVideo(ctx, v.Id, src, v.Video.FileId); err != nil { + log.Warn("CheckTranscodingResults refresh failed", log.Any("videoId", v.Id), log.E(err)) + } + } +} diff --git a/web/skd/playletwatermark/expire.go b/web/skd/playletwatermark/expire.go new file mode 100644 index 0000000..dc1a965 --- /dev/null +++ b/web/skd/playletwatermark/expire.go @@ -0,0 +1,38 @@ +package playletwatermark + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletchaptermod" + + "go.mongodb.org/mongo-driver/bson" +) + +const watermarkStuckTimeout = 48 * time.Hour + +// MarkStuckChaptersFailed 兜底:已登记/处理中超过 2 天仍未完成的剧集标记为处理失败。 +// media 原片已 CompletedFail 时由 submit 立即回写,不必等这 2 天。 +func MarkStuckChaptersFailed() { + ctx := context.Background() + now := time.Now() + cutoff := now.Add(-watermarkStuckTimeout) + err := playletchaptermod.UpdateManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: bson.M{ + "$in": []playletchaptermod.WatermarkStatus{ + playletchaptermod.WatermarkStatusRegistered, + playletchaptermod.WatermarkStatusProcessing, + }, + }, + playletchaptermod.FN.PlayletChapter.UpdatedAt: bson.M{"$lt": cutoff}, + }, bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusFailed, + playletchaptermod.FN.PlayletChapter.UpdatedAt: now, + }}) + if err != nil && err.Code != srvdef.ErrNoData { + log.Error("playletwatermark:MarkStuckChaptersFailed 更新失败", log.E(err)) + return + } + log.Info("playletwatermark:MarkStuckChaptersFailed end", log.Any("cutoff", cutoff)) +} diff --git a/web/skd/playletwatermark/submit.go b/web/skd/playletwatermark/submit.go new file mode 100644 index 0000000..3ad57d2 --- /dev/null +++ b/web/skd/playletwatermark/submit.go @@ -0,0 +1,367 @@ +package playletwatermark + +import ( + "context" + "errors" + "net/url" + "path" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/cfg" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + submitBatchSize = 10 + taskAddRetryBatch = 20 + mediaFileVersionV1 = "v1" + mediaFileVersionV2 = "v2" +) + +var ( + submitRunning bool + submitLock sync.Mutex +) + +// SubmitPendingChapters 定时提交未加水印剧集到 media(每10分钟最多10条) +func SubmitPendingChapters() { + submitLock.Lock() + if submitRunning { + submitLock.Unlock() + return + } + submitRunning = true + submitLock.Unlock() + defer func() { + submitLock.Lock() + submitRunning = false + submitLock.Unlock() + }() + + ctx := context.Background() + log.Info("playletwatermark:SubmitPendingChapters begin") + //MarkStuckChaptersFailed() + + sysCfg, err := cfg.FindOneSysCfg(ctx, bson.M{cfg.FN.SysCfg.Id: 1}) + if err != nil || sysCfg == nil { + log.Error("playletwatermark:SubmitPendingChapters 读取系统配置失败", log.E(err)) + return + } + if !hasVideoWatermark(sysCfg) { + log.Warn("playletwatermark:SubmitPendingChapters 水印素材未配置,跳过") + return + } + cfg.CacheAppKey(sysCfg.AppKey) + + registerPendingSources(ctx) + submitWatermarkTasks(ctx, sysCfg) + log.Info("playletwatermark:SubmitPendingChapters end") +} + +// registerPendingSources 将未处理剧集的相对 m3u8 登记到 media +func registerPendingSources(ctx context.Context) { + list, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: bson.M{ + "$in": []playletchaptermod.WatermarkStatus{ + playletchaptermod.WatermarkStatusPending, + playletchaptermod.WatermarkStatusRegistered, + }, + }, + playletchaptermod.FN.PlayletChapter.VideoUrl: bson.M{"$gt": ""}, + "$or": []bson.M{ + {playletchaptermod.FN.PlayletChapter.MediaSourceId: bson.M{"$exists": false}}, + {playletchaptermod.FN.PlayletChapter.MediaSourceId: ""}, + }, + }, options.Find(). + SetSort(bson.D{{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}}). + SetLimit(submitBatchSize)) + if err != nil { + log.Error("playletwatermark:registerPendingSources 查询失败", log.E(err)) + return + } + for _, item := range list { + if item == nil { + continue + } + sourceID, syncErr := RegisterChapterSource(item) + if syncErr != nil { + log.Error("playletwatermark:registerPendingSources SyncMediaFile 失败", + log.Any("chapterId", item.Id), log.Any("videoUrl", item.VideoUrl), log.E(syncErr)) + continue + } + now := time.Now() + if updateErr := playletchaptermod.UpdateOnePlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.Id: item.Id}, + bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.MediaSourceId: sourceID, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusRegistered, + playletchaptermod.FN.PlayletChapter.UpdatedAt: now, + }}); updateErr != nil { + log.Error("playletwatermark:registerPendingSources 更新章节失败", + log.Any("chapterId", item.Id), log.E(updateErr)) + } + } +} + +// RegisterChapterSource 将剧集原片登记到 media 切片服,返回 mediaSourceId。 +func RegisterChapterSource(item *playletchaptermod.PlayletChapter) (string, error) { + if item == nil { + return "", errors.New("chapter is nil") + } + fieldPath, version := normalizeMediaFilePath(item.VideoUrl) + if fieldPath == "" { + return "", errors.New("invalid video path") + } + sourceID := primitive.NewObjectID() + req := &proxy.MediaFileSyncReq{ + AppId: pubdef.AppId, + AppKey: cfg.CachedAppKey(context.Background()), + VideoList: []proxy.MediaFileSyncItem{ + { + ID: sourceID.Hex(), + Title: mediaWatermarkTitle(playletForChapter(item), item), + TaskID: sourceID.Hex(), + FieldNameFs: fieldPath, + Width: item.Width, + Height: item.Height, + VideoTime: item.MediaTime, + Size: item.MediaSize, + MediaFileVersion: version, + IsPlaylet: true, + }, + }, + } + result, syncErr := proxy.SyncMediaFile(req, "") + if syncErr != nil { + return "", syncErr + } + if result == nil || result.Code != proxy.Success { + return "", errors.New("SyncMediaFile failed") + } + return sourceID.Hex(), nil +} + +// submitWatermarkTasks 对已登记原片尝试 task/add,成功后进入处理中 +func submitWatermarkTasks(ctx context.Context, sysCfg *cfg.SysCfg) { + list, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusRegistered, + playletchaptermod.FN.PlayletChapter.MediaSourceId: bson.M{"$gt": ""}, + }, options.Find(). + SetSort(bson.D{{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}}). + SetLimit(taskAddRetryBatch)) + if err != nil { + log.Error("playletwatermark:submitWatermarkTasks 查询失败", log.E(err)) + return + } + for _, item := range list { + if item == nil || item.MediaSourceId == "" { + continue + } + if strings.TrimSpace(item.MediaTaskId) != "" { + if !restartWatermarkTask(ctx, item, sysCfg) { + continue + } + } else if !addWatermarkTask(ctx, item, sysCfg) { + continue + } + _ = playletchaptermod.UpdateOnePlayletChapter(ctx, + bson.M{ + playletchaptermod.FN.PlayletChapter.Id: item.Id, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusRegistered, + }, + bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusProcessing, + playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now(), + }}) + } +} + +func addWatermarkTask(ctx context.Context, item *playletchaptermod.PlayletChapter, sysCfg *cfg.SysCfg) bool { + req := &proxy.MediaTaskAddReq{ + MediaSourceId: item.MediaSourceId, + AppId: pubdef.AppId, + AppKey: strings.TrimSpace(sysCfg.AppKey), + Title: mediaWatermarkTitle(playletForChapter(item), item), + WatermarkImage: sysCfg.WatermarkImage, + WatermarkMedia: sysCfg.WatermarkMedia, + WatermarkMediaTop: sysCfg.WatermarkMediaTop, + WatermarkMediaTopRight: sysCfg.WatermarkMediaTopRight, + WatermarkMediaBottomLeft: sysCfg.WatermarkMediaBottomLeft, + } + result, addErr := proxy.AddMediaTask(req, "") + if addErr != nil || result == nil { + log.Info("playletwatermark:submitWatermarkTasks task/add 请求失败", + log.Any("chapterId", item.Id), + log.Any("mediaSourceId", item.MediaSourceId), + log.E(addErr)) + return false + } + // 200=新建成功;6007=任务已存在。原片未处理完时保持已登记,下轮再试。 + if result.Code != proxy.Success && result.Code != 6007 { + log.Info("playletwatermark:submitWatermarkTasks task/add 暂未成功", + log.Any("chapterId", item.Id), + log.Any("mediaSourceId", item.MediaSourceId), + log.Any("result", result)) + markFailedIfMediaSourceFailed(ctx, item) + return false + } + return true +} + +func restartWatermarkTask(ctx context.Context, item *playletchaptermod.PlayletChapter, sysCfg *cfg.SysCfg) bool { + watermarkImage := sysCfg.WatermarkImage + watermarkMedia := sysCfg.WatermarkMedia + watermarkMediaTop := sysCfg.WatermarkMediaTop + watermarkMediaTopRight := sysCfg.WatermarkMediaTopRight + watermarkMediaBottomLeft := sysCfg.WatermarkMediaBottomLeft + req := &proxy.MediaTaskUpdateWatermarkReq{ + Id: item.MediaTaskId, + AppId: pubdef.AppId, + AppKey: strings.TrimSpace(sysCfg.AppKey), + WatermarkImage: &watermarkImage, + WatermarkMedia: &watermarkMedia, + WatermarkMediaTop: &watermarkMediaTop, + WatermarkMediaTopRight: &watermarkMediaTopRight, + WatermarkMediaBottomLeft: &watermarkMediaBottomLeft, + } + result, updateErr := proxy.UpdateMediaWatermark(req, "") + if updateErr != nil || result == nil { + log.Info("playletwatermark:submitWatermarkTasks update/watermark 请求失败", + log.Any("chapterId", item.Id), + log.Any("mediaTaskId", item.MediaTaskId), + log.E(updateErr)) + return false + } + if result.Code != proxy.Success { + log.Info("playletwatermark:submitWatermarkTasks update/watermark 暂未成功", + log.Any("chapterId", item.Id), + log.Any("mediaTaskId", item.MediaTaskId), + log.Any("result", result)) + markFailedIfMediaSourceFailed(ctx, item) + return false + } + return true +} + +func markFailedIfMediaSourceFailed(ctx context.Context, item *playletchaptermod.PlayletChapter) { + if item == nil || item.MediaSourceId == "" { + return + } + source, err := proxy.QueryMediaSource(item.MediaSourceId, pubdef.AppId, "") + if err != nil { + log.Info("playletwatermark:markFailedIfMediaSourceFailed 查询原片失败", + log.Any("chapterId", item.Id), + log.Any("mediaSourceId", item.MediaSourceId), + log.E(err)) + return + } + if source == nil || source.Status != proxy.MediaSourceCompletedFail { + return + } + now := time.Now() + if updateErr := playletchaptermod.UpdateOnePlayletChapter(ctx, + bson.M{ + playletchaptermod.FN.PlayletChapter.Id: item.Id, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: bson.M{ + "$in": []playletchaptermod.WatermarkStatus{ + playletchaptermod.WatermarkStatusRegistered, + playletchaptermod.WatermarkStatusProcessing, + }, + }, + }, + bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusFailed, + playletchaptermod.FN.PlayletChapter.UpdatedAt: now, + }}); updateErr != nil { + log.Error("playletwatermark:markFailedIfMediaSourceFailed 更新章节失败", + log.Any("chapterId", item.Id), + log.E(updateErr)) + return + } + log.Info("playletwatermark:markFailedIfMediaSourceFailed 原片已失败,章节标为处理失败", + log.Any("chapterId", item.Id), + log.Any("mediaSourceId", item.MediaSourceId), + log.Any("failReason", source.FailReason)) +} + +// normalizeMediaFilePath 将相对/绝对 m3u8 地址规范化为 media 可识别的相对路径(不含 .m3u8) +func normalizeMediaFilePath(raw string) (fieldPath string, version string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + version = mediaFileVersionV2 + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + u, err := url.Parse(raw) + if err != nil { + return "", "" + } + raw = u.Path + } + raw = strings.TrimPrefix(raw, "/") + if strings.HasPrefix(raw, "v1/") { + version = mediaFileVersionV1 + } + if strings.HasSuffix(strings.ToLower(raw), ".m3u8") { + raw = strings.TrimSuffix(raw, ".m3u8") + raw = strings.TrimSuffix(raw, ".M3U8") + } + raw = path.Clean(raw) + if raw == "." || raw == "" { + return "", "" + } + return raw, version +} + +// mediaWatermarkTitle 传给 media 的标题:短剧标题 + "-" + 剧集标题 +func mediaWatermarkTitle(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string { + playletName := "" + if playlet != nil { + playletName = strings.TrimSpace(playlet.Title) + } + episodeTitle := "" + if chapter != nil { + episodeTitle = strings.TrimSpace(chapter.Title) + } + if playletName != "" && episodeTitle != "" { + return playletName + "-" + episodeTitle + } + if playletName != "" { + return playletName + } + return episodeTitle +} + +func playletForChapter(item *playletchaptermod.PlayletChapter) *playletmod.Playlet { + if item == nil || item.PlayletId <= 0 { + return nil + } + playlet, err := playletmod.FindOnePlaylet(context.Background(), bson.M{ + playletmod.FN.Playlet.Id: item.PlayletId, + }, options.FindOne().SetProjection(bson.M{ + playletmod.FN.Playlet.Title: 1, + })) + if err != nil { + return nil + } + return playlet +} + +func hasVideoWatermark(sysCfg *cfg.SysCfg) bool { + if sysCfg == nil { + return false + } + return strings.TrimSpace(sysCfg.WatermarkMedia) != "" || + strings.TrimSpace(sysCfg.WatermarkMediaTop) != "" || + strings.TrimSpace(sysCfg.WatermarkMediaTopRight) != "" || + strings.TrimSpace(sysCfg.WatermarkMediaBottomLeft) != "" +} diff --git a/web/skd/playletwatermark/sync.go b/web/skd/playletwatermark/sync.go new file mode 100644 index 0000000..cf22f07 --- /dev/null +++ b/web/skd/playletwatermark/sync.go @@ -0,0 +1,284 @@ +package playletwatermark + +import ( + "context" + "net/http" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/syncfilemod" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + syncPageSize int64 = 1000 + syncOverlapWindow = 10 * time.Minute + reconcileBatchSize int64 = 100 +) + +var ( + syncRunning bool + syncLock sync.Mutex +) + +// SyncCompletedChapters 定时从 media 拉取已完成水印任务,回写剧集地址与状态 +func SyncCompletedChapters() { + syncLock.Lock() + if syncRunning { + syncLock.Unlock() + return + } + syncRunning = true + syncLock.Unlock() + defer func() { + syncLock.Lock() + syncRunning = false + syncLock.Unlock() + }() + + ctx := context.Background() + log.Info("playletwatermark:SyncCompletedChapters begin") + + record, err := ensureSyncRecord(ctx) + if err != nil { + log.Error("playletwatermark:SyncCompletedChapters 获取同步水印失败", log.E(err)) + return + } + + recordTime, syncOK := syncIncrementalCompletedChapters(ctx, record.RecordTime) + if syncOK { + if updateErr := syncfilemod.UpdateOneSyncRecord(ctx, bson.M{ + syncfilemod.FN.SyncRecord.Job: syncfilemod.PlayletWatermarkJob, + }, bson.M{"$set": bson.M{ + syncfilemod.FN.SyncRecord.RecordTime: recordTime, + syncfilemod.FN.SyncRecord.SuccessAt: time.Now(), + syncfilemod.FN.SyncRecord.UpdatedAt: time.Now(), + }}); updateErr != nil { + log.Error("playletwatermark:SyncCompletedChapters 更新同步水印失败", log.E(updateErr)) + } + } + + reconcileProcessingChapters(ctx) + log.Info("playletwatermark:SyncCompletedChapters end", + log.Any("recordTime", recordTime), + log.Any("syncOK", syncOK)) +} + +// syncIncrementalCompletedChapters 按更新时间水印增量拉取。 +// 每次回退一段时间重复扫描,避免临界时间及同一时间戳分页造成漏数据;章节回写是幂等的。 +func syncIncrementalCompletedChapters(ctx context.Context, recordTime time.Time) (time.Time, bool) { + cursor := recordTime + if !cursor.IsZero() { + cursor = cursor.Add(-syncOverlapWindow) + } + maxRecordTime := recordTime + for { + req := proxy.MediaTaskSyncReq{ + AppId: pubdef.AppId, + LastUpdateTime: cursor, + PageSize: syncPageSize, + } + result, syncErr := proxy.SyncMedia(&req, "") + if syncErr != nil || result == nil || result.Code != http.StatusOK { + log.Error("playletwatermark:SyncCompletedChapters SyncMedia 失败", log.Any("req", req), log.E(syncErr)) + return recordTime, false + } + pageMaxTime := cursor + for _, media := range result.Data { + if media.UpdatedAt.After(pageMaxTime) { + pageMaxTime = media.UpdatedAt + } + if media.UpdatedAt.After(maxRecordTime) { + maxRecordTime = media.UpdatedAt + } + applyCompletedMedia(ctx, media, "incremental") + } + if int64(len(result.Data)) < syncPageSize { + break + } + if !pageMaxTime.After(cursor) { + log.Error("playletwatermark:SyncCompletedChapters 增量分页水印未推进", + log.Any("cursor", cursor), + log.Any("count", len(result.Data))) + return recordTime, false + } + cursor = pageMaxTime + } + return maxRecordTime, true +} + +// reconcileProcessingChapters 反向对账所有处理中章节。 +// 它不依赖增量水印,可补偿本地更新失败、时间戳临界和历史水印越过造成的漏回写。 +func reconcileProcessingChapters(ctx context.Context) { + var lastID int64 + for { + list, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: bson.M{"$gt": lastID}, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusProcessing, + playletchaptermod.FN.PlayletChapter.MediaSourceId: bson.M{"$gt": ""}, + }, options.Find(). + SetSort(bson.D{{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}}). + SetLimit(reconcileBatchSize)) + if err != nil { + log.Error("playletwatermark:reconcileProcessingChapters 查询章节失败", log.E(err)) + return + } + if len(list) == 0 { + return + } + + sourceIDs := make([]string, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + sourceIDs = append(sourceIDs, item.MediaSourceId) + if item.Id > lastID { + lastID = item.Id + } + } + if len(sourceIDs) > 0 { + result, queryErr := proxy.QueryMedias(&proxy.MediaQueryesReq{ + AppId: pubdef.AppId, + MediaSourceIds: sourceIDs, + }, "") + if queryErr != nil || result == nil || result.Code != http.StatusOK { + log.Error("playletwatermark:reconcileProcessingChapters 查询 media 任务失败", + log.Any("sourceCount", len(sourceIDs)), + log.Any("result", result), + log.E(queryErr)) + return + } + for _, media := range result.Data { + applyCompletedMedia(ctx, media, "reconcile") + } + } + if int64(len(list)) < reconcileBatchSize { + return + } + } +} + +func applyCompletedMedia(ctx context.Context, media proxy.MediaResp, source string) bool { + sourceID := media.MediaSourceId.Hex() + if sourceID == "" || sourceID == "000000000000000000000000" { + log.Warn("playletwatermark:applyCompletedMedia mediaSourceId 无效", + log.Any("mediaTaskId", media.Id.Hex()), + log.Any("source", source)) + return false + } + fieldPath := strings.TrimSpace(media.FieldNameFs) + if fieldPath == "" { + log.Warn("playletwatermark:applyCompletedMedia 视频路径为空", + log.Any("mediaSourceId", sourceID), + log.Any("mediaTaskId", media.Id.Hex()), + log.Any("source", source)) + return false + } + if !strings.HasSuffix(strings.ToLower(fieldPath), ".m3u8") { + fieldPath += ".m3u8" + } + taskID := media.Id.Hex() + setDoc := bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl: fieldPath, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusDone, + playletchaptermod.FN.PlayletChapter.MediaTaskId: taskID, + playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now(), + } + if sourceName := strings.TrimSpace(media.SourceNameFs); sourceName != "" && + isOriginalPlayletBySourceID(ctx, sourceID) { + // 原创才回填原片地址,供换水印/重置复用;非原创保留媒资原始 VideoUrl。 + setDoc[playletchaptermod.FN.PlayletChapter.VideoUrl] = sourceName + } + updateErr := playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.MediaSourceId: sourceID, + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusProcessing, + }, bson.M{"$set": setDoc}) + if updateErr == nil { + log.Info("playletwatermark:applyCompletedMedia 回写成功", + log.Any("mediaSourceId", sourceID), + log.Any("mediaTaskId", taskID), + log.Any("watermarkVideoUrl", fieldPath), + log.Any("source", source)) + return true + } + if updateErr.Code != srvdef.ErrNoData { + log.Error("playletwatermark:applyCompletedMedia 更新章节失败", + log.Any("mediaSourceId", sourceID), + log.Any("source", source), + log.E(updateErr)) + return false + } + + chapter, findErr := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.MediaSourceId: sourceID, + }) + if findErr != nil { + if findErr.Code != srvdef.ErrNoData { + log.Error("playletwatermark:applyCompletedMedia 查询未匹配章节失败", + log.Any("mediaSourceId", sourceID), + log.E(findErr)) + } else { + log.Warn("playletwatermark:applyCompletedMedia 未找到对应章节", + log.Any("mediaSourceId", sourceID), + log.Any("mediaTaskId", taskID), + log.Any("source", source)) + } + return false + } + if chapter.WatermarkStatus != playletchaptermod.WatermarkStatusDone { + log.Warn("playletwatermark:applyCompletedMedia 章节状态不允许回写", + log.Any("chapterId", chapter.Id), + log.Any("mediaSourceId", sourceID), + log.Any("watermarkStatus", chapter.WatermarkStatus), + log.Any("source", source)) + } + return false +} + +func isOriginalPlayletBySourceID(ctx context.Context, sourceID string) bool { + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.MediaSourceId: sourceID, + }, options.FindOne().SetProjection(bson.M{ + playletchaptermod.FN.PlayletChapter.PlayletId: 1, + })) + if err != nil || chapter == nil || chapter.PlayletId <= 0 { + return false + } + playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: chapter.PlayletId, + }, options.FindOne().SetProjection(bson.M{ + playletmod.FN.Playlet.IsOriginal: 1, + })) + return err == nil && playlet != nil && playlet.IsOriginal +} + +func ensureSyncRecord(ctx context.Context) (*syncfilemod.SyncRecord, *srvdef.SrvErr) { + filter := bson.M{syncfilemod.FN.SyncRecord.Job: syncfilemod.PlayletWatermarkJob} + record, err := syncfilemod.FindOneSyncRecord(ctx, filter) + if err == nil { + return record, nil + } + if err.Code != srvdef.ObjectDayStatDoesNotExist { + return nil, err + } + now := time.Now() + record = &syncfilemod.SyncRecord{ + Job: syncfilemod.PlayletWatermarkJob, + RecordTime: time.Time{}, + CreatedAt: now, + UpdatedAt: now, + } + if insertErr := syncfilemod.InsertOneSyncRecord(ctx, record); insertErr != nil { + return nil, insertErr + } + return record, nil +} diff --git a/web/skd/skd.go b/web/skd/skd.go new file mode 100644 index 0000000..b58bb94 --- /dev/null +++ b/web/skd/skd.go @@ -0,0 +1,86 @@ +package skd + +import ( + "context" + "math/rand" + "time" + "videoweb/pub/common/log" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/skd/job/reportdata" + seopushjob "videoweb/web/skd/job/seopush" + "videoweb/web/skd/playletwatermark" + "videoweb/web/skd/videosync" + + "github.com/robfig/cron/v3" +) + +var ( + skdCron *cron.Cron +) + +func init() { + skdCron = cron.New(cron.WithSeconds()) // 秒级定时任务 +} + +func Start() { + log.Info("skd start...") + rand.Seed(time.Now().UnixNano()) + + // 视频同步 + _, _ = skdCron.AddFunc("0 */10 * * * ?", func() { + videosync.VideoSync() + }) + + // 短剧水印:每5分钟提交最多10条未处理剧集到 media + _, _ = skdCron.AddFunc("0 */5 * * * ?", func() { + playletwatermark.SubmitPendingChapters() + }) + // 短剧水印:每3分钟拉取 media 已完成任务并回写剧集 + _, _ = skdCron.AddFunc("0 */3 * * * ?", func() { + playletwatermark.SyncCompletedChapters() + }) + // 连载中短剧:每60分钟扫描一次并投递到 Redis 队列,由后台消费者逐条同步 + _, _ = skdCron.AddFunc(srv_mediaresource.PlayletSerialSyncCronSpec, func() { + srv_mediaresource.EnqueueSerialPlayletSyncJobs() + }) + // 历史短剧压缩封面:每5分钟扫描压缩封面为空的记录并投递队列 + _, _ = skdCron.AddFunc(srv_mediaresource.PlayletCoverCompressCronSpec, func() { + srv_mediaresource.EnqueuePlayletCoverCompressJobs() + }) + _, _ = skdCron.AddFunc(srv_mediaresource.AIContentBackflowCronSpec(), func() { + go srv_mediaresource.ReportAIContentBackflowYesterday() + }) + _, _ = skdCron.AddFunc(srv_mediaresource.AIContentBackflowTodayStatCronSpec(), func() { + go srv_mediaresource.RefreshAIContentBackflowTodayStats() + }) + srv_mediaresource.StartCGDJOriginalBackfillOnce(context.Background()) + go srv_mediaresource.RefreshAIContentBackflowTodayStats() + + _, _ = skdCron.AddFunc("0/1 * * * * ?", func() { // 每秒执行一次批量上报 + reportdata.Report() + }) + + // 搜索引擎推送兜底:补推已上架内容并重试失败任务。 + // 开关关闭或非正式环境不会真正对外提交,详见 srv_seopush.seoPushEnabled + _, _ = skdCron.AddFunc("0/30 * * * * ?", func() { + seopushjob.Run() + }) + + //if webg.Conf.LsjMediaConf.ApiUrl != "" { + // _, _ = skdCron.AddFunc("0 30 */2 * * ?", func() { + // job.TranscodeVideoByWatchTimes() + // }) + // _, _ = skdCron.AddFunc("0 */5 * * * ?", func() { + // job.CheckTranscodingResults() + // }) + //} + + skdCron.Start() +} + +func Stop() { + log.Info("srv_datinggirl:updater Stop...") + defer log.Info("srv_datinggirl: updater Stop!!!") + srv_mediaresource.CloseAIContentBackflowClient() + skdCron.Stop() +} diff --git a/web/skd/videosync/info.go b/web/skd/videosync/info.go new file mode 100644 index 0000000..cf0e092 --- /dev/null +++ b/web/skd/videosync/info.go @@ -0,0 +1,74 @@ +package videosync + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" + "time" + "videoweb/pub/models/videoinformationmod" +) + +type MediaTaskSyncReq struct { + AppId int64 `bson:"appId" json:"appId"` // APPID + LastUpdateTime time.Time `bson:"lastUpdateTime" json:"lastUpdateTime"` // 最后更新时间 + PageSize int64 `bson:"pageSize" json:"pageSize"` +} + +type MediaTaskSyncResp struct { + Code int `json:"code,omitempty"` // + Data []MediaTaskInfo `json:"data,omitempty"` // + Msg string `json:"msg,omitempty"` // +} + +// @table:media +type MediaTaskInfo struct { + Id primitive.ObjectID `bson:"_id,omitempty" json:"id"` + MediaSourceId primitive.ObjectID `bson:"mediaSourceId,omitempty" json:"mediaSourceId"` // 原始Id + VideoTime int32 `bson:"videoTime,omitempty" json:"videoTime"` // 影片长度 + Size int64 `bson:"size,omitempty" json:"size"` // 文件size,单位byte + AppId int64 `bson:"appId,omitempty" json:"appId"` // 应用列表 + CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面 + CoverImgNew string `bson:"coverImgNew,omitempty" json:"coverImgNew"` // 动态封面 + UnencryptedImage string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面 + Title string `bson:"title,omitempty" json:"title"` // 标题 + Publish string `bson:"publish,omitempty" json:"publish"` // 视频发布者 + Remark string `bson:"remark,omitempty" json:"remark"` // 备注 + Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字 + + Nodes []videoinformationmod.Node `bson:"nodes" json:"nodes"` // 节点 + FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签 + SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签 + Status string `bson:"status,omitempty" json:"status"` // 当前状态 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + TaskStartTime time.Time `bson:"taskStartTime,omitempty" json:"taskStartTime"` // 任务开始时间 + SourceNameFs string `bson:"sourceNameFs,omitempty" json:"sourceNameFs"` // 远程源文件 + IsDelLocalDir bool `bson:"isDelLocalDir,omitempty" json:"isDelLocalDir"` // 是否删除本机文件 + WatermarkImage string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件 + WatermarkMedia string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件 + DownloadRetry int `bson:"downloadRetry,omitempty" json:"downloadRetry"` // 下载重试次数 + WatermarkRetry int `bson:"watermarkRetry" json:"watermarkRetry"` // 水印重试次数 + CompressRetry int `bson:"compressRetry" json:"compressRetry"` // 压缩重试次数 + SegmentRetry int `bson:"segmentRetry" json:"segmentRetry"` // 切片重试次数 + LinodeRetry int `bson:"linodeRetry" json:"linodeRetry"` // 上传重试次数 + + // 切片相关信息 + Filename string `bson:"filename,omitempty" json:"filename"` // 文件名称 + FieldNameFs string `bson:"fieldNameFs,omitempty" json:"fieldNameFs"` // 文件名称 + UnencryptedPreMp4 string `bson:"unencryptedPreMp4,omitempty" json:"unencryptedPreMp4"` // 不加密的预览MP4 + TempFieldNameFs string `bson:"tempFieldNameFs,omitempty" json:"tempFieldNameFs"` // 临时存储 + Ext string `bson:"ext,omitempty" json:"ext"` // 视频后缀 + TaskID string `bson:"taskId,omitempty" json:"taskId"` // 项目组传来的唯一标示 + Width int64 `bson:"width,omitempty" json:"width"` // 分辨率宽 + Height int64 `bson:"height,omitempty" json:"height"` // 分辨率高 + Ratio float64 `bson:"ratio,omitempty" json:"ratio"` // 宽高比 + FrameRate string `bson:"frameRate,omitempty" json:"frameRate"` // 视频贞数 + SyBitRate string `bson:"syBitRate,omitempty" json:"syBitRate"` // 音频bit率 + VidBitRate string `bson:"vidBitRate,omitempty" json:"vidBitRate"` // 视频bit率 + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt"` // 创建时间 + UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updatedAt"` // 更新时间 + ProxyUpload *int32 `bson:"proxyUpload,omitempty" json:"proxyUpload"` // 代理上传 + Retry int `bson:"retry" json:"retry"` // 重试次数 默认为零 最大重试3次 + NotifySignal bool `bson:"notifySignal" json:"notifySignal"` //中止通知的信号 false 可以发送通知,true 中止发送通知 + + InfoTsName string `bson:"infoTsName" json:"infoTsName"` // 从该ts中获取视频分辨率等信息 + SyncFlag int64 `bson:"syncFlag" json:"syncFlag"` // 同步状态 + SrcKey string `bson:"srcKey,omitempty" json:"srcKey"` // 原始Key +} diff --git a/web/skd/videosync/videosync.go b/web/skd/videosync/videosync.go new file mode 100644 index 0000000..62c1d87 --- /dev/null +++ b/web/skd/videosync/videosync.go @@ -0,0 +1,111 @@ +package videosync + +import ( + "context" + nethttp "net/http" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/syncfilemod" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" + "videoweb/web/service/srv_videoinfo" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +var ( + isRunning = false + lock sync.Mutex +) + +func getIsRunning() bool { + lock.Lock() + defer lock.Unlock() + return isRunning +} +func setRunning(b bool) { + lock.Lock() + defer lock.Unlock() + isRunning = b +} +func VideoSync() { + isRunning = getIsRunning() + if isRunning { + return + } + setRunning(true) + ctx := context.Background() + log.Info("VideoSync begin") + pageSize := int64(200) + recordFilter := bson.M{ + syncfilemod.FN.SyncRecord.Job: syncfilemod.VideoJob, + } + recordInfo, serr := syncfilemod.FindOneSyncRecord(ctx, recordFilter) + if serr != nil { + if serr.Code == srvdef.ObjectDayStatDoesNotExist { + log.Info("VideoSync init record", log.Any("job", syncfilemod.VideoJob)) + syncfilemod.InsertOneSyncRecord(ctx, &syncfilemod.SyncRecord{ + ID: primitive.NewObjectID(), + Job: syncfilemod.VideoJob, + RecordId: 1, + RecordTime: time.Time{}, + SuccessAt: time.Time{}, + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + }) + } else { + log.Error("VideoSync FindOneSyncRecord failed", log.Any("recordFilter", recordFilter), log.E(serr)) + } + setRunning(false) + return + } + defer func() { + setRunning(false) + // 更新同步记录 + set := bson.M{ + "$set": bson.M{ + syncfilemod.FN.SyncRecord.RecordTime: recordInfo.RecordTime, + syncfilemod.FN.SyncRecord.SuccessAt: time.Now(), + }, + } + _ = syncfilemod.UpdateOneSyncRecord(ctx, recordFilter, set) + }() + // 循环同步 + for { + // 查询接口 + req := proxy.MediaTaskSyncReq{ + AppId: pubdef.AppId, + LastUpdateTime: recordInfo.RecordTime, + PageSize: pageSize, + } + result, err := proxy.SyncMedia(&req, "") + if err != nil || result.Code != nethttp.StatusOK { + log.Error("VideoSync SyncMedia", log.Any("req", req), log.E(err)) + return + } + + // 处理 + for _, media := range result.Data { + if srv_videoinfo.IsPlayletMedia(ctx, media) { + log.Info("VideoSync skip playlet media", log.Any("mediaId", media.Id.Hex()), log.Any("mediaSourceId", media.MediaSourceId.Hex())) + } + se := srv_videoinfo.SyncOneVideo(ctx, media) + if se != nil { + log.Error("VideoSync SyncOneVideo", log.Any("media", media), log.E(se)) + return + } + if media.UpdatedAt.After(recordInfo.RecordTime) { + recordInfo.RecordTime = media.UpdatedAt + } + } + + if len(result.Data) < int(pageSize) { + log.Info("VideoSync success", log.Any("RecordTime", recordInfo.RecordTime)) + break + } + } + log.Info("VideoSync success", log.Any("RecordTime", recordInfo.RecordTime)) +} diff --git a/web/skd/videoupdater/videoupdater.go b/web/skd/videoupdater/videoupdater.go new file mode 100644 index 0000000..5c419c6 --- /dev/null +++ b/web/skd/videoupdater/videoupdater.go @@ -0,0 +1,84 @@ +package videoupdater + +import ( + "context" + "math/rand" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const _topN = 20 + +// RefreshTopVideosUpdatedAt 遍历所有启用的分类,取排名靠前的20个资源, +// 将 PageUpdatedAt 更新为前一个小时内的随机时间 +func RefreshTopVideosUpdatedAt() { + ctx := context.Background() + log.Info("videoupdater RefreshTopVideosUpdatedAt start") + defer log.Info("videoupdater RefreshTopVideosUpdatedAt end") + + // 查询所有启用的分类 + catFilter := bson.M{ + videocategorymod.FN.VideoCategory.Status: true, + } + catOpts := &options.FindOptions{} + catOpts.SetLimit(500) + categories, serr := videocategorymod.FindManyVideoCategorys(ctx, catFilter, catOpts) + if serr != nil { + log.Error("videoupdater FindManyVideoCategorys failed", log.Any("err", serr)) + return + } + + now := time.Now() + oneHourAgo := now.Add(-1 * time.Hour) + + for _, cat := range categories { + updateCategoryTopVideos(ctx, cat, oneHourAgo, now) + } +} + +func updateCategoryTopVideos(ctx context.Context, cat *videocategorymod.VideoCategory, rangeStart, rangeEnd time.Time) { + // mdcom 的 VideoInformation.CategoryId 是单值,直接按等值匹配 + videoFilter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, + videoinformationmod.FN.VideoInformation.AddedTime: bson.M{"$lt": rangeEnd}, + videoinformationmod.FN.VideoInformation.CategoryId: cat.Id, + } + + videoOpt := &options.FindOptions{} + videoOpt.SetSort(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Sort, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, + }) + videoOpt.SetLimit(_topN) + + videos, serr := videoinformationmod.FindManyVideoInformations(ctx, videoFilter, videoOpt) + if serr != nil { + log.Warn("videoupdater FindManyVideoInformations failed", + log.Any("categoryId", cat.Id), log.Any("err", serr)) + return + } + + rangeDuration := rangeEnd.Sub(rangeStart) + for _, v := range videos { + randomOffset := time.Duration(rand.Int63n(int64(rangeDuration))) + randomTime := rangeStart.Add(randomOffset) + + filter := bson.M{videoinformationmod.FN.VideoInformation.Id: v.Id} + update := bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.PageUpdatedAt: randomTime}} + if err := videoinformationmod.UpdateOneVideoInformation(ctx, filter, update); err != nil { + log.Warn("videoupdater UpdateOneVideoInformation failed", + log.Any("videoId", v.Id), log.Any("err", err)) + } + } + + if len(videos) > 0 { + log.Info("videoupdater updated category top videos", + log.Any("categoryId", cat.Id), log.Any("categoryName", cat.Name), log.Any("count", len(videos))) + } +} diff --git a/web/webg/helper.go b/web/webg/helper.go new file mode 100644 index 0000000..d96e306 --- /dev/null +++ b/web/webg/helper.go @@ -0,0 +1 @@ +package webg diff --git a/web/webg/webg.go b/web/webg/webg.go new file mode 100644 index 0000000..7bb6241 --- /dev/null +++ b/web/webg/webg.go @@ -0,0 +1,65 @@ +package webg + +const EsIndex = "sz" + +var ( + Conf GlobalCfgEx +) + +type GlobalCfgEx struct { + Redis struct { + Addr string `yaml:"addr"` + Db int `yaml:"db"` + Passwd string `yaml:"passwd"` + Subscribe bool `yaml:"subscribe"` // 是否有订阅功能 + } + Mongodb struct { + Addr string `yaml:"addr"` + LogAddr string `yaml:"logaddr"` + } + Es struct { + Addr string `yaml:"addr"` + User string `yaml:"user"` + Pass string `yaml:"pass"` + } `yaml:"es"` + JwtKey string `yaml:"jwtKey"` + CdnKey string `yaml:"cdnKey"` + TgToken string `yaml:"tgToken"` + RunMod string `yaml:"runMod"` + Pprof bool `yaml:"pprof"` + Host string `yaml:"host"` + ListenAddr string `yaml:"listenAddr"` + VersionUrl string `yaml:"versionUrl"` + CdnDomain string `yaml:"cdnDomain"` // cdn域名 + OrigDomain string `yaml:"origDomain"` // 源站域名 + WebM3u8Host string `yaml:"webM3u8Host"` // web对外可访问域名(供老司机转码服务器拉取m3u8) + TsAuthKey string `yaml:"tsAuthKey"` + BucketVideoAddressV2 string `yaml:"bucketVideoAddressV2"` + BucketVideoAddress string `yaml:"bucketVideoAddress"` + LsjMediaConf struct { + AppId string `yaml:"appId"` + ApiKey string `yaml:"apiKey"` + ApiUrl string `yaml:"apiUrl"` + M3u8Yuan string `yaml:"m3u8Yuan"` + TsYuan string `yaml:"tsYuan"` + ImageYuan string `yaml:"imageYuan"` + DomainsRefreshHr int64 `yaml:"domainsRefreshHr"` + } `yaml:"lsjMediaConf"` + MediaResourceDBCfg struct { + BaseUrl string `yaml:"baseUrl"` // 域名 + Merchat string `yaml:"merchat"` // 商户号 + Secret string `yaml:"secret"` // 商户密钥 + } `yaml:"mediaResourceDBCfg"` + NewInnerStatUrl string `yaml:"newInnerStatUrl"` + DataReport struct { + Url string `yaml:"url"` + } `yaml:"dataReport"` + DataCenterBackflow struct { + Key string `yaml:"key"` + SelfSourceBaseUrl string `yaml:"selfSourceBaseUrl"` + } `yaml:"dataCenterBackflow"` +} + +func IsDev() bool { + return Conf.RunMod == "dev" || Conf.RunMod == "debug" +}